前端技术
#View Transitions#Navigation API#pageswap#pagereveal#MPA

跨文档 View Transitions 与导航生命周期的绑定:pageswap 与 pagereveal 之间的快照时机

跨文档视图过渡依赖浏览器在旧文档卸载前与新文档首次渲染前分别捕获快照。文章以列表页跳转详情页再返回为例,说明为什么 DOM 修改必须发生在 pageswap 同步阶段才能影响旧快照,pagereveal 中读取导航条目与恢复滚动位置的边界在哪里,并给出配置方式与降级判断。

从一个返回列表页的闪动说起

商品列表页滚动到第 12 屏,点进某个商品详情,看完后按浏览器返回。用户期望列表页停在原来的位置,被点击的那张卡片从详情页主图的位置滑回列表。实际看到的却常常是:列表页从顶部重新出现,或者卡片先闪一下再落位,甚至过渡动画整个跳过。

直觉上可行的做法是在详情页里监听 popstatebeforeunload,在离开前给卡片加上 view-transition-name,让浏览器把这张卡片作为独立快照带走。但跨文档导航里,旧文档在导航开始后很快就不再参与渲染,beforeunload 触发时旧文档的渲染状态已经进入卸载流程,此时再改 DOM 往往影响不到已经捕获或即将捕获的旧快照。真正能决定旧快照长什么样的时点,是 pageswap

跨文档视图过渡要解决的核心问题,是让两个独立文档的视觉状态在同一层里做动画,而不必让旧文档的 DOM 一直保留到动画结束。浏览器把旧状态做成静态图像快照,把新状态做成实时快照,两者放进伪元素树里交叉淡入并做位置尺寸插值。这套机制要求开发者知道:快照在哪个时点被捕获,哪些 API 在那个时点还能改到渲染结果。

跨文档过渡的三段生命周期

跨文档视图过渡不是一次连续动画,而是被导航切成了三段。第一段发生在旧文档,导航被确认后、旧文档即将卸载前,浏览器触发 pageswap。此时旧文档仍可访问 DOM,PageSwapEvent 会暴露 viewTransition 对象以及 activation 信息,后者包含导航类型、来源历史条目和目标历史条目。浏览器在这个窗口内捕获旧状态快照。

第二段是文档切换本身。旧文档被卸载,新文档从网络加载或从 bfcache、prerender 中激活。这段里没有可用的页面脚本,过渡的视觉连续性完全由浏览器维护。

第三段发生在新文档,文档首次渲染时触发 pagerevealPageRevealEvent 同样暴露 viewTransition,并可通过 navigation.activation 读取来源与当前历史条目。浏览器在这个窗口内捕获新状态快照,随后运行过渡动画。

flowchart TD
    A[列表页点击卡片] --> B[导航被确认]
    B --> C[旧文档触发 pageswap]
    C --> D[同步修改 DOM 设置 view-transition-name]
    D --> E[浏览器捕获旧状态快照]
    E --> F[旧文档卸载]
    F --> G[新文档加载或从 bfcache 激活]
    G --> H[新文档触发 pagereveal]
    H --> I[读取 navigation.activation 判断方向]
    I --> J[设置新文档元素的 view-transition-name]
    J --> K[浏览器捕获新状态快照]
    K --> L[运行过渡动画]

关键转折点在 C 到 E 之间:pageswap 回调里对 DOM 的修改必须同步完成,才能进入旧快照。任何 await 之后的修改都可能落在捕获之后。

为什么旧快照只认 pageswap 里的同步修改

旧状态快照捕获的是元素当前的渲染结果,包括它的位置、尺寸、裁剪和视觉样式。浏览器在 pageswap 派发后、旧文档卸载前执行捕获,捕获依据的是此刻的渲染树。如果回调里先 await 一个网络请求或 requestAnimationFrame,控制权交还给事件循环,捕获可能已经在微任务或下一帧之前完成,之后设置的 view-transition-name 不会出现在旧快照里。

MDN 的示例代码给出了一个可参考的模式:在 pageswap 回调中先判断 e.viewTransition 是否存在,再用 e.activation.frome.activation.entry 判断导航方向,然后同步设置目标元素的 viewTransitionName。设置完成后,用 await e.viewTransition.finished 等待过渡结束,再把 viewTransitionName 重置为 none。这个重置很重要,因为旧文档可能进入 bfcache,残留的 view-transition-name 会在下次导航时造成命名冲突。

window.addEventListener("pageswap", async (e) => {
  if (!e.viewTransition) return;
  const target = new URL(e.activation.entry.url);
  if (isDetailPage(target)) {
    const card = document.querySelector(`#item-${idFromUrl(target)}`);
    card.style.viewTransitionName = "item-card";
  }
  await e.viewTransition.finished;
  document.querySelectorAll("[style*='view-transition-name']").forEach((el) => {
    el.style.viewTransitionName = "none";
  });
});

这段代码里,viewTransitionName 的设置是同步的,await 只用于等待过渡结束后的清理。如果把设置动作放到 await 之后,旧快照捕获时元素还没有名字,过渡就会退化成整页交叉淡入,卡片不会独立移动。

pagereveal 里的读取时机与滚动恢复边界

新文档的 pagereveal 在文档首次渲染时触发,此时新文档已经解析出足够的 DOM,但尚未完成首次绘制。这是设置新状态 view-transition-name 的最后窗口,晚于这个时点,新快照已经捕获,再改名字不会影响过渡。

从详情页返回列表页时,列表页可能是从 bfcache 激活的。MDN 指出 pagereveal 在从网络加载新文档或从 bfcache、prerender 激活文档时都会触发。这意味着返回场景下,列表页的 DOM 可能保留了上次离开时的状态,包括滚动位置。navigation.activation.from 会给出详情页的 URL,navigation.activation.entry 给出当前列表页的 URL,据此可以判断方向并给对应卡片设置名字。

滚动恢复的时机需要单独注意。浏览器在 bfcache 激活时通常会恢复滚动位置,但恢复发生在 pagereveal 之前还是之后,会影响你能否在回调里基于滚动位置做判断。如果依赖 window.scrollY 来决定给哪个卡片设置名字,而滚动尚未恢复,读到的可能是 0。稳妥的做法是不依赖滚动位置,而是从 navigation.activation.from 的 URL 中解析出商品 ID,直接定位对应卡片。

window.addEventListener("pagereveal", async (e) => {
  if (!e.viewTransition || !navigation.activation.from) return;
  const from = new URL(navigation.activation.from.url);
  const card = document.querySelector(`#item-${idFromUrl(from)}`);
  if (card) card.style.viewTransitionName = "item-card";
  await e.viewTransition.ready;
  if (card) card.style.viewTransitionName = "none";
});

这里用 await e.viewTransition.ready 而不是 finished,是因为新文档只需要在新快照捕获后立即清理名字,不必等整段动画结束。清理太晚会让名字在后续交互中残留,影响下一次过渡。

配置、降级与替代方案对比

跨文档过渡需要新旧两个文档都通过 @view-transition 规则显式选择加入。MDN 明确说明该规则用于让当前文档和目标文档参与跨文档视图过渡。只在一侧声明,过渡不会发生。

@view-transition {
  navigation: auto;
}

navigation: auto 表示对同源导航启用过渡。跨源导航、下载、表单提交等场景是否触发,取决于导航类型,开发者不应假设所有导航都会过渡。

降级判断需要分两层。第一层是能力检测:pageswappagereveal 在 MDN 上都被标注为 Limited availability,并非 Baseline,在部分主流浏览器中不可用。可以用 if ("onpageswap" in window) 之类的检测决定是否注册监听。第二层是行为降级:即使事件存在,如果 e.viewTransition 为空,说明本次导航没有触发过渡,回调应直接返回,不要执行设置名字的逻辑。

方案旧状态处理新状态处理滚动位置实现复杂度适用边界
跨文档 View Transitionspageswap 同步设置名字,浏览器捕获静态快照pagereveal 设置名字,浏览器捕获实时快照由浏览器在激活时恢复,回调内读取时机不确定中,需两侧声明并处理命名冲突同源 MPA 导航,浏览器支持该 API
同文档 startViewTransition在 update 回调中切换 DOM,浏览器捕获前后状态同一文档内完成由脚本自行控制低,单文档内闭环SPA 路由切换,不涉及文档卸载
纯 CSS 动画无法捕获旧文档状态仅能对新文档做入场动画需脚本恢复只要求新页面入场效果
手动保留旧 DOM需在导航前阻止卸载并保留旧节点新文档加载后叠加需脚本恢复高,易引发可访问性问题不推荐用于常规 MPA 导航

纯 CSS 动画无法拿到旧文档的视觉快照,所以卡片从详情页主图滑回列表这种跨文档连续移动,只有 View Transitions 能表达。同文档 startViewTransition 的时机更可控,因为它不涉及文档卸载,但代价是应用必须自己承担路由和状态管理。

常见失败模式与诊断信号

卡片不移动、只做整页淡入,最常见的原因是 pageswap 里设置名字的动作被异步化,旧快照捕获时元素还没有名字。诊断方法是把设置名字的代码放在回调最前面,确认它同步执行,再检查 e.viewTransition 是否非空。

第二次导航时过渡异常或报命名冲突,通常是上一次过渡结束后没有把 viewTransitionName 重置为 none。旧文档进入 bfcache 后,带名字的元素状态被保留,下次导航时同一个名字可能对应多个元素。MDN 示例中用 await e.viewTransition.finished 后重置,就是为了避免这个问题。

返回列表页时滚动位置丢失,需要区分是浏览器没有恢复,还是恢复发生在 pagereveal 之后导致你的判断失效。可以在 pagereveal 中打印 window.scrollYnavigation.activation.from,观察实际顺序。如果滚动确实未恢复,说明该导航路径可能没有走 bfcache,需要检查是否有 unload 监听、Cache-Control: no-store 等阻止 bfcache 的因素。

过渡动画卡顿或掉帧,通常与快照尺寸有关。整页快照加上一个独立卡片快照,意味着浏览器要合成多层图像。如果卡片本身很大或包含复杂滤镜,合成成本会上升。可以先用 view-transition-name 只标记真正需要独立移动的元素,其余部分交给默认的整页交叉淡入。

适用边界与不适用场景

跨文档视图过渡适合同源、以内容浏览为主的 MPA 导航,比如列表到详情、详情返回列表、文章之间的跳转。它的收益是视觉连续性,代价是两侧文档都要声明并处理命名生命周期。

跨源导航不适用,因为新旧文档不同源,浏览器不会让它们共享过渡层。需要登录态跳转的外部支付、第三方 SSO 回跳,都不应指望跨文档过渡。

对首屏渲染时间敏感且无法接受额外快照合成成本的页面,也应谨慎启用。过渡动画本身不阻塞导航,但快照捕获和合成会占用主线程与合成线程资源,在低端移动设备上可能放大卡顿。

最后,pageswappagereveal 的可用性因浏览器而异,且属于较新的能力。生产环境应把它们当作渐进增强:不支持时页面仍能正常导航,只是没有过渡动画。不要为了过渡而延迟导航或阻塞卸载,那会牺牲用户实际感知的加载速度。

资料来源

  1. MDN: View Transition API
  2. MDN: Window pageswap event
  3. MDN: Window pagereveal event
  4. W3C CSS View Transitions Module Level 1