使用视图过渡 API

本文解释了视图过渡 API 的工作原理、如何创建视图过渡和自定义过渡动画,以及如何操作活动视图过渡的理论。这涵盖了单页应用 (SPA) 中 DOM 状态更新的视图过渡,以及多页应用 (MPA) 中文档之间的导航。

视图过渡过程

让我们逐步了解视图过渡的工作过程

  1. 视图过渡被触发。其触发方式取决于视图过渡的类型

    • 对于同文档过渡 (SPA),通过将触发视图更改 DOM 更新的函数作为回调传递给 document.startViewTransition() 方法来触发视图过渡。
    • 对于跨文档过渡 (MPA),通过启动到新文档的导航来触发视图过渡。导航的当前文档和目标文档都需要位于相同的源,并通过在其 CSS 中包含一个 @view-transition at-rule,其中 navigation 描述符设置为 auto 来选择加入视图过渡。

      注意:活动的视图过渡具有关联的 ViewTransition 实例(例如,在同文档 (SPA) 过渡的情况下由 startViewTransition() 返回)。ViewTransition 对象包含多个 Promise,允许你在视图过渡过程的不同部分达到时运行代码。有关更多信息,请参阅使用 JavaScript 控制视图过渡

  2. 在当前(旧页面)视图上,API 会捕获已声明 view-transition-name 的元素的静态图像快照

  3. 视图更改发生

    • 对于同文档过渡 (SPA),调用传递给 startViewTransition() 的回调,这会导致 DOM 更改。

      当回调成功运行后,ViewTransition.updateCallbackDone Promise 会实现,允许你响应 DOM 更新。

    • 对于跨文档过渡 (MPA),导航发生在当前文档和目标文档之间。

  4. API 从新视图中捕获“实时”快照(即交互式 DOM 区域)。

    此时,视图过渡即将运行,ViewTransition.ready Promise 会实现,允许你通过运行自定义 JavaScript 动画(而不是默认动画)来响应,例如。

  5. 旧页面快照“淡出”,新视图快照“淡入”。默认情况下,旧视图快照从 opacity 1 动画到 0,新视图快照从 opacity 0 动画到 1,从而创建交叉淡入淡出效果。

  6. 当过渡动画达到其结束状态时,ViewTransition.finished Promise 会实现,允许你响应。

注意:如果在 document.startViewTransition() 调用期间,文档的 页面可见性状态hidden(例如,如果文档被窗口遮挡、浏览器最小化或另一个浏览器选项卡处于活动状态),则视图过渡将完全跳过。

关于快照的题外话

值得注意的是,在谈论视图过渡时,我们通常使用术语快照来指代页面中声明了 view-transition-name 的部分。这些部分将与页面上设置了不同 view-transition-name 值的其他部分分开进行动画处理。虽然通过视图过渡对快照进行动画处理实际上涉及两个单独的快照——一个旧 UI 状态的快照和一个新 UI 状态的快照——但为简单起见,我们使用快照来指代整个页面区域。

旧 UI 状态的快照是静态图像,因此用户无法在它“淡出”时与之交互。

新 UI 状态的快照是交互式 DOM 区域,因此用户可以在新内容“淡入”时开始与之交互。

视图过渡伪元素树

为了处理创建出站和入站过渡动画,API 构建了一个具有以下结构的伪元素树

::view-transition
└─ ::view-transition-group(root)
  └─ ::view-transition-image-pair(root)
      ├─ ::view-transition-old(root)
      └─ ::view-transition-new(root)

对于同文档过渡 (SPA),伪元素树在文档中可用。对于跨文档过渡 (MPA),伪元素树仅在目标文档中可用。

树结构中最有趣的部分如下

  • ::view-transition 是视图过渡叠加层的根,它包含所有视图过渡组并位于所有其他页面内容之上。

  • ::view-transition-group() 作为每个视图过渡快照的容器。root 参数指定默认快照 — 视图过渡动画将应用于 view-transition-nameroot 的快照。默认情况下,这是 :root 元素的快照,因为默认浏览器样式定义了这一点

    css
    :root {
      view-transition-name: root;
    }
    

    但请注意,页面作者可以通过取消上述设置并在不同元素上设置 view-transition-name: root 来更改此设置。

  • ::view-transition-old() 目标是旧页面元素的静态快照,而 ::view-transition-new() 目标是新页面元素的实时快照。这两者都作为替换内容呈现,方式与 <img><video> 相同,这意味着它们可以使用 object-fitobject-position 等属性进行样式设置。

注意:可以通过在不同的 DOM 元素上设置不同的 view-transition-name 来使用不同的自定义视图过渡动画来定位它们。在这种情况下,会为每个元素创建一个 ::view-transition-group()。有关示例,请参阅不同元素的动画不同

注意:正如你稍后将看到的,要自定义出站和入站动画,你需要分别使用动画来定位 ::view-transition-old()::view-transition-new() 伪元素。

创建基本的视图过渡

本节演示了如何创建基本的视图过渡,包括 SPA 和 MPA 的情况。

基本 SPA 视图过渡

SPA 可能包括响应某种事件(例如导航链接被点击或服务器推送更新)来获取新内容并更新 DOM 的功能。

我们的 视图过渡 SPA 演示 是一个基本的图像库。我们有一系列包含缩略图 <img> 元素的 <a> 元素,使用 JavaScript 动态生成。我们还有一个包含 <figcaption><img><figure> 元素,用于显示全尺寸图库图像。

当点击缩略图时,displayNewImage() 函数通过 Document.startViewTransition() 运行,这会导致全尺寸图像及其关联的标题显示在 <figure> 中。我们已将其封装在一个 updateView() 函数中,该函数仅在浏览器支持 View Transition API 时才调用它

js
function updateView(event) {
  // Handle the difference in whether the event is fired on the <a> or the <img>
  const targetIdentifier = event.target.firstChild || event.target;

  const displayNewImage = () => {
    const mainSrc = `${targetIdentifier.src.split("_th.jpg")[0]}.jpg`;
    galleryImg.src = mainSrc;
    galleryCaption.textContent = targetIdentifier.alt;
  };

  // Fallback for browsers that don't support View Transitions:
  if (!document.startViewTransition) {
    displayNewImage();
    return;
  }

  // With View Transitions:
  const transition = document.startViewTransition(() => displayNewImage());
}

此代码足以处理显示图像之间的过渡。支持的浏览器会将旧图像和新图像以及标题的更改显示为平滑的交叉淡入淡出(默认视图过渡)。它仍然可以在不支持的浏览器中工作,但没有漂亮的动画。

基本 MPA 视图过渡

在创建跨文档 (MPA) 视图过渡时,过程甚至比 SPA 更简单。不需要 JavaScript,因为视图更新是由跨文档、同源导航触发的,而不是由 JavaScript 启动的 DOM 更改触发的。要启用基本的 MPA 视图过渡,你需要在当前文档和目标文档的 CSS 中指定 @view-transition at-rule 以选择加入,如下所示

css
@view-transition {
  navigation: auto;
}

我们的 视图过渡 MPA 演示 展示了此 at-rule 的实际应用,并额外演示了如何自定义视图过渡的出站和入站动画

注意:目前 MPA 视图过渡只能在同源文档之间创建,但此限制可能会在未来的实现中放宽。

自定义动画

视图过渡伪元素应用了默认的 CSS 动画(在其参考页面中有详细说明)。

如上所述,大多数外观过渡都具有默认的平滑交叉淡入淡出动画。有一些例外

  • heightwidth 过渡应用了平滑缩放动画。
  • positiontransform 过渡应用了平滑移动动画。

你可以使用常规 CSS 以任何你想要的方式修改默认动画 — 使用 ::view-transition-old() 针对“from”动画,使用 ::view-transition-new() 针对“to”动画。

例如,要更改两者的速度

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

建议你在想要将样式应用于 ::view-transition-old()::view-transition-new() 的情况下,使用 ::view-transition-group() 来定位这些样式。由于伪元素层次结构和默认的用户代理样式,这些样式将被两者继承。例如

css
::view-transition-group(root) {
  animation-duration: 0.5s;
}

注意:这也是一种保护代码的好选择——::view-transition-group() 也会动画,你可能会得到 group/image-pair 伪元素与 oldnew 伪元素的不同持续时间。

对于跨文档 (MPA) 过渡,伪元素需要仅包含在目标文档中才能使视图过渡工作。如果你想在两个方向上都使用视图过渡,则需要将它包含在两者中。

我们的 视图过渡 MPA 演示 包含了上述 CSS,但将自定义更进一步,定义了自定义动画并将其应用于 ::view-transition-old(root)::view-transition-new(root) 伪元素。结果是,当导航发生时,默认的交叉淡入淡出过渡被替换为“向上滑动”过渡

css
/* Create a custom animation */

@keyframes move-out {
  from {
    transform: translateY(0%);
  }

  to {
    transform: translateY(-100%);
  }
}

@keyframes move-in {
  from {
    transform: translateY(100%);
  }

  to {
    transform: translateY(0%);
  }
}

/* Apply the custom animation to the old and new page states */

::view-transition-old(root) {
  animation: 0.4s ease-in both move-out;
}

::view-transition-new(root) {
  animation: 0.4s ease-in both move-in;
}

不同元素的动画不同

默认情况下,视图更新期间更改的所有不同元素都使用相同的动画进行过渡。如果你希望某些元素与默认的 root 动画不同地进行动画处理,你可以使用 view-transition-name 属性将它们分开。例如,在我们的 视图过渡 SPA 演示 中,<figcaption> 元素被赋予 figure-captionview-transition-name,以便在视图过渡方面将它们与页面的其余部分分开

css
figcaption {
  view-transition-name: figure-caption;
}

应用此 CSS 后,生成的伪元素树现在将如下所示

::view-transition
├─ ::view-transition-group(root)
│ └─ ::view-transition-image-pair(root)
│     ├─ ::view-transition-old(root)
│     └─ ::view-transition-new(root)
└─ ::view-transition-group(figure-caption)
  └─ ::view-transition-image-pair(figure-caption)
      ├─ ::view-transition-old(figure-caption)
      └─ ::view-transition-new(figure-caption)

第二组伪元素的存在允许仅对 <figcaption> 应用单独的视图过渡样式。不同的旧视图和新视图捕获彼此独立处理。

以下代码仅对 <figcaption> 应用自定义动画

css
@keyframes grow-x {
  from {
    transform: scaleX(0);
  }
  to {
    transform: scaleX(1);
  }
}

@keyframes shrink-x {
  from {
    transform: scaleX(1);
  }
  to {
    transform: scaleX(0);
  }
}

::view-transition-group(figure-caption) {
  height: auto;
  right: 0;
  left: auto;
  transform-origin: right center;
}

::view-transition-old(figure-caption) {
  animation: 0.25s linear both shrink-x;
}

::view-transition-new(figure-caption) {
  animation: 0.25s 0.25s linear both grow-x;
}

在这里,我们创建了一个自定义 CSS 动画,并将其应用于 ::view-transition-old(figure-caption)::view-transition-new(figure-caption) 伪元素。我们还为两者添加了许多其他样式,以使它们保持在相同的位置,并阻止默认样式干扰我们的自定义动画。

注意:你可以使用 * 作为伪元素中的标识符来定位所有快照伪元素,无论它们具有什么名称。例如

css
::view-transition-group(*) {
  animation-duration: 2s;
}

有效的 view-transition-name

view-transition-name 属性可以采用唯一的 <custom-ident> 值,它可以是任何不会被误解为关键字的标识符。每个渲染元素的 view-transition-name 值必须是唯一的。如果两个渲染元素同时具有相同的 view-transition-nameViewTransition.ready 将被拒绝,并且过渡将被跳过。

它还可以采用关键字值

  • none:使元素不参与单独的快照,除非它有一个设置了 view-transition-name 的父元素,在这种情况下,它将作为该元素的一部分进行快照。
  • match-element:自动为所有选定元素设置唯一的 view-transition-name 值。

利用默认动画样式

请注意,我们还发现了另一种更简单且比上述方法产生更好结果的过渡选项。我们最终的 <figcaption> 视图过渡最终看起来像这样

css
figcaption {
  view-transition-name: figure-caption;
}

::view-transition-group(figure-caption) {
  height: 100%;
}

这之所以有效,是因为默认情况下,::view-transition-group() 会以平滑缩放方式在旧视图和新视图之间过渡 widthheight。我们只需要为两种状态设置固定的 height 即可使其工作。

注意:使用视图过渡 API 实现平滑过渡 包含其他几个自定义示例。

使用 JavaScript 控制视图过渡

视图过渡具有关联的 ViewTransition 对象实例,其中包含多个 Promise 成员,允许你响应过渡的不同状态到达时运行 JavaScript。例如,一旦创建了伪元素树并且动画即将开始,ViewTransition.ready 就会实现,而一旦动画完成并且新的页面视图对用户可见和可交互,ViewTransition.finished 就会实现。

可以这样访问 ViewTransition

  1. 对于同文档 (SPA) 过渡,document.startViewTransition() 方法返回与过渡关联的 ViewTransition
  2. 对于跨文档 (MPA) 过渡

让我们看一些示例代码,以展示如何使用这些功能。

JavaScript 驱动的自定义同文档 (SPA) 过渡

以下 JavaScript 可用于创建从用户点击时鼠标光标位置发出的圆形显示视图过渡,动画由 Web Animations API 提供。

js
// Store the last click event
let lastClick;
addEventListener("click", (event) => (lastClick = event));

function spaNavigate(data) {
  // Fallback for browsers that don't support this API:
  if (!document.startViewTransition) {
    updateTheDOMSomehow(data);
    return;
  }

  // Get the click position, or fallback to the middle of the screen
  const x = lastClick?.clientX ?? innerWidth / 2;
  const y = lastClick?.clientY ?? innerHeight / 2;
  // Get the distance to the furthest corner
  const endRadius = Math.hypot(
    Math.max(x, innerWidth - x),
    Math.max(y, innerHeight - y),
  );

  // Create a transition:
  const transition = document.startViewTransition(() => {
    updateTheDOMSomehow(data);
  });

  // Wait for the pseudo-elements to be created:
  transition.ready.then(() => {
    // Animate the root's new view
    document.documentElement.animate(
      {
        clipPath: [
          `circle(0 at ${x}px ${y}px)`,
          `circle(${endRadius}px at ${x}px ${y}px)`,
        ],
      },
      {
        duration: 500,
        easing: "ease-in",
        // Specify which pseudo-element to animate
        pseudoElement: "::view-transition-new(root)",
      },
    );
  });
}

此动画还需要以下 CSS,以关闭默认 CSS 动画并阻止旧视图和新视图状态以任何方式混合(新状态“擦拭”旧状态,而不是过渡进入)

css
::view-transition-image-pair(root) {
  isolation: auto;
}

::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
  display: block;
}

JavaScript 驱动的自定义跨文档 (MPA) 过渡

Chrome DevRel 团队成员列表 演示提供了一组基本的团队资料页面,并演示了如何使用 pageswappagereveal 事件根据“from”和“to”URL 自定义跨文档视图过渡的出站和入站动画。

pageswap 事件监听器如下所示。这会在链接到资料页面的出站页面上的元素上设置视图过渡名称。当从主页导航到资料页面时,自定义动画针对每次点击的链接元素提供。

js
window.addEventListener("pageswap", async (e) => {
  // Only run this if an active view transition exists
  if (e.viewTransition) {
    const currentUrl = e.activation.from?.url
      ? new URL(e.activation.from.url)
      : null;
    const targetUrl = new URL(e.activation.entry.url);

    // Going from profile page to homepage
    // ~> The big img and title are the ones!
    if (isProfilePage(currentUrl) && isHomePage(targetUrl)) {
      // Set view-transition-name values on the elements to animate
      document.querySelector(`#detail main h1`).style.viewTransitionName =
        "name";
      document.querySelector(`#detail main img`).style.viewTransitionName =
        "avatar";

      // Remove view-transition-names after snapshots have been taken
      // Stops naming conflicts resulting from the page state persisting in BFCache
      await e.viewTransition.finished;
      document.querySelector(`#detail main h1`).style.viewTransitionName =
        "none";
      document.querySelector(`#detail main img`).style.viewTransitionName =
        "none";
    }

    // Going to profile page
    // ~> The clicked items are the ones!
    if (isProfilePage(targetUrl)) {
      const profile = extractProfileNameFromUrl(targetUrl);

      // Set view-transition-name values on the elements to animate
      document.querySelector(`#${profile} span`).style.viewTransitionName =
        "name";
      document.querySelector(`#${profile} img`).style.viewTransitionName =
        "avatar";

      // Remove view-transition-names after snapshots have been taken
      // Stops naming conflicts resulting from the page state persisting in BFCache
      await e.viewTransition.finished;
      document.querySelector(`#${profile} span`).style.viewTransitionName =
        "none";
      document.querySelector(`#${profile} img`).style.viewTransitionName =
        "none";
    }
  }
});

注意:我们在每次拍摄快照后删除 view-transition-name 值。如果我们保留它们设置,它们将保留在导航时保存到 bfcache 的页面状态中。如果然后按下返回按钮,则导航回的页面的 pagereveal 事件处理程序将尝试在不同的元素上设置相同的 view-transition-name 值。如果多个元素设置了相同的 view-transition-name,则视图过渡将被跳过。

pagereveal 事件监听器如下所示。这与 pageswap 事件监听器类似,但请记住,这里我们正在为新页面上的页面元素自定义“to”动画。

js
window.addEventListener("pagereveal", async (e) => {
  // If the "from" history entry does not exist, return
  if (!navigation.activation.from) return;

  // Only run this if an active view transition exists
  if (e.viewTransition) {
    const fromUrl = new URL(navigation.activation.from.url);
    const currentUrl = new URL(navigation.activation.entry.url);

    // Went from profile page to homepage
    // ~> Set VT names on the relevant list item
    if (isProfilePage(fromUrl) && isHomePage(currentUrl)) {
      const profile = extractProfileNameFromUrl(fromUrl);

      // Set view-transition-name values on the elements to animate
      document.querySelector(`#${profile} span`).style.viewTransitionName =
        "name";
      document.querySelector(`#${profile} img`).style.viewTransitionName =
        "avatar";

      // Remove names after snapshots have been taken
      // so that we're ready for the next navigation
      await e.viewTransition.ready;
      document.querySelector(`#${profile} span`).style.viewTransitionName =
        "none";
      document.querySelector(`#${profile} img`).style.viewTransitionName =
        "none";
    }

    // Went to profile page
    // ~> Set VT names on the main title and image
    if (isProfilePage(currentUrl)) {
      // Set view-transition-name values on the elements to animate
      document.querySelector(`#detail main h1`).style.viewTransitionName =
        "name";
      document.querySelector(`#detail main img`).style.viewTransitionName =
        "avatar";

      // Remove names after snapshots have been taken
      // so that we're ready for the next navigation
      await e.viewTransition.ready;
      document.querySelector(`#detail main h1`).style.viewTransitionName =
        "none";
      document.querySelector(`#detail main img`).style.viewTransitionName =
        "none";
    }
  }
});

稳定页面状态以使跨文档过渡保持一致

在运行跨文档过渡之前,你理想地希望等待页面状态稳定下来,依靠 渲染阻塞 来确保

  1. 关键样式已加载并应用。
  2. 关键脚本已加载并运行。
  3. 用户初始视图可见的 HTML 已解析,因此它会一致地渲染。

默认情况下,样式是渲染阻塞的,除非它们通过脚本动态添加到文档中。脚本和动态添加的样式都可以使用 blocking="render" 属性进行渲染阻塞。

为了确保你的初始 HTML 已解析并在过渡动画运行之前始终一致地渲染,你可以使用 <link rel="expect">。在此元素中,你包含以下属性

  • rel="expect" 表示你想要使用此 <link> 元素来渲染阻塞页面上的某些 HTML。
  • href="#element-id" 表示你想要渲染阻塞的元素的 ID。
  • blocking="render" 用于渲染阻塞指定的 HTML。

注意:为了阻塞渲染,具有 blocking="render"scriptlinkstyle 元素必须位于文档的 head 中。

让我们通过一个 HTML 文档示例来探讨这是什么样子

html
<!doctype html>
<html lang="en">
  <head>
    <!-- This will be render-blocking by default -->
    <link rel="stylesheet" href="style.css" />

    <!-- Marking critical scripts as render blocking will
         ensure they're run before the view transition is activated -->
    <script async src="layout.js" blocking="render"></script>

    <!-- Use rel="expect" and blocking="render" to ensure the
         #lead-content element is visible and fully parsed before
         activating the transition -->
    <link rel="expect" href="#lead-content" blocking="render" />
  </head>
  <body>
    <h1>Page title</h1>
    <nav>...</nav>
    <div id="lead-content">
      <section id="first-section">The first section</section>
      <section>The second section</section>
    </div>
  </body>
</html>

结果是文档渲染被阻塞,直到解析了主要内容 <div>,确保了一致的视图过渡。

你还可以在 <link rel="expect"> 元素上指定 media 属性。例如,你可能希望在窄屏设备上加载页面时阻塞较少内容的渲染,而不是在宽屏设备上。这是有道理的——在移动设备上,页面首次加载时可见的内容比在桌面设备上少。

这可以通过以下 HTML 实现

html
<link
  rel="expect"
  href="#lead-content"
  blocking="render"
  media="screen and (width > 640px)" />
<link
  rel="expect"
  href="#first-section"
  blocking="render"
  media="screen and (width <= 640px)" />