从一次商品详情跳转说起
在 React 路由切换时,用户点击商品卡片跳转到详情页,页面瞬间替换,视觉上会感到突兀。传统做法是给容器加淡入淡出动画,但问题不少:需要同时渲染新旧两个页面,管理它们的定位与层级;旧页面上的点击事件可能干扰新页面;动画结束后还要手动清理 DOM。View Transitions API 为这类同文档(SPA)状态切换提供了一种浏览器原生方案,它把“捕获旧状态、更新 DOM、动画过渡”的流程封装起来,开发者的核心工作只剩调用一个 API。
本文以 React 路由切换为贯穿场景,分析 View Transitions API 在同文档过渡中的快照捕获机制、伪元素树结构、默认动画与自定义方式,对比传统 CSS 动画和 JavaScript 动画库的差异,并讨论其性能来源、适用边界与回退策略。
同文档过渡的触发与基本流程
同文档过渡通过 document.startViewTransition() 触发,它接收一个回调函数,该函数负责更新 DOM。浏览器在调用时先捕获当前页面的视觉快照,然后抑制渲染,执行回调更新 DOM,再捕获新状态的快照,最后在两个快照之间播放过渡动画。
function navigateToDetail(productId) {
// 不支持 API 时的回退
if (!document.startViewTransition) {
updateDOM(productId);
return;
}
// 使用视图过渡
const transition = document.startViewTransition(() => {
updateDOM(productId); // 更新 React 状态或直接操作 DOM
});
}
回调执行后,DOM 已经更新,但浏览器会延迟绘制,直到新快照就绪。ViewTransition 对象暴露了 updateCallbackDone、ready、finished 等 Promise,分别对应 DOM 更新完成、动画即将开始、动画结束的时刻,便于开发者挂接自定义逻辑。
快照捕获与渲染隔离
快照捕获是 View Transitions 的核心。浏览器在旧状态被替换前,把页面内容截取为位图,新状态则以“实时快照”形式存在。这里的“旧快照”是静态图片,而“新快照”是动态的——它指向新 DOM 的渲染结果。
为了同时展示新旧快照,浏览器会创建一个覆盖层,即 ::view-transition 伪元素树。该树位于页面内容之上,独立于普通文档流。默认情况下,整个 :root 元素被赋予 view-transition-name: root,因此整个页面会作为一个整体参与过渡。开发者可以通过给特定元素设置 view-transition-name,让该元素单独拥有一个快照组,从而获得独立的动画。
.product-image {
view-transition-name: product-hero;
}
一旦设置了 view-transition-name,该元素在过渡期间会被隔离,浏览器为其创建独立的 ::view-transition-group 子树。这种隔离意味着元素的布局变化不会影响页面其他部分,动画可以针对该元素单独执行。
伪元素树的结构与默认动画
同文档过渡的伪元素树结构如下:
::view-transition
└─ ::view-transition-group(root)
└─ ::view-transition-image-pair(root)
├─ ::view-transition-old(root) // 旧快照
└─ ::view-transition-new(root) // 新快照
::view-transition 是覆盖层根,包含所有快照组。::view-transition-group 是单个元素的快照容器,负责尺寸和位置动画。::view-transition-image-pair 内部包含新旧两个快照,旧快照是静态的,新快照是实时的。
默认动画是交叉淡化:旧快照的 opacity 从 1 到 0,新快照从 0 到 1。但 MDN 指出,对于尺寸(width、height)变化,会应用平滑缩放动画;对于位置(position、transform)变化,会应用平滑移动动画。这意味着如果新旧状态中同一个 view-transition-name 的元素位置或大小不同,浏览器会自动生成位移动画,而不是简单的淡入淡出。
自定义动画与命名元素
默认的交叉淡化往往不够,开发者可以通过 CSS 修改伪元素上的动画。例如,为商品图片设置一个从右侧滑入的效果:
::view-transition-old(product-hero) {
animation: fade-out 0.3s ease-out;
}
::view-transition-new(product-hero) {
animation: fade-in 0.3s ease-in;
}
@keyframes fade-out {
to { opacity: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
}
更常见的做法是让商品图片在列表页缩略图与详情页大图之间平滑缩放。这要求新旧状态中对应元素拥有相同的 view-transition-name,浏览器会自动计算两个快照的尺寸和位置差异,并生成过渡。
在 React 路由切换中,如果列表页的缩略图和详情页的主图是不同组件,但都渲染同一张图片,可以给它们设置相同的 view-transition-name,例如 product-hero。这样当路由切换时,浏览器会把缩略图“放大”到大图位置,产生视觉连续性。
与 React 路由的结合
在 React 应用中,路由切换通常由 React Router 等库管理。使用 View Transitions 时,需要把触发视图更新的代码包装在 startViewTransition 回调中。例如,在点击导航链接时:
import { useNavigate } from 'react-router-dom';
function ProductCard({ product }) {
const navigate = useNavigate();
const handleClick = () => {
if (document.startViewTransition) {
document.startViewTransition(() => {
navigate(`/product/${product.id}`);
});
} else {
navigate(`/product/${product.id}`);
}
};
return <div onClick={handleClick}>{/* 商品卡片 */}</div>;
}
这里的关键是,navigate 调用会触发 React 状态更新,导致 DOM 变化,而这个变化发生在 startViewTransition 的回调中,浏览器会抑制渲染直到回调完成,然后捕获新快照。
需要注意,React 的状态更新可能是异步的(例如在事件处理中),但 startViewTransition 的回调会同步执行,React 18 的自动批处理可能会把状态更新推迟。为了确保 DOM 在回调中更新,可以使用 flushSync 强制同步刷新,但这会带来额外的性能开销,需要权衡。
与传统 CSS/JS 动画的对比
传统实现页面切换动画,通常需要同时保留新旧两个 DOM,手动控制它们的定位、透明度,并在动画结束后移除旧 DOM。例如,使用 CSS 类切换和 requestAnimationFrame 实现淡入淡出:
.page-enter { opacity: 0; }
.page-enter-active { opacity: 1; transition: opacity 0.3s; }
.page-exit { opacity: 1; }
.page-exit-active { opacity: 0; transition: opacity 0.3s; }
这种方式的问题在于:新旧页面同时存在于 DOM 中,可能引发布局抖动;需要管理 z-index 避免重叠;旧页面上的事件监听器可能干扰交互;动画中断时状态难以恢复。
JavaScript 动画库(如 Framer Motion)提供了更高级的 API,但本质上仍是操作 DOM 和 CSS,且需要引入额外依赖,增加包体积。
View Transitions 由浏览器底层实现,快照捕获和动画都在合成器线程上执行,不占用主线程,因此性能更优。此外,它天然处理了新旧内容的共存问题——旧快照是位图,不参与 DOM 事件,不会干扰交互。
性能优势:合成器线程与主线程
浏览器渲染流程中,主线程负责 JavaScript、样式计算和布局,合成器线程负责将图层合成并显示到屏幕。传统 CSS 动画如果只改变 transform 和 opacity,可以在合成器线程运行,但页面切换往往涉及 DOM 增删和布局变化,无法完全避免主线程参与。
View Transitions 的巧妙之处在于,新旧快照的过渡动画只涉及位图的移动和透明度变化,这些操作可以在合成器线程完成。旧快照是静态位图,新快照虽然是实时渲染,但动画期间它被包裹在伪元素中,浏览器只需对位图做变换。因此,即使 DOM 更新本身需要主线程工作,动画阶段对主线程的占用极低,减少了卡顿风险。
适用边界与回退策略
View Transitions API 并非所有浏览器都支持。截至 2025 年,同文档过渡支持 Chrome 111+、Edge 111+、Safari 18+,Firefox 尚未支持。跨文档过渡支持更晚,Chrome 126+、Safari 18.2+。
因此,必须采用渐进增强策略。对于同文档过渡,使用特性检测:
if (document.startViewTransition) {
document.startViewTransition(() => updateDOM());
} else {
updateDOM();
}
对于跨文档过渡,使用 @supports 包裹相关 CSS:
@supports (view-transition-name: none) {
@view-transition {
navigation: auto;
}
}
此外,应尊重用户的 prefers-reduced-motion 偏好。可以在该偏好为 reduce 时缩短动画时长或直接禁用:
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*) {
animation-duration: 0.01s !important;
}
}
常见失败模式与注意事项
使用 View Transitions 时,有几个容易踩的坑。
view-transition-name 必须在页面内唯一。如果两个元素同时拥有相同名称,过渡会失败,浏览器会跳过动画。在动态列表中,需要确保每个元素有唯一的名称,例如包含 ID。
过度使用动画会适得其反。不是每次状态变化都需要过渡,频繁的动画会增加用户等待感,尤其在低端设备上。应只对重要的视图切换使用。
缺少回退是常见问题。如果浏览器不支持 API,直接调用 startViewTransition 会报错,因此必须先做特性检测。
性能方面,虽然动画在合成器线程运行,但捕获快照本身需要额外内存。如果页面包含大量大图或复杂背景,快照可能占用较多内存,在低端设备上需要谨慎。
与替代方案的对比
下表对比了 View Transitions、纯 CSS 过渡和 JavaScript 动画库(如 Framer Motion)在页面切换场景下的差异。
| 维度 | View Transitions API | 纯 CSS 过渡 | JavaScript 动画库 |
|---|---|---|---|
| 实现复杂度 | 低,只需调用 API | 中,需管理新旧 DOM | 高,需配置动画状态 |
| 新旧内容共存 | 浏览器处理,旧快照为位图 | 需手动处理定位和层级 | 需手动管理组件挂载/卸载 |
| 动画性能 | 合成器线程,主线程占用低 | 取决于属性,transform/opacity 可合成 | 通常操作主线程,可能卡顿 |
| 代码量 | 极少,几行 JS + CSS | 较多,需编写过渡类和清理逻辑 | 较多,需引入库并配置 |
| 浏览器支持 | 现代浏览器,Firefox 不支持 | 所有浏览器 | 所有浏览器 |
| 可访问性 | 自动尊重 prefers-reduced-motion | 需手动实现 | 需手动配置 |
| 适用场景 | SPA/MPA 页面切换 | 简单状态变化 | 复杂动画编排 |
从表中可以看出,View Transitions 在实现复杂度和性能上有明显优势,但浏览器支持是主要限制。对于需要兼容旧浏览器的项目,仍需要回退方案。
总结
View Transitions API 为同文档过渡提供了一种简洁的浏览器原生方案。它通过捕获新旧状态快照,在伪元素树中管理动画,将大部分工作交给合成器线程,从而减少主线程压力。与 React 路由结合时,只需将导航逻辑包装在 startViewTransition 中,即可获得平滑的页面切换效果。然而,它并非万能:浏览器支持有限,需要渐进增强;view-transition-name 必须唯一;过度使用会影响体验。理解其快照捕获与渲染隔离机制,有助于在合适的场景中发挥它的价值。
附录:同文档过渡流程示意
flowchart TD
A[用户点击导航] --> B{是否支持 startViewTransition?}
B -- 否 --> C[直接更新 DOM]
B -- 是 --> D[调用 startViewTransition]
D --> E[浏览器捕获旧状态快照]
E --> F[抑制渲染,执行回调更新 DOM]
F --> G[捕获新状态快照]
G --> H[构建伪元素树]
H --> I[播放默认或自定义动画]
I --> J[动画结束,清理伪元素树]
J --> K[新页面呈现]
图中展示了同文档过渡的完整流程。关键转折点在于“抑制渲染”,它确保了新旧快照之间没有中间帧,动画从旧快照直接过渡到新快照。