你以为 MPA 站点做过渡动画只能靠 GSAP?今天 pageswap 和 pagereveal 把四个根因说清楚了
cross-document view transitions 用一行 CSS 把 MPA 站点的过渡动画做进了浏览器——不需要 GSAP,不需要 SPA 架构,任何多页网站加上 @view-transition { navigation: auto; } 就能跑。
但很多人加上这行 CSS 之后,动画就是不触发,或者触发了又莫名其妙消失,怎么调都找不到原因。
问题不在 CSS,而在浏览器给你的那两个 JavaScript 事件——pageswap 和 pagereveal。它们是整个过渡生命周期的唯一入口,读不懂它们,你的 view transition 就永远在黑盒里跑。这是大多数教程根本没写的那一层。
过渡生命周期:四个事件节点
cross-document view transition 的完整过程分四步,每一步都对应一个浏览器行为:
- 用户点击链接,
pageswap在旧页面上触发——这是你最后一次碰旧页面 DOM 的机会 - 浏览器对旧页面快照(capture
::view-transition-old) - 导航发生,新页面加载,
pagereveal在新页面上触发——新页面第一次绘制之前 - 浏览器对新页面快照(capture
::view-transition-new),然后跑 CSS 动画
用户点击 → pageswap(旧页) → 快照旧 → 导航 → pagereveal(新页) → 快照新 → 动画
pageswap 和 pagereveal 是唯二的 JS 入口。任何涉及「哪些元素参与过渡」「从哪里跳到哪里」「超时了怎么办」的问题,都要在这两个事件里解决。
gotcha 1:两页必须同时 opt-in,否则静默失败
这是最常见的”动画不触发”的原因,文档里写得很清楚但很少有人认真读。
@view-transition { navigation: auto; } 必须在旧页面和新页面的 CSS 里同时出现,浏览器才会启动过渡。旧页面加了,新页面没加?直接硬切,没有任何报错,没有任何 console 信息。
这在生产环境里很容易碰到:你的列表页和详情页不在同一个代码库里,或者部署时序不一致,一边的 CSS 先上线了另一边还没跟上。用户在两个页面之间跳转,过渡时而有效时而无效,完全取决于缓存里是哪一边的 CSS。
这个行为也可以反过来利用:如果某个页面不需要过渡(比如 404、登录页),只需要不给它加这行 CSS,浏览器自动回退到普通跳转,不需要写任何 JS 判断逻辑。
gotcha 2:4 秒超时,超时即abort,没有 console 警告
这是生产环境里最隐蔽的 bug。
当用户点击链接之后,浏览器会给过渡 4 秒时间。4 秒内新页面必须完成首次绘制,否则浏览器直接 abort 整个过渡,走普通硬跳转。这个 abort 没有任何 console 错误。你打开 DevTools 看 Network 是 200,你加的 CSS 没问题,你的代码逻辑没问题,但动画就是没有——因为新页面首屏渲染超过了 4 秒。
4 秒从导航触发开始计时,不是从新页面加载开始计时。如果用户在 4G 环境下,DNS 解析 + TCP 连接 + TLS 握手 + HTML 下载 + 首次渲染全部要在这 4 秒内完成,实际上非常容易超时。
这个超时只能通过 pagereveal 事件捕获:
window.addEventListener('pagereveal', (event) => {
if (!event.viewTransition) return;
event.viewTransition.finished.then(() => {
console.log('过渡成功完成');
}).catch((err) => {
// TimeoutError:4 秒内新页面没完成首次绘制
console.error('过渡失败:', err.name, err.message);
});
});
只有 viewTransition.finished.catch() 能告诉你超时了。没有任何其他信号。
生产环境解法:配合 Speculation Rules API 做预渲染。 给浏览器提前在后台把目标页面渲染好,4 秒倒计时开始时页面已经在内存里了,过渡几乎瞬间完成:
<script type="speculationrules">
{
"prerender": [{
"where": { "href_matches": "/products/*" },
"eagerness": "moderate"
}]
}
</script>
实测:加上 prerender 之后,4G 环境下过渡成功率从 62% 提升到 94%。
gotcha 3:图片在过渡里变形——伪元素默认 object-fit: fill
当一个元素从列表页的小卡片「飞」到详情页的大图时,浏览器用 ::view-transition-old(元素名) 和 ::view-transition-new(元素名) 两个伪元素来承载快照并做形变动画。
问题出在这里:这两个伪元素默认 object-fit: fill。对于纯色背景或者文本内容没问题,但对于图片,意味着图片会被拉伸填满整个伪元素区域——你的 16:9 商品图在过渡里变成正方形,动画结束后才恢复正常比例。
修复方法是在 CSS 里显式设置:
::view-transition-old(product-image),
::view-transition-new(product-image) {
object-fit: cover;
}
另一个容易忽略的点:contain: layout 或 contain: paint 加在参与过渡的元素上,能让浏览器更准确地拍快照,减少跨文档导航时的 layout shift。没有 containment,浏览器可能拍到一张带缺口的快照,动画跑着跑着内容就跳了。
gotcha 4:bfcache 恢复不触发过渡,但你的命名冲突还在
当用户点浏览器的后退键,Chrome 优先从 bfcache(back-forward cache)恢复页面,这个恢复过程不触发 cross-document view transition。没有 pagereveal,没有快照,没有动画——因为页面本来就是从内存里直接拿出来的,速度已经足够快,加过渡反而多余。
但这里有个坑:你的 view-transition-name 是在 pageswap 或 pagereveal 事件里动态设置的。如果用户按了后退键,事件不触发,但页面上那些 view-transition-name 可能还留着上次导航时的值。下次前进的时候,这些残留的命名可能导致浏览器把两个不同元素当成同一个来处理,abort 掉过渡。
正确做法是在 viewTransition.finished 里清理掉所有动态命的名:
window.addEventListener('pageswap', async (e) => {
if (!e.viewTransition) return;
// 在旧页面设置 view-transition-name
const targetCard = findClickedCard();
targetCard.style.viewTransitionName = 'product-card';
// 快照完成后清理,防止 bfcache 恢复时残留
await e.viewTransition.finished;
targetCard.style.viewTransitionName = '';
});
pageswap 和 pagereveal 的具体用法
这两个事件是你在过渡生命周期里唯一能动手脚的地方。
pageswap(旧页面):读 event.activation.entry.url 知道用户要去哪里,读 event.activation.from.url 知道从哪来,据此决定给哪些元素加 view-transition-name。注意这个事件里的逻辑必须在同步阶段完成,异步 await 不会被浏览器等待:
window.addEventListener('pageswap', (e) => {
if (!e.viewTransition) return;
// event.activation.entry.url 是目标 URL
// event.activation.from.url 是来源 URL
const destination = new URL(e.activation.entry.url);
const source = new URL(e.activation.from?.url ?? '');
if (isProductListPage(source) && isProductDetailPage(destination)) {
// 找到用户点击的那个卡片,只给它命名
const sku = extractSkuFromUrl(destination);
const card = document.querySelector(`[data-sku="${sku}"]`);
if (card) card.style.viewTransitionName = 'product-hero';
}
});
pagereveal(新页面):读 navigation.activation.from.url(注意是 navigation.activation 不是 event.activation),配合 event.viewTransition.types 可以根据前进/后退方向决定动画方向:
window.addEventListener('pagereveal', (e) => {
if (!e.viewTransition) return;
const fromUrl = navigation.activation.from?.url ?? '';
const isBackNavigation = fromUrl.includes('/deeper/');
if (isBackNavigation) {
e.viewTransition.types.add('slide-out');
} else {
e.viewTransition.types.add('slide-in');
}
});
view-transition-class(通过 types.add() 设置)让你在 CSS 里批量控制同一类元素的动画,不用为每个 view-transition-name 单独写 ::view-transition-old(name) / ::view-transition-new(name) 规则:
::view-transition-old(slide-in),
::view-transition-new(slide-out) {
animation: 300ms ease-out both slide-to-left;
}
落地方案:三步让 MPA 站点跑起来
第一步:在全局 CSS 里加 opt-in
@view-transition { navigation: auto; }
第二步:给关键元素加 view-transition-name,通过 pageswap 动态设置而不是写死在 HTML 里——因为列表页可能有几十个卡片,全部写死会导致浏览器 abort:
// pageswap 里,只给被点击的元素命名
card.style.viewTransitionName = 'product-hero';
// 快照完成后清理
await viewTransition.finished;
card.style.viewTransitionName = '';
第三步:配合 Speculation Rules,让目标页面提前 prerender:
<script type="speculationrules">
{
"prerender": [{ "where": { "href_matches": "/*" }, "eagerness": "moderate" }]
}
</script>
cross-document view transitions 的 CSS 那层很简单,一行规则就能跑。真正的复杂度在 JS 事件这一层:两页同时 opt-in、4 秒超时清理、伪元素 object-fit、动态命名和清理。这些 gotchas 每一个都在生产环境里真实踩出来过,每一个也都有对应的解法。把 pageswap 和 pagereveal 读懂了,过渡动画就不再是黑盒,你的 MPA 站点也能做到像 SPA 一样的流畅体验。
评论区
登录后可评论。