跨文档 View Transitions:多页应用也能做出丝滑过渡了

接手过一个内容站,技术栈是十年前那套:服务端渲染模板,每次点链接都是整页刷新。产品同学总拿手机上的原生 App 来对比,说人家点进详情页是图片放大铺满屏,我们这边是白屏闪一下然后页面跳变。我当时的第一反应是上 SPA 框架重写路由,把整站改成客户端导航。预算和工期都不允许,方案被毙了。

后来真正落地的,是跨文档的 View Transitions。它不要求我改架构,还是老老实实的多页跳转,但浏览器会在两个文档之间插一段过渡动画。改完之后那个"图片从列表放大到详情"的效果,几乎和原生一样,而我几乎没动 JS。

先回忆一下同文档版本

View Transitions 这套 API 我其实更早在一个 SPA 项目里用过,那是同文档(same-document)的形态。先看这个方法:

1function navigate(url) {
2  if (!document.startViewTransition) {
3    updateDOM(url);
4    return;
5  }
6  document.startViewTransition(() => updateDOM(url));
7}

startViewTransition 接收一个回调,回调里同步地把 DOM 改成新状态。浏览器的做法是:在回调执行前对当前页面截一张"旧"快照,回调改完 DOM 后再截一张"新"快照,然后在这两张快照之间做交叉淡入淡出,默认就是一个 fade。

这套机制在 SPA 里很自然,因为 DOM 一直在,路由切换本来就是改 DOM。但传统多页站点没有这个回调时机——你点链接,旧文档直接被卸载,新文档从头加载,中间根本没有"同一个 document 改 DOM"这回事。所以早期 View Transitions 在 MPA 上完全用不了,这也是它当初没在我那个内容站派上用场的原因。

跨文档:一行 CSS,不写 JS

转机是跨文档(cross-document)View Transitions 落地。它解决的正是 MPA 场景:两个独立的 HTML 文档之间也能过渡。

最让我意外的是开启方式——不需要 JS,一段 CSS 就够:

1@view-transition {
2  navigation: auto;
3}

这段规则要同时写在"来源页"和"目标页"。两边都声明了 navigation: auto,且导航属于同源的常规跳转(普通链接点击、表单提交、history 前进后退),浏览器就会自动在旧文档和新文档之间触发过渡。

我第一次加上去刷新一看,整页跳转那下白闪没了,取而代之是一个平滑的淡入淡出。就这一行,连构建配置都没碰。当时我对着屏幕愣了几秒,习惯性想找"还有没有别的步骤",确实没有了。

默认动画是整页的交叉淡化。要做"共享元素放大"这种效果,才需要再往下走一步。

用 view-transition-name 配对做形变

默认整页 fade 已经比白闪强很多,但产品要的是那个"列表里的封面图放大到详情页"。这就要靠 view-transition-name

思路是:在列表页给某张封面图起一个过渡名字,在详情页给那张大图起同一个名字。浏览器发现新旧两个文档里有同名的元素,就不会把它们当成整页快照的一部分,而是单独拎出来,在两者的位置、尺寸之间做形变(morph)动画。

列表页的卡片:

1<a href="/posts/42" class="card">
2  <img class="cover" src="/img/42.jpg" alt="" />
3  <h3>某篇文章标题</h3>
4</a>
1.card .cover {
2  view-transition-name: cover-42;
3}

详情页的大图:

1.detail .hero {
2  view-transition-name: cover-42;
3}

两个文档里都叫 cover-42。点进去的时候,列表里那张小图就会平滑地放大、移动到详情页大图的位置,看起来像同一个元素被"带"了过去。标题同理,我又给标题配了一对 view-transition-name: title-42,于是标题也是从卡片位置滑到详情页顶部,而不是凭空淡出再淡入。

这里有个我一开始没想明白的点:名字必须能对应上。列表页 42 号卡片叫 cover-42,详情页恰好是 42 号文章,名字一致,配对成功。如果详情页用了个写死的名字 cover,而列表页每张卡片名字都不同,那点哪张都对不上,就退回整页 fade。

同一页里同名只能有一个,否则报错

紧接着我就踩了配对机制最大的坑。

我图省事,给列表页所有卡片的封面都写了同一个名字:

1.card .cover {
2  view-transition-name: cover; /* 所有卡片都叫 cover */
3}

结果过渡直接不工作了,控制台一行警告:同一个文档里 view-transition-name 必须唯一,发现重复后整个过渡被跳过。

规范要求:在任意一次过渡的快照里,每个 view-transition-name 在同一文档内只能命中一个元素。一个列表页有几十张卡片都叫 cover,浏览器没法判断该把哪张和详情页配对,干脆放弃。

正确做法是给每张卡片生成唯一名字。我的模板是服务端渲染的,直接用文章 id 拼:

1<img class="cover" src="..." style="view-transition-name: cover-{{ post.id }}" />

如果是构建期或客户端动态生成,也可以用 CSS 自定义属性 + attr(),或者直接在循环里写内联 style。关键是保证同一页内不撞名。

修完之后又顺手验证了一种情形:列表页同时存在 cover-41 cover-42 cover-43,详情页只有 cover-42,这是允许的——没匹配上的那些名字(41、43)在新文档里不存在,它们就按普通元素走整页过渡,只有 42 单独做形变。这正是我想要的。

用伪元素自定义动画

默认形变是线性的位置 + 尺寸插值。想调时长、缓动曲线、甚至换成别的动画,要靠一组伪元素。

过渡进行时,浏览器会在根元素上构造一棵伪元素树,结构大致是:

::view-transition
└── ::view-transition-group(name)
    └── ::view-transition-image-pair(name)
        ├── ::view-transition-old(name)
        └── ::view-transition-new(name)

group 控制整体的位置和尺寸过渡,oldnew 分别是旧/新快照的图像,默认在 image-pair 里做交叉淡化。这些都能用 CSS 选中。

比如把所有过渡统一调成 0.4 秒、带点 ease 曲线:

1::view-transition-group(*) {
2  animation-duration: 0.4s;
3  animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
4}

(*) 是通配,匹配所有命名组。也可以只针对某个名字精调:

1::view-transition-group(cover-42) {
2  animation-duration: 0.5s;
3}

我给整页根快照(默认名字是 root)单独压短了淡化时间,让背景切换更利落,而共享元素的形变稍慢一点,主次就出来了:

1::view-transition-old(root),
2::view-transition-new(root) {
3  animation-duration: 0.2s;
4}
5
6::view-transition-group(cover-42) {
7  animation-duration: 0.45s;
8}

还有个细节:默认 oldnew 都是把快照当成图片按比例填充,如果新旧元素长宽比差很多,形变中途会有拉伸感。可以给它们设 object-fit

1::view-transition-old(cover-42),
2::view-transition-new(cover-42) {
3  object-fit: cover;
4  height: 100%;
5}

这一步在我这个案例里很关键,因为列表缩略图是正方形,详情大图是宽幅 16:9,不处理的话放大过程会有明显的变形。

关于动画的方向:是 group 在动,不是元素

有一点我卡了挺久才想通:形变过渡里真正"动"的是 ::view-transition-group,它从旧元素的几何位置插值到新元素的几何位置。而 oldnew 这两张快照是叠在 group 里做淡化的——旧图淡出、新图淡入,同时整个 group 在移动和缩放。

理解这点之后,调试就有方向了。如果觉得形变路径不对,是改 group 的动画;如果觉得新旧两张图切换得太生硬或太糊,是改 old/new 的淡化时长和叠加方式。我一开始把这两件事混在一起调,怎么调都别扭,分清楚之后很快就顺了。

还有个容易忽略的点:group 的动画是位置和尺寸的几何插值,浏览器自动算关键帧,你一般只需要管 animation-durationanimation-timing-function,不用自己写 @keyframes。只有当你想完全改变形变以外的行为(比如让它走一条曲线路径、或者旋转)时才需要自定义关键帧,那种情况我在这个项目里没遇到。

pageswap / pagereveal:需要 JS 的精细控制

到这一步纯 CSS 已经能覆盖大部分需求。但有两类问题只能靠 JS:一是想根据导航类型(前进、后退、刷新)做不同动画,二是过渡触发时需要临时改一改某个元素的过渡名字。

跨文档过渡为此提供了两个事件:

  • pageswap:旧文档即将被换走、过渡快照即将拍摄前触发。
  • pagereveal:新文档首次渲染、过渡即将开始播放前触发。

两个事件都给一个 viewTransition 对象(前提是这次导航确实触发了过渡),还能拿到导航信息。

一个典型用法:返回上一页时,希望动画方向反过来。我在旧页面监听 pageswap,根据目标 URL 决定要不要给某个元素临时挂名字:

1window.addEventListener('pageswap', (e) => {
2  if (!e.viewTransition) return;
3
4  const toDetail = e.activation.entry.url.includes('/posts/');
5  if (!toDetail) {
6    // 离开列表去别处,不做共享元素过渡,清掉名字
7    document.querySelectorAll('.card .cover').forEach((el) => {
8      el.style.viewTransitionName = '';
9    });
10  }
11});

在新页面用 pagereveal 给根元素打个标记,CSS 据此选不同的动画方向:

1window.addEventListener('pagereveal', (e) => {
2  if (!e.viewTransition) return;
3
4  const nav = e.activation?.navigationType; // 'push' | 'reload' | 'traverse' | 'replace'
5  document.documentElement.dataset.nav = nav;
6});
1html[data-nav="traverse"]::view-transition-old(root) {
2  /* 后退时让旧页面往右滑出 */
3  animation-name: slide-out-right;
4}

activation 上的 navigationType 能区分是新前进(push)、后退/历史遍历(traverse)还是刷新(reload),这是判断动画方向最可靠的信号。我之前试过用 referrer 猜方向,遇到从外站跳进来就判断错,换成 navigationType 后就稳了。

pageswap 里还有一个我后来才发现很有用的能力:可以在快照拍摄前临时改 DOM 或样式。因为这个事件在旧文档截图之前触发,你这时候动的东西会被算进"旧"快照。我用它做过一件事——列表页有个 hover 才显示的操作浮层,正常点进详情时这个浮层是显示着的,如果直接截图,过渡里会看到一个莫名其妙的浮层淡出。我在 pageswap 里把它隐藏掉,截出来的旧快照就干净了:

1window.addEventListener('pageswap', (e) => {
2  if (!e.viewTransition) return;
3  document.querySelectorAll('.card-overlay').forEach((el) => {
4    el.style.display = 'none';
5  });
6});

要注意这些改动只影响快照,不影响真实页面——反正旧文档马上就被卸载了。但如果是同文档过渡,这种改法就要小心,因为改的是会留下来的真实 DOM。跨文档场景下没有这个顾虑,旧文档是一次性的,改完即焚。

过渡期间页面是"冻结"的,别在这时候干重活

有个性能相关的坑值得单独说。跨文档过渡的过程是:浏览器拍下旧文档快照,加载新文档,新文档第一帧渲染好之后,在两套快照之间播动画。这意味着动画播放的那几百毫秒里,页面视觉上是被快照"接管"的。

如果新文档首屏渲染很慢——比如要等一大堆 JS 执行、等接口、等图片解码——那过渡会卡在"旧快照定住不动"的状态,等新页面准备好才开始播,体感上就是点完之后愣一下才动起来。我那个详情页一开始就有这毛病,因为大图是懒加载的,过渡触发时图还没解码完。

解决办法是让参与过渡的关键元素尽早可用。我把详情页那张 hero 大图的 loading 去掉懒加载、加上 fetchpriority="high",并且服务端在 HTML 里就把它写死、不靠 JS 插入:

1<img class="hero" src="/img/42-large.jpg" fetchpriority="high"
2     style="view-transition-name: cover-42" />

改完之后过渡触发就很跟手。教训是:参与共享元素过渡的元素,一定要让它在新文档里"第一时间就在、且尽快可见",否则再丝滑的动画也救不了那一下延迟。

prefers-reduced-motion 降级

加动画必须考虑减少动态效果的偏好,这是无障碍底线。有些用户开了系统的"减弱动态效果",大幅度的放大滑动会让他们不适甚至眩晕。

做法是用媒体查询把动画收敛成最朴素的淡化,或者干脆去掉:

1@media (prefers-reduced-motion: reduce) {
2  ::view-transition-group(*),
3  ::view-transition-old(*),
4  ::view-transition-new(*) {
5    animation-duration: 0.01ms !important;
6  }
7}

我没有完全把过渡禁掉,而是把时长压到几乎瞬间,这样既不会有动态效果,又保留了 View Transitions 抑制白闪的好处。如果想彻底关,也可以在 @view-transition 那层用媒体查询包起来,让它退回普通跳转。

不支持的浏览器自动退化

这是我最喜欢跨文档 View Transitions 的一点:它本质上是渐进增强。

@view-transition { navigation: auto } 这条规则,不认识它的浏览器会直接当成无效 CSS 忽略掉,导航照常进行,只是没有动画——也就是原来那个整页刷新的样子。view-transition-name 同理,不支持的浏览器把它当未知属性丢弃,元素该怎么显示还怎么显示。

所以我不需要写任何特性检测当退路。支持的浏览器吃到丝滑过渡,不支持的用户得到的体验和加这套东西之前完全一样,没有任何回退报错。对一个要长期维护、用户浏览器五花八门的内容站来说,这种"零成本降级"比效果本身更让我放心。

唯一要注意的是,如果你还想顺带做同文档的 SPA 过渡,那 document.startViewTransition 仍然需要做 if (!document.startViewTransition) 这种检测,因为那是 JS API,不像 CSS 那样静默忽略。

如果确实想知道当前浏览器支不支持跨文档过渡(比如要做埋点统计覆盖率),可以用 CSS 特性查询探测:

1const supported = CSS.supports('view-transition-name: none');

但我想强调的是,这种检测在功能层面完全不必要——加不加它,不支持的浏览器表现都一样。我只在做数据统计时用过一次,平时绝不会因为"怕老浏览器出问题"而去包一层判断,因为根本没有问题可言。这跟过去引入很多新特性时要写一堆 polyfill、降级分支的体验完全不同,是它让我愿意大胆在生产环境用的根本原因。

给列表-详情加共享元素过渡的完整回顾

把整个过程串一遍,这个内容站最后落地的就这么几件事:

  1. 在站点全局 CSS 里加 @view-transition { navigation: auto },所有页面立刻有了 fade,白闪消失。这一步性价比最高,五分钟搞定。
  2. 列表页给每张卡片的封面图和标题用文章 id 生成唯一的 view-transition-name,详情页对应大图和标题用相同的名字。点进点出都有了共享元素形变。
  3. 用伪元素调时长和缓动,给共享元素设 object-fit 解决长宽比变形,把 root 的淡化压短突出主体。
  4. pagereveal 标记导航类型,让后退时动画方向反过来;用 pageswap 在离开列表去无关页面时清掉过渡名字,避免不该有的形变。
  5. prefers-reduced-motion 下把动画收敛成瞬间完成。

踩过的坑集中在两个地方:一是同名唯一性,列表里偷懒用同一个名字会让整个过渡静默失效,必须按 id 区分;二是新旧元素长宽比不一致导致形变中途变形,要靠 object-fit 兜住。其余都比我预想的简单太多。

产品同学后来又拿手机点了一遍列表到详情,这次没再提原生 App,反倒追问了一句是不是背后换了框架。答案是没有——那个内容站还是最初那套服务端渲染模板,整页跳转,没多写一行路由代码,改动集中在几段 CSS 声明和两个跨文档事件的监听上。