适用于多页面应用的跨文档视图过渡

Bramus
Bramus

当两个不同文档之间发生视图转换时,称为跨文档视图转换。在多页面应用 (MPA) 中,通常就是这种情况。从 Chrome 126 开始,Chrome 支持跨文档视图转换。

浏览器支持

  • Chrome:126。
  • Edge:126。
  • Firefox:不受支持。
  • Safari Technology Preview:受支持。

跨文档视图转换依赖于与同一文档视图转换完全相同的构建块和原则,这并非偶然:

  1. 浏览器会为旧版和新版页面上具有唯一 view-transition-name 的元素拍摄快照。
  2. 在抑制渲染时,DOM 会更新。
  3. 最后,过渡由 CSS 动画提供支持。

与同一文档内的视图转换相比,跨文档视图转换的不同之处在于,您无需调用 document.startViewTransition 即可启动视图转换。相反,跨文档视图转换的触发器是从一个网页到另一个网页的同源导航,这通常是由网站用户点击链接执行的操作。

换句话说,没有可用于在两份文档之间启动视图转换的 API。不过,您需要满足以下两个条件:

  • 这两个文档需要位于同一来源。
  • 这两个网页都需要选择启用此功能,才能允许视图转换。

本文档后面部分介绍了这两种情况。


跨文档视图转换仅限于同源导航

跨文档视图转换仅限于同源导航。如果两个参与导航的网页的来源相同,则该导航会被视为同源。

网页的来源是所用架构、主机名和端口的组合,如 web.dev 上详述的那样。

突出显示架构、主机名和端口的网址示例。它们共同构成了起源。
一个网址示例,其中突出显示了架构、主机名和端口。它们共同构成了起源。

例如,从 developer.chrome.com 导航到 developer.chrome.com/blog 时,您可以实现跨文档视图转换,因为它们是同源的。从 developer.chrome.com 导航到 www.chrome.com 时无法实现此过渡,因为这两个网页是跨源网页和同一网站网页。


跨文档视图转换功能需用户选择启用

如需在两个文档之间实现跨文档视图转换,参与的两个网页都需要选择允许这样做。这可通过 CSS 中的 @view-transition at-rule 实现。

@view-transition at-rule 中,将 navigation 描述符设置为 auto,以便为跨文档同源导航启用视图转换。

@view-transition {
  navigation: auto;
}

navigation 描述符设置为 auto 表示您选择允许以下 NavigationType 发生视图转换:

  • traverse
  • pushreplace(如果激活不是由用户通过浏览器界面机制发起的)。

auto 中排除的导航包括使用网址地址栏导航或点击书签,以及用户或脚本发起的任何形式的重载。

如果导航花费的时间过长(在 Chrome 中,超过 4 秒),则系统会使用 TimeoutError DOMException 跳过视图转换。

跨文档视图转换演示

查看以下演示,了解如何使用视图转换创建堆叠导航器演示。此处没有对 document.startViewTransition() 的调用,视图转换是通过从一个页面导航到另一个页面触发的。

堆栈导航器演示的录制内容。需要 Chrome 126 或更高版本。

自定义跨文档视图转换

如需自定义跨文档视图转换,您可以使用一些 Web 平台功能。

这些功能并非 View Transition API 规范的一部分,但旨在与该规范搭��使用。

pageswappagereveal 事件

浏览器支持

  • Chrome:124。
  • Edge:124。
  • Firefox:不受支持。
  • Safari:不受支持。

来源

为了让您能够自定义跨文档视图转换,HTML 规范包含两个可供您使用的新事件:pageswappagereveal

无论视图转换是否即将发生,系统都会针对每个同源跨文档导航触发这两个事件。如果两个页面之间即将发生视图转换,您可以使用这些事件的 viewTransition 属性访问 ViewTransition 对象。

  • pageswap 事件会在网页的最后一帧呈现之前触发。您可以使用此方法在系统截取旧快照之前,对要移除的网页进行一些最后一刻的更改。
  • 网页在初始化或重新激活后,但在首次呈现机会之前,会触发 pagereveal 事件。借助此功能,您可以在系统拍摄新快照之前自定义新页面。

例如,您可以使用这些事件快速设置或更改某些 view-transition-name 值,或者通过向 sessionStorage 写入和读取数据,将数据从一个文档传递到另一个文档,以便在视图转换实际运行之前对其进行自定义。

let lastClickX, lastClickY;
document.addEventListener('click', (event) => {
  if (event.target.tagName.toLowerCase() === 'a') return;
  lastClickX = event.clientX;
  lastClickY = event.clientY;
});

// Write position to storage on old page
window.addEventListener('pageswap', (event) => {
  if (event.viewTransition && lastClick) {
    sessionStorage.setItem('lastClickX', lastClickX);
    sessionStorage.setItem('lastClickY', lastClickY);
  }
});

// Read position from storage on new page
window.addEventListener('pagereveal', (event) => {
  if (event.viewTransition) {
    lastClickX = sessionStorage.getItem('lastClickX');
    lastClickY = sessionStorage.getItem('lastClickY');
  }
});

如果需要,您可以选择在这两种情况下都跳过转换。

window.addEventListener("pagereveal", async (e) => {
  if (e.viewTransition) {
    if (goodReasonToSkipTheViewTransition()) {
      e.viewTransition.skipTransition();
    }
  }
}

pageswappagereveal 中的 ViewTransition 对象是两个不同的对象。它们还会以不同的方式处理各种 promise

  • pageswap:文档隐藏后,系统会跳过旧的 ViewTransition 对象。发生这种情况时,viewTransition.ready 会拒绝,viewTransition.finished 会解析。
  • pagereveal:此时 updateCallBack promise 已解析完毕。您可以使用 viewTransition.readyviewTransition.finished 承诺。

浏览器支持

  • Chrome:123。
  • Edge:123。
  • Firefox:不受支持。
  • Safari:不受支持。

来源

pageswappagereveal 事件中,您还可以根据旧网页和新网页的网址采取行动。

例如,在 MPA 堆栈导航器中,要使用的动画类型取决于导航路径:

  • 从概览页面导航到详情页面时,新内容需要从右向左滑入。
  • 从详情页面导航到概览页面时,旧内容需要从左向右滑出。

为此,您需要有关导航的信息,对于 pageswap,是即将发生的导航;对于 pagereveal,是刚刚发生的导航。

为此,浏览器现在可以公开 NavigationActivation 对象,其中包含与同源导航相关的信息。此对象会公开所用导航类型、当前目的地和最终目的地历史记录条目(如 Navigation API 中的 navigation.entries() 中所示)。

在已启用的页面上,您可以通过 navigation.activation 访问此对象。在 pageswap 事件中,您可以通过 e.activation 访问此属性。

请查看此个人资料演示,其中使用 pageswappagereveal 事件中的 NavigationActivation 信息为需要参与视图转换的元素设置 view-transition-name 值。

这样一来,您就不必事先为列表中的每个项都添加 view-transition-name 装饰。而是使用 JavaScript 在需要时才对需要的元素进行处理。

“配置文件”演示的录制内容。需要 Chrome 126 或更高版本。

代码如下所示:

// OLD PAGE LOGIC
window.addEventListener('pageswap', async (e) => {
  if (e.viewTransition) {
    const targetUrl = new URL(e.activation.entry.url);

    // Navigating to a profile page
    if (isProfilePage(targetUrl)) {
      const profile = extractProfileNameFromUrl(targetUrl);

      // Set view-transition-name values on the clicked row
      document.querySelector(`#${profile} span`).style.viewTransitionName = 'name';
      document.querySelector(`#${profile} img`).style.viewTransitionName = 'avatar';

      // Remove view-transition-names after snapshots have been taken
      // (this to deal with BFCache)
      await e.viewTransition.finished;
      document.querySelector(`#${profile} span`).style.viewTransitionName = 'none';
      document.querySelector(`#${profile} img`).style.viewTransitionName = 'none';
    }
  }
});

// NEW PAGE LOGIC
window.addEventListener('pagereveal', async (e) => {
  if (e.viewTransition) {
    const fromURL = new URL(navigation.activation.from.url);
    const currentURL = new URL(navigation.activation.entry.url);

    // Navigating from a profile page back to the homepage
    if (isProfilePage(fromURL) && isHomePage(currentURL)) {
      const profile = extractProfileNameFromUrl(currentURL);

      // Set view-transition-name values on the elements in the list
      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';
    }
  }
});

该代码还会在视图转换运行后移除 view-transition-name 值,以便自行清理。这样,页面就可以准备好进行连续导航,还可以处理历史记录的遍历。

为此,请使用此实用程序函数,以临时设置 view-transition-name

const setTemporaryViewTransitionNames = async (entries, vtPromise) => {
  for (const [$el, name] of entries) {
    $el.style.viewTransitionName = name;
  }

  await vtPromise;

  for (const [$el, name] of entries) {
    $el.style.viewTransitionName = '';
  }
}

现在,前面的代码可以简化为:

// OLD PAGE LOGIC
window.addEventListener('pageswap', async (e) => {
  if (e.viewTransition) {
    const targetUrl = new URL(e.activation.entry.url);

    // Navigating to a profile page
    if (isProfilePage(targetUrl)) {
      const profile = extractProfileNameFromUrl(targetUrl);

      // Set view-transition-name values on the clicked row
      // Clean up after the page got replaced
      setTemporaryViewTransitionNames([
        [document.querySelector(`#${profile} span`), 'name'],
        [document.querySelector(`#${profile} img`), 'avatar'],
      ], e.viewTransition.finished);
    }
  }
});

// NEW PAGE LOGIC
window.addEventListener('pagereveal', async (e) => {
  if (e.viewTransition) {
    const fromURL = new URL(navigation.activation.from.url);
    const currentURL = new URL(navigation.activation.entry.url);

    // Navigating from a profile page back to the homepage
    if (isProfilePage(fromURL) && isHomePage(currentURL)) {
      const profile = extractProfileNameFromUrl(currentURL);

      // Set view-transition-name values on the elements in the list
      // Clean up after the snapshots have been taken
      setTemporaryViewTransitionNames([
        [document.querySelector(`#${profile} span`), 'name'],
        [document.querySelector(`#${profile} img`), 'avatar'],
      ], e.viewTransition.ready);
    }
  }
});

使用呈现阻塞功能等待内容加载

浏览器支持

  • Chrome:124。
  • Edge:124。
  • Firefox:不受支持。
  • Safari:不受支持。

在某些情况下,您可能希望暂停网页的首次渲染,直到新 DOM 中出现某个元素。这样可以避免闪烁,并确保您要进行动画处理的状态稳定。

<head> 中,使用以下元标记定义在网页首次呈现之前需要存在的一个或多个元素 ID。

<link rel="expect" blocking="render" href="#section1">

此元标记表示该元素应存在于 DOM 中,而不是表示应加载内容。例如,对于图片,只要 DOM 树中存在带有指定 id<img> 标记,该条件的计算结果就为 true。图片本身可能仍在加载中。

在全面采用呈现阻塞功能之前,请注意增量呈现是 Web 的��本方面,因此在选择阻塞呈现时请务必谨慎。需要根据具体情况评估阻止渲染的影响。默认情况下,请避免使用 blocking=render,除非您可以通过衡量对 Core Web Vitals 的影响来主动衡量和评估其对用户的影响。


查看跨文档视图过渡中的视图过渡类型

跨文档视图转换还支持视图转换类型,以自定义动画和要捕获的元素。

例如,在分页中前往下一页或上一页时,您可能需要使用不同的动画,具体取决于您是前往序列中的上一个页面还是下一个页面。

如需预先设置这些类型,请在 @view-transition at-rule 中添加这些类型:

@view-transition {
  navigation: auto;
  types: slide, forwards;
}

如需动态设置类型,请使用 pageswappagereveal 事件来操控 e.viewTransition.types 的值。

window.addEventListener("pagereveal", async (e) => {
  if (e.viewTransition) {
    const transitionType = determineTransitionType(navigation.activation.from, navigation.activation.entry);
    e.viewTransition.types.add(transitionType);
  }
});

这些类型不会自动从旧页面上的 ViewTransition 对象转移到新页面的 ViewTransition 对象。您需要确定至少要在新页面上使用的类型,以便动画能够按预期运行。

如需响应这些类型,请使用与同文档视图转换相同的方式使用 :active-view-transition-type() 伪类选择器

/* Determine what gets captured when the type is forwards or backwards */
html:active-view-transition-type(forwards, backwards) {
  :root {
    view-transition-name: none;
  }
  article {
    view-transition-name: content;
  }
  .pagination {
    view-transition-name: pagination;
  }
}

/* Animation styles for forwards type only */
html:active-view-transition-type(forwards) {
  &::view-transition-old(content) {
    animation-name: slide-out-to-left;
  }
  &::view-transition-new(content) {
    animation-name: slide-in-from-right;
  }
}

/* Animation styles for backwards type only */
html:active-view-transition-type(backwards) {
  &::view-transition-old(content) {
    animation-name: slide-out-to-right;
  }
  &::view-transition-new(content) {
    animation-name: slide-in-from-left;
  }
}

/* Animation styles for reload type only */
html:active-view-transition-type(reload) {
  &::view-transition-old(root) {
    animation-name: fade-out, scale-down;
  }
  &::view-transition-new(root) {
    animation-delay: 0.25s;
    animation-name: fade-in, scale-up;
  }
}

由于类型仅适用于有效的视图转换,因此在视图转换完成后,系统会自动清理类型。因此,类型非常适合与 BFCache 等功能搭配使用。

演示

在以下分页演示中,网页内容会根据您要导航到的页码向前或向后滑动。

分页演示 (MPA) 的录制。它会根据您要前往的页面使用不同的转换效果。

pagerevealpageswap 事件中,系统会通过查看“到”和“从”网址来确定要使用的转换类型。

const determineTransitionType = (fromNavigationEntry, toNavigationEntry) => {
  const currentURL = new URL(fromNavigationEntry.url);
  const destinationURL = new URL(toNavigationEntry.url);

  const currentPathname = currentURL.pathname;
  const destinationPathname = destinationURL.pathname;

  if (currentPathname === destinationPathname) {
    return "reload";
  } else {
    const currentPageIndex = extractPageIndexFromPath(currentPathname);
    const destinationPageIndex = extractPageIndexFromPath(destinationPathname);

    if (currentPageIndex > destinationPageIndex) {
      return 'backwards';
    }
    if (currentPageIndex < destinationPageIndex) {
      return 'forwards';
    }

    return 'unknown';
  }
};

反馈

我们一如既往地衷心期待您的反馈。如需分享,请在 GitHub 上向 CSS 工作组提交问题,提供建议和问题。请在问题前面添加 [css-view-transitions] 前缀。如果您遇到 bug,请改为提交 Chromium bug