使用视图转换 API

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

视图转换过程

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

  1. 触发视图转换。如何触发取决于视图转换的类型
    • 对于相同文档转换 (SPA),通过将触发视图更改 DOM 更新的函数作为回调传递给 document.startViewTransition() 方法来触发视图转换。
    • 对于跨文档转换 (MPA),通过启动导航到新文档来触发视图转换。导航的当前文档和目标文档都需要位于同一来源,并且通过在它们的 CSS 中包含 @view-transition 规则并使用 navigation 描述符 auto 来选择加入视图转换。

      注意: 活动视图转换与一个关联的 ViewTransition 实例相关联(例如,对于相同文档 (SPA) 转换,由 startViewTransition() 返回)。ViewTransition 对象包含多个 promise,允许您在视图转换过程到达不同部分时运行代码。有关更多信息,请参阅 使用 JavaScript 控制视图转换

  2. 在当前(旧页面)视图上,API 会捕获声明了 view-transition-name 的元素的快照。
  3. 发生视图更改
    • 对于相同文档转换 (SPA),传递给 startViewTransition() 的回调被调用,这会导致 DOM 发生更改。当回调成功运行后,ViewTransition.updateCallbackDone promise 完成,允许您对 DOM 更新做出响应。
    • 对于跨文档转换 (MPA),导航发生在当前文档和目标文档之间。
  4. API 会捕获新视图的快照作为实时表示。此时,视图转换即将运行,并且 ViewTransition.ready promise 完成,允许您通过运行自定义 JavaScript 动画来做出响应,而不是默认动画,例如。
  5. 旧页面快照“退出”动画,而新视图快照“进入”动画。默认情况下,旧视图快照从 opacity 1 动画到 0,新视图快照从 opacity 0 动画到 1,这会创建交叉淡入淡出效果。
  6. 当转换动画到达其结束状态时,ViewTransition.finished promise 完成,允许您做出响应。

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

视图转换伪元素树

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

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

注意: 对于每个捕获的 view-transition-name,都会创建一个 ::view-transition-group 子树。

对于相同文档转换 (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)进行样式化。

注意: 通过为每个元素设置不同的 view-transition-name,可以针对不同的 DOM 元素使用不同的自定义视图转换动画。在这种情况下,将为每个元素创建一个 ::view-transition-group。有关示例,请参阅 不同元素的动画不同

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

创建基本视图转换

本节说明了如何在 SPA 和 MPA 情况下创建基本视图转换。

基本 SPA 视图转换

例如,SPA 可能包含功能,用于在某种事件(例如单击导航链接或从服务器推送更新)发生时获取新内容并更新 DOM。在我们的 视图转换 SPA 演示 中,我们将此简化为 displayNewImage() 函数,该函数基于单击的缩略图显示新的全尺寸图像。我们将此封装在 updateView() 函数中,该函数仅在浏览器支持时调用视图转换 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 规则,使其选择加入,如下所示

css
@view-transition {
  navigation: auto;
}

我们的 视图转换 MPA 演示 显示了此规则的实际应用,并额外演示了如何 自定义视图转换的出站和入站动画

注意: 目前 MPA 视图转换只能在同一来源文档之间创建,但此限制可能会在将来的实现中放松。

自定义您的动画

视图转换伪元素具有默认的 CSS 动画 应用(在它们的 参考页面 中有详细说明)。

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

  • heightwidth 转换具有平滑缩放动画效果。
  • positiontransform 转换具有平滑移动动画效果。

您可以使用常规 CSS 以任何您想要的方式修改默认动画 - 使用 ::view-transition-old 来定位“从”动画,使用 ::view-transition-new 来定位“到”动画。

例如,要更改两者速度

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> 元素被赋予了 view-transition-namefigure-caption 的值,以便在视图过渡方面将它们与页面上的其他元素区分开来。

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>。不同的旧视图和新视图捕获将独立处理。

注意:view-transition-name 的值可以是任何你想要的值,除了 none —— none 值专门表示该元素将不参与视图过渡。

view-transition-name 值也必须是唯一的。如果两个渲染的元素在同一时间具有相同的 view-transition-name,则 ViewTransition.ready 将被拒绝,并且过渡将被跳过。

以下代码将自定义动画应用于 <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;
}

利用默认动画样式:

请注意,我们还发现另一个过渡选项,它比上面介绍的方法更简单,并且产生了更好的结果。我们最终的 <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 事件根据“来自”和“到”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 事件监听器类似,但请注意,这里我们正在自定义“到”动画,用于新页面上的页面元素。

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。

让我们通过一个简单的示例 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 href="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 (min-width: 641px)" />
<link
  rel="expect"
  href="#first-section"
  blocking="render"
  media="screen and (max-width: 640px)" />