有兴趣了解我们的下一本书?了解更多关于 使用 React 构建大型 JavaScript Web 应用程序

渲染模式

动画视图过渡

注意: 单页应用程序的视图过渡 API 在 Chrome 111+ 中可用。

视图过渡简介

视图过渡 API 提供了一种简单的方法来过渡任何视觉 DOM 更改,从一个状态到下一个状态。这可能包括小的更改,例如切换一些内容,或者更大的更改,例如从一个页面导航到另一个页面。以下是一个 演示 在 SPA(单页应用程序)中使用视图过渡 API 的方法 src

JavaScript API 围绕 document.startViewTransition(callback) 展开,其中 callback 是一个函数,通常会更新 DOM 到新的状态。

让我们以切换 <details> 元素为例。

if (document.startViewTransition) {
  // (check for browser support)
  document.addEventListener("click", function (event) {
    if (event.target.matches("summary")) {
      event.preventDefault(); // (we'll toggle the element ourselves)
      const details = event.target.closest("details");
      document.startViewTransition(() => details.toggleAttribute("open"));
    }
  });
}

document.startViewTransition 在调用回调之前会拍摄当前 DOM 的快照。在这里,我们的回调只是切换 open 属性。完成后,浏览器就可以在初始快照和新版本之间进行过渡。


这些旧版本和新版本以伪元素的形式呈现,可以使用 CSS 中的 ::view-transition-old(root)::view-transition-new(root) 分别引用它们。例如,为了强调过渡,我们可以像这样延长 animation-duration

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 2s;
}

视图过渡还能够为更高级的动画动画化多个更改,这些动画超越了默认的交叉淡入淡出。通过为特定元素提供 CSS view-transition-name,以及 containmentlayoutpaint,API 让开发者可以精细控制元素的过渡方式,包括它们的宽度、高度和位置。这些高级过渡可以真正帮助传达从一个页面到另一个页面的流程。

以这个 图片库 为例。

最明显的过渡是图片的大小和位置,当每个页面上的 <img> 元素都被赋予相同的唯一 view-transition-name,以及 CSS containment 值为 layout 时,会自动实现这种过渡。在这个演示中,view-transition-name 是在样式属性中硬编码的,但你也可以动态地添加它们(例如,在 onclick 处理程序中),只要它们对页面是唯一的,并且在开始过渡之前添加即可。

图片下方细节需要一些额外的样式。如果你注意到,每行细节都有一个分阶段的滑入/滑出动画。

我们为每个行元素赋予它自己的 view-transition-name

figcaption h2 {
  contain: layout;
  view-transition-name: photo-heading;
}
figcaption div {
  contain: layout;
  view-transition-name: photo-location-time;
}
figcaption dl {
  contain: layout;
  view-transition-name: photo-meta;
}

这将为每个区域生成过渡组,就像前面提到的新/旧快照一样,但只覆盖页面的一部分区域,而不是整个文档。就像整个文档过渡元素可以用 ::view-transition-old(root)::view-transition-new(root) 来定位一样,这些过渡组可以用 ::view-transition-old(NAME)::view-transition-new(NAME) 来定位。请注意,细节文本在图片网格页面上不存在,因此当从网格过渡到图片页面时,只会有 ::view-transition-new(NAME)而不是 ::view-transition-old(NAME),反之亦然,当以相反的方向导航时。因此,我们可以使用 :only-child 伪类定位这些情况,并自定义动画。对于 photo-heading 组。

/* Enter */
::view-transition-new(photo-heading):only-child {
  animation: 300ms ease 50ms both fade-in, 300ms ease 50ms both slide-up;
}

/* Exit */
::view-transition-old(photo-heading):only-child {
  animation: 200ms ease 150ms both fade-out, 200ms ease 150ms both slide-down;
}

这就是 API 的基础知识。 Jake Archibald 优秀的视图过渡文章 很好地涵盖了细节。现在,让我们看看如何过渡全页面导航。

一个典型的页面导航看起来像这样。

  1. 用户点击链接
  2. 请求数据
  3. DOM 用响应更新

为了在这个流程中应用视图过渡,有一些需要考虑的地方。

首先,是最大限度地减少屏幕处于冻结状态的时间。你可能已经从上面的慢速过渡示例中注意到,一旦视图过渡开始,DOM 就会在回调完成之前不可交互。如果我们在用户点击链接时开始过渡,他们可能需要等待一段时间,而且 UI 会处于冻结状态。为了最大限度地减少这种令人讨厌的情况,理想情况下,document.startViewTransition 应该在请求完成之后调用。这样,我们就可以准备好进行更改,并且 DOM 可以尽可能快地更新。

其次,我们需要确保在更新 DOM 之前已经捕获了初始 DOM 快照。当使用第三方框架中的页面导航时,我们无法完全控制渲染过程;DOM 在接收到响应时会自动更新。因此,我们没有一个可以传递给 document.startViewTransition 的独立函数,该函数可以整齐地执行 DOM 更新。我们可能需要拦截、暂停和恢复渲染,以营造一种我们只有一个更新 DOM 函数的假象。

如果从 DOM 更新回调中返回一个 promise,视图过渡 API 会在执行动画之前等待它解析。我们可以使用这个功能来处理上面提到的时间问题。

React 组件示例

为了解决上述问题,我们将创建一个 React 类组件,因为它比函数组件更容易解释流程。我们将使用以下生命周期方法来控制渲染。

  • shouldComponentUpdate: 我们将在这里返回 false 并开始视图过渡——这将为我们赢得一些时间,让快照捕获完成。
  • forceUpdate: 在快照捕获完成后手动重新渲染组件。
  • componentDidUpdate: 通知视图过渡 API DOM 已经更新。

以下是它的样子。

import { Component } from "react";

export default class ViewTransition extends Component {
  shouldComponentUpdate() {
    if (!document.startViewTransition) return true; // skip when not supported

    document.startViewTransition(() => this.#updateDOM());
    return false; // don't update the component, we'll do this manually
  }

  #updateDOM() {
    // now we know the screenshot has been taken, we can force render
    // (which skips `shouldComponentUpdate`)
    this.forceUpdate();
    // set up a promise that will resolve when the component renders
    return new Promise((resolve) => {
      this.#rendered = resolve;
    });
  }

  render() {
    return this.props.children;
  }

  #rendered = () => {};

  componentDidUpdate() {
    // resolve the `updateDOM` promise to notify the View Transition API
    // that the DOM has been updated
    this.#rendered();
  }
}

注意:Next.js 应用路由器 目前处于测试阶段,关于它和页面目录的最佳实践可能会发生变化。

要在 Next.js 应用中使用它,首先我们需要在开发环境中禁用 React 严格模式。严格模式通过两次渲染组件来运行其检查。这会干扰开发环境中 ViewTransition 的渲染流程,所以我们将全局禁用它,并使用 StrictMode 组件为子组件重新启用它。

// next.config.js
const nextConfig = {
  reactStrictMode: false,
};

module.exports = nextConfig;

接下来,在 pages/_app.js 中,我们将把 Component 包裹在我们的 ViewTransitionStrictMode 组件中,我们应该开始看到动画过渡。

// pages/_app.js
import "@/styles/globals.css";
import { StrictMode } from "react";
import ViewTransition from "@/components/ViewTransition";

export default function App({ Component, pageProps }) {
  return (
    <ViewTransition>
      <StrictMode>
        <Component {...pageProps} />
      </StrictMode>
    </ViewTransition>
  );
}

查看 Next.js 演示,我们的 实时 Next.js 演示 及其 源代码

注意: React 文档建议不要使用 shouldComponentUpdateforceUpdate,并指出它们应该只用于性能优化,并且 shouldComponentUpdate 不保证被调用。由于页面动画是一种增强功能,并且即使 shouldComponentUpdate 没有被调用,这个组件也能正常工作,所以我对这个警告没有意见。

一种没有视图过渡的替代方法

视图过渡 API 用于页面过渡的一个必要缺点是,它需要在动画化之前获得新页面的 HTML。这可能需要一些时间,并且在用户点击链接后不会给用户任何反馈。一个加载动画可以填补空白,但我们可以通过在用户点击链接时立即动画化元素,然后在 HTML 到达时动画化新 HTML 来争取一些时间。这类似于标准 iOS 导航在加载下一个屏幕时立即滑过。

  1. 用户点击链接
  2. 元素被动画化出去;同时发出数据请求
  3. 等待响应和动画都完成
  4. 动画化响应

这种方法和视图过渡 API 的主要区别在于,它无法在元素之间过渡,因为在它动画化出去时,它没有新的 HTML 来进行过渡。

两种方法都有用,具体取决于情况。例如,如果页面之间有共享元素,你可能选择视图过渡,而如果更改很大,只有少数共享元素,你可以从退出动画的即时反馈中受益。

为了实现这一点,我们需要钩入路由事件,这将取决于你使用的框架或库。特别是,我们需要在用户导航时得到通知。在 Next.js 中,我们可以使用 routeChangeStart 路由事件 来启动退出动画,但让我们看看如何在没有 Next.js、React 或完全客户端渲染的 HTML 的情况下实现这一点。

使用 Turbo 和 Turn 为服务器端渲染的多页面应用程序制作动画

注意: 视图过渡 API 计划支持多页面导航,即不使用 JavaScript。但是,JavaScript API 对于更高级的过渡可能仍然需要。

TurboHotwire 库套件的一部分(不要与 Vercel 的 Turbo 混淆),提供了一种逐步增强多页应用程序 (MPA) 的渲染方法。它旨在实现 SPA 的速度,而无需将代码架构为完全客户端渲染的应用程序。它是通过捕获链接点击和表单提交,使用 JavaScript 执行请求,并将 `<body>` 替换为响应中的新 `<body>` 来实现的。这样,它是一种混合方法:HTML 在服务器上生成,但 DOM 通过 JavaScript 更新。

Turn 是一个用于使用 Turbo 动画化页面导航的库。它支持两种动画方法(尽管当前视图过渡处于实验阶段)。Turn 在适当的时候向 `<html>` 元素添加 `turn-before-exit`、`turn-exit` 和 `turn-enter` 类,为开发者提供了一种定制动画的方法。

要使其正常工作,请将 `data-turn-exit` 和 `data-turn-enter` 属性添加到要动画化的元素中,然后应用您的 CSS 样式。例如,对于淡入/淡出

html.turn-exit [data-turn-exit] {
  animation-name: fade-out;
  animation-duration: 0.3s;
  animation-fill-mode: forwards;
}

html.turn-enter [data-turn-enter] {
  animation-name: fade-in;
  animation-duration: 0.6s;
  animation-fill-mode: forwards;
}

@keyframes fade-out {
  0% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}

@keyframes fade-in {
  0% {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
}

然后将 `Turn` 库导入到您的应用程序 JavaScript 中并调用 `Turn.start()`。

它的工作原理是挂钩到 Turbo 的渲染事件,并在需要时控制流程

  1. turbo:visit:在请求开始之前,添加 `turn-exit` 类
  2. turbo:before-render:请求完成但新 HTML 渲染之前(类似于 React 的 `shouldComponentUpdate`),暂停渲染以等待任何退出动画完成
  3. turbo:render:新 HTML 渲染后,移除 `turn-exit` 类并添加 `turn-enter` 类
  4. 退出动画完成,移除 `turn-enter` 类

Turn 还对视图过渡提供了实验性支持,通过设置 `Turn.config.experimental.viewTransitions = true` 来启用。这将在支持的情况下使用视图过渡,并在不支持的情况下回退到 CSS 动画方法。(探索如何在个案基础上切换方法正在进行中 :)

总结

页面过渡可以是传达页面之间变化的好方法。新的内置视图过渡 API 可以使用旧状态和新状态执行复杂的过渡。通过挂钩到框架事件,我们可以将这些状态更改传达给 API。对于页面导航,理想情况下,过渡应该在请求完成之后发生,以避免 DOM 处于非活动状态。

另一种(或补充)方法是在用户点击链接后立即执行退出动画。这样做的好处是,在新的 HTML 到达之前,为请求完成争取一些时间。

Team member 02

Dom Christie

软件工程师