从一个返回列表页的闪动说起
商品列表页滚动到第 12 屏,点进某个商品详情,看完后按浏览器返回。用户期望列表页停在原来的位置,被点击的那张卡片从详情页主图的位置滑回列表。实际看到的却常常是:列表页从顶部重新出现,或者卡片先闪一下再落位,甚至过渡动画整个跳过。
直觉上可行的做法是在详情页里监听 popstate 或 beforeunload,在离开前给卡片加上 view-transition-name,让浏览器把这张卡片作为独立快照带走。但跨文档导航里,旧文档在导航开始后很快就不再参与渲染,beforeunload 触发时旧文档的渲染状态已经进入卸载流程,此时再改 DOM 往往影响不到已经捕获或即将捕获的旧快照。真正能决定旧快照长什么样的时点,是 pageswap。
跨文档视图过渡要解决的核心问题,是让两个独立文档的视觉状态在同一层里做动画,而不必让旧文档的 DOM 一直保留到动画结束。浏览器把旧状态做成静态图像快照,把新状态做成实时快照,两者放进伪元素树里交叉淡入并做位置尺寸插值。这套机制要求开发者知道:快照在哪个时点被捕获,哪些 API 在那个时点还能改到渲染结果。
跨文档过渡的三段生命周期
跨文档视图过渡不是一次连续动画,而是被导航切成了三段。第一段发生在旧文档,导航被确认后、旧文档即将卸载前,浏览器触发 pageswap。此时旧文档仍可访问 DOM,PageSwapEvent 会暴露 viewTransition 对象以及 activation 信息,后者包含导航类型、来源历史条目和目标历史条目。浏览器在这个窗口内捕获旧状态快照。
第二段是文档切换本身。旧文档被卸载,新文档从网络加载或从 bfcache、prerender 中激活。这段里没有可用的页面脚本,过渡的视觉连续性完全由浏览器维护。
第三段发生在新文档,文档首次渲染时触发 pagereveal。PageRevealEvent 同样暴露 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.from 和 e.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 表示对同源导航启用过渡。跨源导航、下载、表单提交等场景是否触发,取决于导航类型,开发者不应假设所有导航都会过渡。
降级判断需要分两层。第一层是能力检测:pageswap 和 pagereveal 在 MDN 上都被标注为 Limited availability,并非 Baseline,在部分主流浏览器中不可用。可以用 if ("onpageswap" in window) 之类的检测决定是否注册监听。第二层是行为降级:即使事件存在,如果 e.viewTransition 为空,说明本次导航没有触发过渡,回调应直接返回,不要执行设置名字的逻辑。
| 方案 | 旧状态处理 | 新状态处理 | 滚动位置 | 实现复杂度 | 适用边界 |
|---|---|---|---|---|---|
| 跨文档 View Transitions | pageswap 同步设置名字,浏览器捕获静态快照 | 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.scrollY 和 navigation.activation.from,观察实际顺序。如果滚动确实未恢复,说明该导航路径可能没有走 bfcache,需要检查是否有 unload 监听、Cache-Control: no-store 等阻止 bfcache 的因素。
过渡动画卡顿或掉帧,通常与快照尺寸有关。整页快照加上一个独立卡片快照,意味着浏览器要合成多层图像。如果卡片本身很大或包含复杂滤镜,合成成本会上升。可以先用 view-transition-name 只标记真正需要独立移动的元素,其余部分交给默认的整页交叉淡入。
适用边界与不适用场景
跨文档视图过渡适合同源、以内容浏览为主的 MPA 导航,比如列表到详情、详情返回列表、文章之间的跳转。它的收益是视觉连续性,代价是两侧文档都要声明并处理命名生命周期。
跨源导航不适用,因为新旧文档不同源,浏览器不会让它们共享过渡层。需要登录态跳转的外部支付、第三方 SSO 回跳,都不应指望跨文档过渡。
对首屏渲染时间敏感且无法接受额外快照合成成本的页面,也应谨慎启用。过渡动画本身不阻塞导航,但快照捕获和合成会占用主线程与合成线程资源,在低端移动设备上可能放大卡顿。
最后,pageswap 和 pagereveal 的可用性因浏览器而异,且属于较新的能力。生产环境应把它们当作渐进增强:不支持时页面仍能正常导航,只是没有过渡动画。不要为了过渡而延迟导航或阻塞卸载,那会牺牲用户实际感知的加载速度。