页面切换动画是这个时代前端体验里最容易被提需求、又最容易被砍掉的功能。产品觉得不复杂,开发却要在新旧 DOM 之间协调状态;设计图上只是一个淡入淡出,落到代码里往往是一整套类名切换和生命周期清理。CSS View Transitions API 的出现,终于让浏览器自己接管了这层“脏活累活”。

这篇文章我会从 API 的运行机制讲起,再逐步拆解单页应用和多页应用中的接入方式,最后给出一组基于工程视角的取舍建议。如果你正打算在项目里引入页面过渡动画,这篇文章应该能帮你少走几步弯路。
理解 View Transitions:一次状态切换,两帧快照
View Transitions API 的核心思路,不是告诉你“这个元素应该怎么运动”,而是让浏览器自己拍两张照片:一张旧状态的,一张新状态的。两次拍摄之间的差异,就是过渡动画的起点和终点。
调用 document.startViewTransition() 后,浏览器会先捕获当前页面快照,然后执行你传入的回调函数。这个回调负责同步或异步地更新 DOM。浏览器会在回调返回的 Promise 结束后捕获新快照,再用一组过渡伪元素把旧快照放在上面、新快照放在下面,默认做交叉淡化。
示意图形式的关键代码很简单:
async function updateContent() {
const transition = document.startViewTransition(() => {
// 在这里更新 DOM,可以返回 Promise
content.innerHTML = 'Hello View Transitions';
});
await transition.finished;
console.log('过渡完成');
}
注意这里有一个容易被忽略的细节:如果回调返回 Promise,浏览器会等它 resolve 后才捕获新快照。反过来,如果你的回调只是临时设置了一个状态,而真正的 DOM 更新发生在下一个宏任务,新快照很可能捕获的是旧内容,动画看起来就会“闪一下”。这也是后面要说到的坑。
这个设计背后的工程收益,是动画过程不再依赖 JavaScript 逐帧驱动。浏览器会在合成器层面完成快照的展示和变换,主线程只需要在开始和结束时参与。对于动画过程中仍在进行的用户交互,比如滚动和点击,浏览器也能更从容地处理。
从一个最简单的过渡开始
先看一个常见的场景:点击按钮,替换卡片里的文案。以前你至少要给元素加上两个 class,监听 transitionend,还得处理动画被打断时的情况。用 View Transitions 之后,核心代码只剩两段。
<button id='btn'>切换内容</button>
<div id='card'>原始内容</div>
<style>
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.4s;
}
::view-transition-new(root) {
animation-name: slide-in;
}
@keyframes slide-in {
from { transform: translateX(50px); opacity: 0; }
to { transform: none; opacity: 1; }
}
</style>
<script>
btn.addEventListener('click', () => {
document.startViewTransition(() => {
card.textContent = '新内容';
});
});
</script>
这段代码里,startViewTransition 生成的是默认 root 视图,也就是整页快照。通过伪元素 ::view-transition-new(root),我们把新内容入场动画改成了向右滑入。旧内容默认会保持静态,然后被新内容覆盖。
这里值得说的是“快照”的含义:如果卡片上有一个正在播放的 GIF 或者视频,过渡期间它们会被冻结成静态帧,动画结束后才恢复。这是性能换观感的代价,也是使用这个 API 前必须接受的一个特点。
命名视图:让两个元素“连接”起来
实际页面里,更有价值的过渡方式是让用户感觉“同一个元素”从 A 点走到了 B 点。典型例子是列表到详情页:点击一张卡片,卡片放大填满整个屏幕。这种共享元素动画,以前常见的实现是使用 FLIP 技术,把元素位移、缩放的过程手动拆成一帧帧样式。维护起来相当累,尤其是在图片加载完成前后触发时很容易算错位置。
View Transitions API 用 view-transition-name 简化了这件事。你只需要在旧状态和新状态中,给属于同一个逻辑元素的 DOM 节点设置相同的名称,浏览器就会自动生成一组额外的视图快照,并对它们执行平滑的缩放和位移。
.list-card, .detail-hero {
view-transition-name: card-hero;
}
你还需要在列表和详情页里分别保存这两个元素。注意名称在当前页面里必须唯一,否则浏览器会直接跳过整个过渡并打出一条 warning。比如列表里有多张卡片同时存在时,不能给每张卡片都设置同一个名称,否则切换详情页时会因为同名冲突而失效。正确的做法是只在对应生命周期的元素上设置,或者动态设置一个全局唯一的名字。
这个命名机制还带来一个灵活性:你可以在过渡的不同阶段给 ::view-transition 伪元素配置不同的动画,比如加入轻微的旋转、视差效果,甚至触发子元素的入场动画。自由度虽然比不上 JS 动画库,但页面级过渡已经足够用。
在 SPA 路由中接入
单页应用是 View Transitions API 最直接的受益者。路由切换本质上就是一次 DOM 状态更新,完全符合 startViewTransition 的适用模型。但这里的难点在于,现代框架的渲染是异步的。如果你在回调里只是触发了 Vue 的 reactive 变化,却没有等待渲染冲刷完成,浏览器很可能在新 DOM 存在之前就已经拍照了。
一个通用的接入模式是这样:
function navigate(toUrl) {
if (!document.startViewTransition) {
return changeRoute(toUrl);
}
const t = document.startViewTransition(async () => {
await changeRoute(toUrl); // 等待路由更新和渲染完成
});
return t.finished;
}
这里的 changeRoute 必须返回一个在 DOM 更新完成后 resolve 的 Promise。在 Vue 里可以是 nextTick 之后,在 React 里可以通过 flushSync 或者把 startViewTransition 放在事件处理中结合 React 18 的 concurrent 特性。每个框架的接入细节不同,但原则是一致的:让浏览器等待你的新页面真正画出来,再捕获新快照。
这里建议不要一上来就改造所有路由。先挑一个用户高频访问的路径,比如从列表页进入详情页,在这个路由上做实验。观察两点:一是过渡开始前后是否有闪白,二是快照捕获时机是否正确。确认稳定之后再推广到其他路由,你会发现拆出来的公共封装反而更好维护。
跨文档导航:不需要 JS 的多页面过渡
View Transitions 的另一块拼图是跨文档导航。在 Chromium 126 之后的版本中,同源页面之间的浏览器原生跳转,可以通过一段 CSS 激活过渡效果:
@view-transition {
navigation: auto;
}
这行声明告诉浏览器,在同源页面导航时自动启用视图过渡。旧页面和新页面分别被当作旧快照和新快照。如果你想让某个元素在两个页面之间共享位置,同样需要使用相同的 view-transition-name。比如一个博客的标题,在文章列表页和详情页都叫 logo,它就会在跳转时保持位置上的连续性。
这个能力对 MPA 架构的项目来说很有价值。以往多页面跳转只能依靠整页刷新,现在至少可以在浏览器层面获得类似 SPA 的过渡体验。但也要注意,跨文档过渡期间,JavaScript 上下文是断开的,所有状态都依赖 DOM 内容在两个页面中的呈现。所以它更适合内容型站点,而不是状态很重的后台系统。
和 JS 动画方案比,值得替换吗?
团队在选型时,最常问的就是“这个东西能替代 GSAP 吗”。我的看法是,它们解决的问题层次不一样。View Transitions 更倾向“页面状态切换时的结构过渡”,JS 动画库更擅长“组件内复杂交互和连续动画”。
| 对比维度 | View Transitions API | JS 动画库 |
|---|---|---|
| 开发成本 | 低,CSS 为主,少量 JS 调用 | 高,需要管理状态和动画生命周期 |
| 性能 | 主要由合成器处理,主线程压力小 | 复杂动画会持续占用主线程资源 |
| 定制能力 | 受伪元素结构限制,适合页面级动画 | 任意 CSS 属性、时间线、物理模拟 |
| 路由集成 | 与原生导航和路由架构天然契合 | 需要显式触发和清理 |
| 兼容性 | Chromium 支持,其他环境需降级 | 所有现代浏览器 |
所以我的建议是:如果需求是“页面切换时有过渡”,优先考虑 View Transitions;如果需求是“触摸拖拽、滚动驱动的复杂动画”,继续使用动画库。两者并不互斥,甚至可以在同一页面里共存。
生产环境里容易踩的几个坑
从工程角度看,看懂 API 用法还不代表能安全上线。我梳理了几个团队经常遇到的问题:
- 回调不返回 Promise: 当 DOM 更新依赖异步渲染时,新快照可能捕捉到旧内容,动画看起来或者不出现、或者闪一下。务必让回调返回一个渲染完成的 Promise。
- view-transition-name 冲突: 同一时间有多个相同名称的元素存在,过渡会被整体跳过。排查时很容易忽略,因为页面功能没有报错,只是动画消失了。
- 滥用命名视图: 每个命名视图都会创建多套快照和伪元素,几十个命名元素就会产生几十倍的内存和合成开销。只给真正需要共享过渡的元素命名。
- 忽略 reduced-motion 偏好: 无节制地使用页面动画会打扰到前庭功能障碍用户。通过
prefers-reduced-motion媒体查询关闭动画,是负责任的做法。
还有一个不太容易注意的边界:动画持续期间用户可能快速连续点击。View Transitions 默认会管理新的过渡请求,旧动画会被中断,但不保证用户点击事件不会丢失。如果你的页面对点击响应很敏感,建议在动画期间给按钮加上 pointer-events: none 或者设置防抖。
兼容性现状与渐进增强策略
View Transitions API 在 Chromium 124 中开始支持,跨文档版本从 126 开始开放;Safari 在较新版本中也加入了基础支持,Firefox 目前仍处于需要手动开启的实验阶段。这意味着你不可能把它当作唯一实现,必须在接入前做好检测。
渐进增强是一个很实用的策略:
function withViewTransition(updateFn) {
if (document.startViewTransition) {
document.startViewTransition(updateFn);
} else {
updateFn();
}
}
通过这个封装,不支持的环境会直接更新 DOM,用户什么也感知不到。这和“页面在旧浏览器里没有动画”是两种完全不同的体验,前者不会产生任何错误。
最后的落地建议是这个:找一个核心路径,比如“列表到详情”,把它改造成基于 View Transitions 的过渡,提前在 CI 或真机上测试动画帧率和快照捕获时间。如果它带来的体验提升足够明显,你会更有动力把这种机制逐步扩展到整个应用。
当浏览器生态补齐之后,页面过渡动画很可能像 flexbox 一样,成为开发人员随手可用的基础能力。而我们现在要做的,不是等着它全面普及,而是在自己熟悉的场景里验证它、理解它,等到时机成熟时,让页面切换这件事真正变得自然起来。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/790/