前端技术
#View Transitions#CSS#SPA#跨文档过渡#快照隔离

CSS View Transitions 如何捕获新旧状态快照:跨文档过渡为何需要隔离

本文以 SPA 路由切换为贯穿场景,剖析 View Transitions API 如何通过伪元素树和静态快照实现平滑过渡,重点解释跨文档过渡时快照隔离的必要性,以及同源限制、渲染阻塞与回退机制,并与 React Transition Group 等方案对比。

当用户在一个单页应用里从商品列表页点击进入详情页,页面往往需要经历一次完整的 DOM 重建。传统做法是让新旧两套内容同时存在于文档中,用 CSS 动画把旧内容淡出、新内容淡入。这个方案听起来直接,却带来一个棘手的工程问题:为了一个纯视觉的过渡效果,DOM 结构被临时改造成包含两套状态的中间形态,旧元素可能被容器裁剪,焦点和阅读位置丢失,屏幕阅读器还会把两套内容同时读出来。

CSS View Transitions API 换了一种思路:它允许 DOM 在瞬间切换到新状态,然后在另一个图层里用旧状态的静态快照和新状态的实时画面来播放过渡动画。这个机制的关键在于快照的捕获与隔离,尤其是跨文档导航时,两个独立文档之间的快照不能混在一起。本文以 SPA 路由切换为贯穿场景,说明快照如何被捕获、伪元素树如何组织,以及为什么跨文档过渡必须做隔离。

传统方案:双状态共存及其失效条件

在 View Transitions API 出现之前,SPA 里实现元素从一个视图“移动”到另一个视图的效果,通常需要让该元素同时存在于两个容器之外。例如商品缩略图要放大成详情页的大图,开发者会把这张图临时提升到 body 层,避免被列表容器或详情容器的 overflow 裁剪。这个中间态往往要维持几百毫秒,期间 DOM 结构被破坏,可访问性也随之受损。

更麻烦的是,如果新旧状态都挂在 DOM 里,用户可能在过渡期间点到旧内容上的按钮,触发意外的交互。开发者还得写额外代码来屏蔽旧内容的 pointer-events,并在动画结束后手动清理旧节点。这些代码分散在业务逻辑里,很难维护。

View Transitions 的设计目标就是消除这个中间态。规范在引言里明确指出,传统做法“为了纯视觉效果而牺牲了 DOM 结构”,而新方案让 DOM 立即切换,再把视觉过渡放到另一个图层中。这个图层由一组伪元素构成,旧状态是静态图像,新状态是实时渲染,两者可以交叉淡化、位移和缩放。

快照捕获:静态图像与实时画面的分工

同文档过渡由 document.startViewTransition(updateCallback) 触发。调用后,浏览器先捕获当前屏幕内容作为“旧状态”快照,然后暂停渲染,执行 updateCallback 更新 DOM,再捕获新的屏幕内容作为“新状态”快照,最后恢复渲染并播放伪元素动画。

旧状态快照是静态的,它是一张位图,不再与 DOM 关联。新状态快照则是“活的”,它直接引用新 DOM 的渲染结果。这种分工让动画期间新旧视觉可以共存,而 DOM 里只有新状态,不会出现两套内容同时可交互的问题。

默认情况下,整个页面作为一个整体参与过渡,效果是整页交叉淡化。开发者可以用 view-transition-name 属性给特定元素命名,让这些元素被单独捕获,从而独立于页面其余部分做动画。例如给商品缩略图设置 view-transition-name: product-image,浏览器就会为它单独生成一组伪元素,旧快照和新画面可以分别做缩放和位移,而页面其他部分只做淡化。

伪元素树:快照如何在渲染层共存

过渡动画期间,浏览器在文档之上构建一棵伪元素树。根节点是 ::view-transition,它包含所有命名元素的过渡组。每个命名元素对应一组 ::view-transition-group(name)::view-transition-image-pair(name)::view-transition-old(name)::view-transition-new(name) 伪元素。

这棵伪元素树是理解快照隔离的关键。旧快照和新画面被分别放在 oldnew 伪元素里,它们被 image-pair 包裹,而 image-pair 又被 group 包裹。group 负责整体的位置和尺寸动画,image-pair 负责新旧之间的混合。默认情况下,image-pair 会创建一个隔离上下文,让新旧快照的混合不会影响页面其他部分。

伪元素树的结构让开发者可以用熟悉的 CSS 动画来定制过渡。例如给 ::view-transition-old(product-image) 写一个缩放和位移动画,给 ::view-transition-new(product-image) 写一个淡入动画。这些动画运行在独立的合成层上,不阻塞主线程的布局和绘制。

::view-transition-old(product-image) {
  animation: 300ms ease-out both shrink;
}

::view-transition-new(product-image) {
  animation: 300ms ease-out both grow;
}

这段代码示意了如何让旧图缩小消失、新图放大出现。both 填充模式保证动画开始前和结束后伪元素保持正确的状态。实际动画的细节取决于具体场景,但机制是统一的:伪元素只是承载快照的容器,动画由 CSS 驱动。

跨文档过渡:为什么需要隔离

跨文档过渡发生在多页应用(MPA)的导航中,例如用户从列表页点击链接跳转到详情页。与同文档过渡不同,跨文档过渡没有 JavaScript API 可调用,它由浏览器在导航时自动触发,前提是两个页面都通过 @view-transition { navigation: auto; } 选择加入,并且导航是同源的。

这里出现了一个根本性的差异:旧状态属于旧文档,新状态属于新文档,它们分别在不同的渲染进程或渲染上下文中。如果浏览器不把两个文档的快照隔离开,旧文档的样式、脚本或布局就可能泄漏到新文档中,造成视觉污染甚至安全问题。

规范在安全与隐私考量中明确提到,跨文档过渡必须限制在同源导航,并且要求两个文档都显式选择加入。同源限制确保两个文档的信任边界一致,避免恶意页面通过过渡窃取另一页面的视觉内容。选择加入机制则保证页面作者明确知道并同意自己的页面参与过渡,防止被第三方页面强行捕获快照。

隔离还体现在渲染层面。旧文档在导航开始后会被冻结,它的快照被捕获后,旧文档就不再参与渲染。新文档在首次绘制前会等待旧快照就绪,然后在新文档的渲染层上播放过渡动画。如果新文档的渲染被阻塞,过渡就无法开始,浏览器会直接显示新页面,跳过动画。

渲染阻塞与回退机制

跨文档过渡有一个关键约束:新文档的首次渲染必须被阻塞,直到旧快照捕获完成。否则用户会先看到新页面闪一下,然后才看到过渡动画,造成闪烁。规范通过 <link rel="expect"> 来标识新文档的关键内容,浏览器会阻塞渲染直到这些内容解析完成,从而保证首次绘制的一致性和过渡的平滑。

但渲染阻塞是有代价的。如果新文档的关键内容加载缓慢,用户会看到旧页面停留更长时间,感知延迟反而增加。因此 expect 机制需要谨慎使用,只标记真正影响首屏的关键内容。

回退机制是 View Transitions 设计的重要部分。规范强调过渡是“对底层状态改变的视觉增强”,这意味着如果过渡无法创建,DOM 更新仍然要正常完成。例如浏览器不支持该 API、用户开启了 prefers-reduced-motion、或者跨文档导航不满足同源条件,过渡会被跳过,页面直接切换。

在同文档过渡中,startViewTransitionupdateCallback 始终会被调用,即使过渡被跳过。开发者可以用特性检测来提供回退:

if (document.startViewTransition) {
  document.startViewTransition(() => updateTheDOM());
} else {
  updateTheDOM();
}

这段代码确保在不支持 API 的浏览器里,DOM 更新照常执行,只是没有动画。这是渐进增强的典型做法:动画是锦上添花,状态改变才是核心。

与 React Transition Group 的对比

React Transition Group 是 SPA 中常用的过渡方案,它要求新旧状态同时存在于 DOM 中,通过添加/移除 CSS 类来触发进入和离开动画。这种方案与 View Transitions 有本质区别。

React Transition Group 需要开发者手动管理过渡状态,例如在组件卸载前延迟移除 DOM 节点,并处理动画结束事件。这导致代码复杂度较高,而且新旧内容共存期间,可访问性问题难以避免。View Transitions 则把过渡逻辑交给浏览器,DOM 只保留新状态,开发者只需调用一个 API 或声明一个 CSS 规则。

从性能角度看,React Transition Group 的动画通常运行在主线程上,如果动画涉及布局属性,可能引发强制同步布局。View Transitions 的快照动画在合成器线程上运行,不阻塞主线程,因此更平滑。但 View Transitions 的快照是位图,如果元素包含视频或动态内容,旧快照会冻结,而 React Transition Group 可以保持元素实时渲染。

下表对比了两者在关键维度上的差异:

维度View Transitions APIReact Transition Group
DOM 状态只保留新状态,旧状态为静态快照新旧状态同时存在于 DOM
动画线程合成器线程,不阻塞主线程主线程,可能引发强制同步布局
可访问性过渡期间 DOM 只有新内容,问题较少新旧内容共存,可能导致焦点和读屏混乱
实现复杂度调用 API 或声明 CSS 规则,无需手动清理需要管理过渡状态、延迟卸载、处理动画事件
动态内容旧快照是静态位图,视频等会冻结元素保持实时渲染,可包含动态内容
跨文档支持支持 MPA 导航,需同源和选择加入仅限 SPA 内,无法跨文档

这个表格帮助决策:如果追求低成本和跨文档支持,View Transitions 更合适;如果需要在过渡期间保持元素实时更新,React Transition Group 仍是可行的选择。

贯穿场景:SPA 商品列表到详情页

用一个具体的 SPA 场景贯穿全文:商品列表页展示缩略图,用户点击后进入详情页,大图从缩略图位置放大出现。

在传统方案中,开发者会把缩略图克隆一份放到详情页的对应位置,用 FLIP 动画实现位移和缩放。这需要精确计算两个元素的位置差,并在动画结束后移除克隆节点。如果列表和详情页属于不同的路由组件,状态管理会变得复杂。

使用 View Transitions,开发者只需给缩略图和大图设置相同的 view-transition-name,然后调用 startViewTransition 切换路由:

function navigateToDetail(productId) {
  if (!document.startViewTransition) {
    renderDetailPage(productId);
    return;
  }
  document.startViewTransition(() => renderDetailPage(productId));
}

浏览器会自动捕获旧页面的缩略图快照,更新 DOM 后,新页面的大图会与旧快照进行匹配,默认产生交叉淡化。开发者可以通过 CSS 动画定制为缩放效果,让大图从缩略图的位置和尺寸平滑放大。

整个过程中,DOM 只包含新页面的内容,旧页面只是一张位图。用户无法在过渡期间点击旧内容,焦点也不会丢失。如果浏览器不支持该 API,renderDetailPage 照常执行,页面直接切换。

快照隔离的工程边界

快照隔离并非没有代价。首先,旧快照是静态位图,如果旧状态包含视频、动画或频繁更新的内容,过渡期间这些内容会冻结,可能造成视觉上的不自然。其次,快照捕获需要占用内存,如果页面上有大量命名元素,每个元素都会生成独立的快照,内存开销会上升。

另一个边界是跨文档过渡的同源限制。如果导航跨越到不同源的站点,即使两个页面都声明了 @view-transition,浏览器也不会启动过渡。这是安全模型的必然要求,但也意味着 CDN 托管的静态页面如果与主站不同源,就无法享受跨文档过渡。

渲染阻塞的边界同样值得注意。跨文档过渡要求新文档的首次渲染等待旧快照就绪,如果新文档的关键内容(由 <link rel="expect"> 标记)加载缓慢,用户会看到旧页面停留更久。开发者需要权衡过渡的平滑度和首屏速度,只标记真正必要的内容。

最后,prefers-reduced-motion 用户应该被尊重。规范建议在用户开启减少动态效果时跳过过渡,开发者可以通过 matchMedia 检测并调用 skipTransition() 来主动跳过。

可观测信号与诊断

生产环境中,开发者需要观察过渡是否按预期运行。ViewTransition 对象提供了 readyfinished 两个 Promise,分别表示动画即将开始和已经结束。如果过渡被跳过,ready 会 reject,finished 会正常 resolve。

const transition = document.startViewTransition(() => updateDOM());
transition.ready.then(() => console.log('动画开始')).catch(() => console.log('过渡被跳过'));
transition.finished.then(() => console.log('动画结束'));

如果动画没有出现,常见原因包括:updateCallback 抛异常、view-transition-name 在新旧状态中不匹配、或者浏览器不支持该 API。开发者可以在 DevTools 的 Rendering 面板中启用“显示 view transition 快照”来调试快照是否正确捕获。

性能方面,可以通过 Performance 面板观察过渡期间是否有长任务。虽然动画在合成器线程运行,但快照捕获本身需要主线程参与,如果页面过于复杂,捕获可能耗时较长。此时应该考虑减少命名元素的数量,或者只在必要的元素上使用 view-transition-name

生命周期与状态流转

一个成功的同文档过渡会经历以下阶段:调用 startViewTransition 后,浏览器捕获旧状态,暂停渲染,执行 updateCallback,捕获新状态,创建伪元素树,恢复渲染,播放动画,最后移除伪元素。

下面的流程图展示了这个状态流转,以及跨文档导航时的额外步骤:

flowchart TD
    A[用户点击链接] --> B{是否同源且双方选择加入?}
    B -- 否 --> C[直接导航,无过渡]
    B -- 是 --> D[旧文档捕获快照]
    D --> E[冻结旧文档,开始导航]
    E --> F[新文档加载并解析关键内容]
    F --> G{新文档渲染是否被阻塞?}
    G -- 是 --> H[等待关键内容完成]
    H --> I[新文档首次渲染]
    G -- 否 --> I
    I --> J[创建伪元素树,播放过渡动画]
    J --> K[动画结束,移除伪元素]
    K --> L[导航完成]

这个流程揭示了跨文档过渡与同文档过渡的关键差异:旧文档在导航开始后就被冻结,新文档的渲染被阻塞直到旧快照就绪。如果任何一步失败,浏览器会回退到普通导航,不播放动画。

总结

View Transitions API 通过将旧状态捕获为静态快照,并将新旧视觉放在独立的伪元素层中,解决了传统双状态共存方案的诸多问题。跨文档过渡的隔离机制——同源限制、选择加入和渲染阻塞——是安全与平滑的保障,但也带来了内存和延迟的边界。与 React Transition Group 相比,View Transitions 在实现成本和跨文档支持上优势明显,但在动态内容保持方面有所不足。

对于 SPA 路由切换场景,如果浏览器支持且不需要过渡期间保持元素实时更新,View Transitions 是值得优先考虑的方案。开发者应当始终提供回退路径,并尊重用户的减少动态效果偏好。

资料来源

  1. CSS View Transitions Module Level 1
  2. View Transitions API - MDN
  3. Smooth transitions with the View Transitions API
  4. CSS View Transitions Module Level 1