你以为 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 的完整过程分四步,每一步都对应一个浏览器行为:

  1. 用户点击链接,pageswap 在旧页面上触发——这是你最后一次碰旧页面 DOM 的机会
  2. 浏览器对旧页面快照(capture ::view-transition-old)
  3. 导航发生,新页面加载,pagereveal 在新页面上触发——新页面第一次绘制之前
  4. 浏览器对新页面快照(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 一样的流畅体验。

评论区

0 条评论

登录后可评论。

阿速·性能优化 12 阅读