上个月把一个电商前台从 SPA 改回了多页应用。原因不复杂,SEO 那边实在扛不住了,服务端渲染的 SPA 加上一堆 hydrate 问题,Core Web Vitals 一直上不去。改成 MPA 之后 SEO 指标是好了,但用户体验直接掉了档——每次点商品卡片,页面闪一下白,然后新内容跳出来,非常生硬。
一开始想手写一段淡入淡出遮一下,做出来的效果很假:内容先变透明,等新页面 ready 再变回来,中间总有半秒钟白屏漏出来。后来去翻了翻 View Transitions API 的文档,发现这个 API 就是为这种场景设计的——而且跨文档过渡(Cross-document View Transitions)反而比 SPA 里那种程序化的用法更省事。
两行 CSS 就能跑起来
先说最小可用的写法。在参与跳转的两端页面里都写这么一段:
@view-transition {
navigation: auto;
}
就这两行。打开浏览器点个链接试试——跳转的时候会有一个淡入淡出效果,旧的页面渐隐,新的页面渐显,整个过程大概 250 毫秒。
这里有几个前提条件得满足。
第一,两个页面必须同源。跨域跳转不会触发。
第二,导航必须是普通链接点击或者表单提交这类”浏览器自己处理”的导航。如果你在 click 事件里 preventDefault() 然后 location.href = ...,一样会触发,但如果是 window.open 或者 history.pushState 就不会。
第三,用户如果开了”减少动态效果”(prefers-reduced-motion: reduce),浏览器会自动跳过动画,直接切换。这点是内置的,不用自己处理。
动画背后发生了什么
想看明白怎么自定义,得先知道浏览器做了什么。
当检测到导航要发生时,浏览器会在旧页面拍一张”快照”,把整个视口渲染成一个图片。新页面加载完成之后,也拍一张快照。然后在一个临时的伪元素树上,把这两张图叠在一起做交叉淡入淡出。
这个伪元素树长这样:
::view-transition
::view-transition-group(root)
::view-transition-image-pair(root)
::view-transition-old(root)
::view-transition-new(root)
::view-transition 是最外层容器,铺满整个视口,隔离了页面其他内容。root 这个名字对应的是页面根元素的快照。下面 old 是旧图,new 是新图。
默认的动画就写在 old 和 new 上:old 从 opacity 1 变到 0,new 从 0 变到 1,时长 250 毫秒。
默认的太素了,换个自己写的
想让新页面从右侧滑入,可以这样:
::view-transition-old(root) {
animation: slide-out-left 320ms cubic-bezier(.4, 0, .2, 1) both;
}
::view-transition-new(root) {
animation: slide-in-right 320ms cubic-bezier(.4, 0, .2, 1) both;
}
@keyframes slide-out-left {
to { transform: translateX(-16%); opacity: 0; }
}
@keyframes slide-in-right {
from { transform: translateX(16%); opacity: 0; }
}
注意这里换了根元素的动画,会影响所有共享元素的容器。如果你只针对某个特定元素做不同的动画,得靠 view-transition-name 给它单独分配一个 group。
真正有意思的部分:共享元素
淡入淡出其实随便写个遮罩都能糊弄过去,真正让这套 API 值得用的是能不能做”元素从列表飞到大图位置”这样的转场。
做法是给两个页面上打算”当着是同一个元素”的 DOM 节点,设置同一个 view-transition-name:
/* 列表页 */
.card-hero {
view-transition-name: product-hero;
}
/* 详情页 */
.detail-image {
view-transition-name: product-hero;
}
名字一样,浏览器就会认为:旧页面上这块区域和新页面上那块区域是一回事。它会把这两个位置、大小都记下来,然后生成一个额外的 ::view-transition-group(product-hero),让这个”元素”从旧位置滑到新位置,同时按新旧尺寸做插值。
这里有个坑一定要先记住:同一时刻,页面上所有可见元素的 view-transition-name 必须唯一。如果有两个元素用了同一个名字,整个跨文档过渡会被直接取消——不是那一个元素不参与,是所有的都不参与。这个失败很静默,控制台只留一行 warn,很容易忽略。
完整案例:商品列表跳到商品详情
下面把前面这些串起来,做一个”点卡片,图片从卡片位置飞到大图位置”的转场。
先是列表页:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>商品列表</title>
<link rel="stylesheet" href="/styles.css" rel="external nofollow" rel="external nofollow" >
</head>
<body>
<main>
<ul class="grid">
<li>
<a class="card" href="/detail.html?id=101" rel="external nofollow" data-id="101">
<img class="card-hero" src="/img/101-thumb.webp" alt="">
<h3>温和洁面乳</h3>
<p>¥98</p>
</a>
</li>
<li>
<a class="card" href="/detail.html?id=102" rel="external nofollow" data-id="102">
<img class="card-hero" src="/img/102-thumb.webp" alt="">
<h3>保湿精华</h3>
<p>¥168</p>
</a>
</li>
<!-- 更多卡片 -->
</ul>
</main>
</body>
</html>
再看详情页:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>商品详情</title>
<link rel="stylesheet" href="/styles.css" rel="external nofollow" rel="external nofollow" >
</head>
<body>
<main>
<img class="detail-image" src="/img/101-large.webp" alt="">
<h1>温和洁面乳</h1>
<p class="price">¥98</p>
<!-- 其他详情内容 -->
</main>
</body>
</html>
问题来了:卡片有好几个,我没办法在 CSS 里给所有图片都写死 view-transition-name: product-hero,那样名字就重了,整个过渡会崩掉。
正确做法是只给被点击的那张卡片动态加上名字。用 JS 在点击的时候处理:
document.querySelectorAll('.card').forEach(card => {
card.addEventListener('click', () => {
const img = card.querySelector('.card-hero');
img.style.viewTransitionName = 'product-hero';
});
});
这段代码要放在一个会被 pageswap 事件能捕捉到的位置。实际上更稳妥的写法是把逻辑挂在 pageswap 上,因为它保证在快照之前触发:
window.addEventListener('pageswap', event => {
const clicked = document.querySelector('.card.activating');
if (clicked) {
clicked.querySelector('.card-hero').style.viewTransitionName = 'product-hero';
}
});
但这个 .activating 类哪来的?这就是下面要说的:光靠点链接跳转,脚本没法告诉离开页面哪个卡片被点了。得先记录一下。
用 pageswap 和 pagereveal 协同工作
跨文档过渡提供了两个事件让脚本介入。
pageswap:在旧页面即将被替换、快照即将拍摄时触发。事件对象上有 viewTransition 属性,如果不是 undefined,说明这次跳转会走过渡流程。
pagereveal:在新页面第一次渲染后触发,此时快照还没拍,你还有机会改样式。
唯一的问题是,pageswap 里拿不到”用户点了哪个元素”这个信息——如果是普通链接点击,浏览器没有直接给你目标元素。得自己提前记下来。
最省事的做法是存到 sessionStorage 里:
document.addEventListener('click', e => {
const link = e.target.closest('a[data-id]');
if (link) {
sessionStorage.setItem('lastClickedId', link.dataset.id);
}
});
然后在 pageswap 里读出来:
window.addEventListener('pageswap', () => {
const id = sessionStorage.getItem('lastClickedId');
if (!id) return;
const card = document.querySelector(`a[data-id="${id}"] img.card-hero`);
if (card) card.style.viewTransitionName = 'product-hero';
});
到了详情页,需要把同样的名字给到大图。这一侧没有”哪个卡片被点”的问题,因为详情页只有一个大图,直接写:
window.addEventListener('pagereveal', () => {
const hero = document.querySelector('.detail-image');
if (hero) hero.style.viewTransitionName = 'product-hero';
});
两侧名字对上,共享元素过渡就成立了。你会看到缩略图从卡片位置放大、滑到详情页的大图位置,尺寸和位置都在插值,效果比简单的淡入淡出高级得多。
用 types 区分前进和后退
前进的时候希望新页面从右边进来,后退的时候希望老页面从右边回来,这两个方向得区分开。
View Transitions 提供了 types 机制。@view-transition 里可以带一个 types 属性:
@view-transition {
navigation: auto;
types: slide;
}
然后在 CSS 里按类型写规则:
html:active-view-transition-type(slide) {
&::view-transition-old(root) {
animation: slide-out-left 320ms both;
}
&::view-transition-new(root) {
animation: slide-in-right 320ms both;
}
}
那从哪知道是前进还是后退呢?看导航的 URL 和当前 URL 的关系。在 pageswap 事件里可以判断:
window.addEventListener('pageswap', event => {
if (!event.viewTransition) return;
const from = new URL(event.activation.from.url);
const to = new URL(event.activation.entry.url);
// 简单判断:路径变深算前进,变浅算后退
if (to.pathname.split('/').length > from.pathname.split('/').length) {
event.viewTransition.types.add('forward');
} else {
event.viewTransition.types.add('backward');
}
});
然后在 CSS 里分别针对 forward 和 backward 写动画就行。这套写法比 SPA 里用状态机管理方向要简单多了,因为 activation 里连从哪里来到哪里去都告诉你了。
兼容性和降级策略
跨文档视图过渡目前的浏览器支持情况:Chrome 126 及以上、Edge 126 及以上、Safari 18.2 及以上。Firefox 还在实现中。
值得高兴的是,这个特性的降级成本几乎为零。不支持的时候,浏览器什么都不做,页面正常跳转,用户看到的是普通导航效果。不像某些 API 需要你写一堆 fallback 代码。
如果想在代码里判断支持情况,可以这么写:
const supportsCrossDocVT = CSS.supports('view-transition-name', 'x')
&& 'onpageswap' in window;
有两个条件必须都判断。CSS.supports 只能说明支持视图过渡,但可能只是 SPA 版本的支持,没有跨文档能力。onpageswap 这个事件属性才是跨文档过渡的特征。
另外如果你的站点有页面级缓存(bfcache),需要注意一点:从 bfcache 恢复的导航不会触发视图过渡。用户按后退键的时候,如果页面是从缓存里恢复的,动画会被跳过。这是符合预期的,因为页面本来就在那儿,硬加个动画反而怪。
几个踩过的坑
name 重复会整个过渡挂掉。前面提过一次,但值得再讲一遍,因为它的表现太隐蔽。你在列表页给所有卡片名字的时候,如果忘了清理上一个,第二次点击时就会有两个元素同时用 product-hero,这次导航就完全没有过渡。排查的时候打开控制台的 warn 级别日志,能看到相关提示。
别在过渡进行时改 DOM。过渡期间浏览器已经把页面渲染成图片了,这时候你如果改了什么会触发重绘的属性,浏览器可能会重新拍快照,整个动画就乱套了。所有需要在过渡期间改的东西,必须在 pageswap 或者 pagereveal 里提前改完。
iframe 里的内容不算。跨文档过渡只拍主文档的快照,iframe 里如果嵌了广告或者地图,过渡期间会闪一下。这个目前没有特别好的解决办法,只能接受或者干脆去掉 iframe。
页面高度差异会抖。如果离开时页面很长(比如列表页),进入时页面很短(比如详情页),快照的尺寸不一致,浏览器会按高度插值,新页面会从压缩状态展开,看起来有点诡异。可以在 pagereveal 里把新页面的初始高度设成和旧页面接近,或者干脆在过渡期间给 html 加一个固定的高度。
动画时长别设太长。默认 250 毫秒是经过考量的。很多人第一次做,会忍不住把时长拉到 600 甚至 800 毫秒,觉得能看清楚动画。实际上用户看三遍就腻了,之后每一次跳转都变成折磨。300 毫秒是个比较舒服的上限。
和 SPA 方案的取舍
如果你的项目本来就是 SPA,那程序化的 document.startViewTransition() 更合适,可以用在路由切换上,粒度控制更细。但如果你的项目是 MPA,或者正在从 SPA 往 MPA 迁,跨文档过渡是几乎零成本的方案——加一段 CSS,加几十行 JS,就有一整套转场效果。
我这次的迁改总共花了不到一天,主要时间都花在调动画曲线和测试边界情况上。相比重新搭一套 SPA 路由系统的成本,这个投入产出比是很难拒绝的。
还有个小经验:如果站点里有多个不同方向的跳转(列表→详情、详情→编辑、编辑→回到列表),建议给每种跳转单独定义一组 view-transition-name,名字上做区分,比如 hero-product、hero-article。万一某次导航时状态没清理干净,名字也不会撞车。
最后提一句:这套 API 的好玩之处在于,你先用最简单的两行 CSS 跑通,然后逐步加共享元素、加方向、加 duration 控制,每一步都有肉眼可见的效果提升。做前端这么久,很少有哪个特性是这样一点点搭起来的体验。

