View Transitions API 跨文档过渡:给多页站点做转场动画,从闪白到丝滑

2026-09-13 0 106

上个月把一个电商前台从 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 是新图。

默认的动画就写在 oldnew 上: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 里分别针对 forwardbackward 写动画就行。这套写法比 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-producthero-article。万一某次导航时状态没清理干净,名字也不会撞车。

最后提一句:这套 API 的好玩之处在于,你先用最简单的两行 CSS 跑通,然后逐步加共享元素、加方向、加 duration 控制,每一步都有肉眼可见的效果提升。做前端这么久,很少有哪个特性是这样一点点搭起来的体验。

View Transitions API 跨文档过渡:给多页站点做转场动画,从闪白到丝滑
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 html View Transitions API 跨文档过渡:给多页站点做转场动画,从闪白到丝滑 https://www.taomawang.com/web/html/2752.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务