View Transitions API (Level-1 single-document)
今天咱们来聊一个前端圈儿里越来越火的新玩意儿——CSS View Transitions。这东西就好比给你的网页换场加了个丝滑的电影转场特效,告别过去那种生硬的“啪嗒”一下切换页面的体验。
W3C 的大佬们捣鼓出的这个 CSS View Transitions Module Level 1 规范,目前已经是 CR(Candidate Recommendation)阶段,说明离咱们大规模用上不远了。咱们的目标是彻底搞懂它,从“这啥玩意儿?”到“哦豁,有点意思”再到“爷青回,这动画我自己写!”。
一、告别刀耕火种:View Transitions 要解决啥蛋疼问题?
想想以前,尤其是在 SPA(单页应用)里搞页面切换动画,那叫一个折腾:
- DOM 大乱炖:为了让新旧两个状态能同时存在并产生动画效果(比如旧的淡出,新的淡入),你可能得手动控制 DOM,让两个页面的内容在某个时间段内都挂在页面上。这 DOM 结构,简直是为了动画效果“牺牲色相”,乱七八糟。
- JS 胶水代码:得写一堆 JavaScript 来协调 DOM 的增删、CSS class 的切换、动画的开始结束监听。逻辑复杂,还容易出 bug。
- 性能与体验:DOM 结构复杂了,性能可能受影响;动画过程中,焦点管理、可访问性(ARIA)也容易出问题,用户体验可能打折。比如动画过程中,屏幕阅读器是读旧的还是新的?按钮能点吗?
核心痛点:视觉过渡效果 和 DOM 状态更新 这两件事,在过去是紧密耦合、互相掣肘的。为了视觉效果,我们不得不扭曲 DOM 结构和更新逻辑。
二、View Transitions 的核心思想与哲学:解耦!分离!
W3C 的大佬们说:“不行,这太 low 了!咱们得想个办法把这两件事分开!”
于是,View Transitions 的核心哲学诞生了:
将视觉层面的过渡动画 与 DOM 结构和数据的更新彻底分离。
怎么分离呢?想象一下:
- DOM 更新?瞬间完成! 你该怎么更新 DOM 还怎么更新,别管动画的事儿。调用 API 后,你的更新代码(比如 React 的
setState
,Vue 的数据修改,或者原生 JS 操作)会立即或异步执行,DOM 状态瞬间变成新的。 - 视觉过渡?交给浏览器! 在你更新 DOM 的前后,浏览器会像个摄影师,“咔嚓”给旧状态拍张照,“咔嚓”给新状态拍张照(这里的“照片”是渲染层面的快照,不是真的图片文件)。
- 幕后动画师:浏览器拿到这两张“照片”后,在一个特殊的、凌驾于普通内容之上的图层里,基于这两张快照玩起了动画。默认是简单的交叉淡入淡出(cross-fade)。旧“照片”慢慢消失,新“照片”慢慢显现。
- 动画结束,收工! 动画播完,这个特殊的图层和里面的“照片”就被清理掉了,用户看到的就是真实的新 DOM 状态。
好处显而易见:
- DOM 干净:你的 DOM 结构永远反映真实的应用状态,不用为了动画而妥协。
- 关注点分离:JS 只管数据和 DOM 更新,CSS/Web Animations API 只管动画表现。清爽!
- 体验更佳:因为 DOM 更新是(逻辑上)瞬时的,底层的状态切换更快,可访问性问题也更容易处理。动画只是一个视觉增强层。
- 渐进增强:如果浏览器不支持 View Transitions,或者因为某些原因(如用户设置了减弱动态效果)动画无法执行,你的 DOM 更新逻辑依然会照常运行,页面还是能正常切换,只是没动画而已。完美符合渐进增强的理念。
三、上手初体验:document.startViewTransition
登场!
说了半天理论,上代码!最最核心的入口就是 document
对象上的新方法 startViewTransition()
。
// 假设你原来是这么更新内容的
function updateContent(data) {
// ... 一顿操作猛如虎,更新 DOM ...
console.log("DOM 更新完毕!");
}
// 现在想加上过渡动画?改成这样:
function navigate(data) {
// 判断浏览器是否支持
if (!document.startViewTransition) {
console.log("浏览器不支持 View Transitions,直接更新");
updateContent(data);
return;
}
// 调用 startViewTransition !
const transition = document.startViewTransition(() => {
// 这个回调函数里,放你原来的 DOM 更新逻辑
updateContent(data);
// 这个回调函数可以是同步的,也可以返回一个 Promise
// 如果返回 Promise,浏览器会等 Promise resolve 后再拍“新照片”
// return new Promise(resolve => setTimeout(resolve, 1000));
});
// transition 对象后面会讲,它包含一些有用的 Promise
transition.finished.then(() => {
console.log("过渡动画播放完毕!");
});
}
// 触发导航
navigate({ content: "新的页面内容" });
发生了什么?
- 调用
startViewTransition()
,浏览器:“收到!准备拍旧照片!” - 浏览器拍下当前页面的旧状态快照。
- 浏览器执行你传入的那个回调函数
() => updateContent(data)
。 - 你的
updateContent
函数执行,DOM 瞬间变成了新状态。 - (如果回调返回 Promise,浏览器会等它 resolve)浏览器:“OK,DOM 更新完了,拍新照片!”
- 浏览器拍下当前页面的新状态快照。
- 浏览器在幕后创建一个特殊的伪元素树(后面细讲),把旧快照和新快照放进去。
- 浏览器应用默认的 CSS 动画(交叉淡入淡出)到这些伪元素上。
- 动画开始播放。用户看到旧内容淡出,新内容淡入。
- 动画结束。
- 浏览器清理掉伪元素树。
transition.finished
Promise resolve。
就这么简单,一个基础的页面交叉淡入淡出效果就有了!你几乎没改动原来的 DOM 更新逻辑,只是把它包了一层 startViewTransition
。
ViewTransition
对象:你的动画小助手
startViewTransition
返回一个 ViewTransition
对象,它有几个重要的属性(都是 Promise):
updateCallbackDone
: 当你传入的回调函数执行完毕(如果是 Promise,则 resolve)时,这个 Promise 就 resolve。表示 DOM 更新逻辑本身已经跑完了。ready
: 当浏览器准备好新旧快照,并且创建了用于动画的伪元素、即将开始动画时,这个 Promise 就 resolve。如果你想用 JavaScript (Web Animations API) 来控制更复杂的动画,这是最佳时机。finished
: 当整个过渡动画播放完毕,并且伪元素被清理后,这个 Promise 就 resolve。表示一切都结束了,用户看到的是最终的新状态。
还有一个方法:
skipTransition()
: 调用这个方法可以立即跳过动画,直接显示最终状态。这在某些情况下(比如用户快速连续点击)可能有用。
四、深入幕后:揭秘伪元素树
前面提到,浏览器在幕后创建了一个“伪元素树”来承载动画。这棵树长啥样?
::view-transition (根节点,覆盖整个视口)
└─ ::view-transition-group(name) (每个独立过渡元素的“分组”)
└─ ::view-transition-image-pair(name) (新旧快照的“容器对”)
├─ ::view-transition-old(name) (旧状态快照的“图片”)
└─ ::view-transition-new(name) (新状态快照的“图片”)
::view-transition
: 整个过渡效果的根容器,通常固定定位,覆盖整个快照区域(Snapshot Containing Block,一般是整个视口)。::view-transition-group(name)
: 代表一个独立进行过渡的视觉区域。默认情况下,整个页面是一个匿名的root
分组。如果你给某个元素指定了view-transition-name
,那么这个元素就会拥有自己的具名分组。这个 group 负责模拟原始元素的大小和位置,并且默认会在这两个状态之间进行动画。::view-transition-image-pair(name)
: 包裹新旧两个快照的容器。它的主要作用是提供一个隔离环境(isolation: isolate
),确保新旧快照的混合模式(blend mode)只影响它们俩,不影响外部。::view-transition-old(name)
: 承载旧状态快照的伪元素。你可以把它想象成一个<img>
标签,内容是旧页面的样子。::view-transition-new(name)
: 承载新状态快照的伪元素。同上,内容是新页面的样子。
关键点:
name
通常是root
(代表整个页面),或者是你通过 CSS 的view-transition-name
属性给元素指定的名字。- 默认情况下,只有
::view-transition-group(root)
这一个分组。 - 浏览器提供了一套用户代理样式表 (UA Stylesheet),给这些伪元素加上了默认样式和动画,实现了那个默认的交叉淡入淡出效果。
::view-transition-old(root)
代表着 startViewTransition 开始之前的页面快照(截图)::view-transition-new(root)
代表着当前 document.documentElement 元素,它被当成快照使用。在这个期间,你实时去续改 dom 的样式,就是直接反应在::view-transition-new(root)
这个快照上。- 也就是说,其实你的整个 document.documentElement 已经变成一个快照元素了,如果你隐藏了
::view-transition
(opacity:0),你会发现整个 document.documentElement 都不见了,其实是灯下黑,它就是挂载在::view-transition-new(root)
里头。同时因为处于快照模式,DOM 的交互实现现在完全无法响应。
- 也就是说,其实你的整个 document.documentElement 已经变成一个快照元素了,如果你隐藏了
- 如果你通过
view-transition-name: name
去定义一个 Element,那么等同于这个元素被剥离出来,独立成一个图层来渲染。- 如果这个 name 在
::view-transition-new(root)
中存在,那么它会被抽出来作为::view-transition-new(name)
进行渲染,否则那么就默认使用::view-transition-old(name)
复制成::view-transition-new(name)
- 也就是说,这时候有两个层叠在一起的元素
::view-transition-old(name)
和::view-transition-new(name)
,默认情况下,前者会做淡出动画,后者会做淡入动画。因此你可以拿着这两个元素分别搞事情,从而实现复杂的动画效果。 - 以上同时二者被包含在
::view-transition-image-pair(name)
下面,同时::view-transition-image-pair(name)
被包含在::view-transition-group(name)
下面。目前 group 下面只有一个 image-pair 对象,之所以要设计成两层。是因为 image-pair 负责提供isolation: isolate
,然后 group 负责提供几何变 —— 即位置(transform)、尺寸(width, height)从旧状态到新状态的动画。 - 这里是因为
isolation: isolate
局限性,导致不得不分成两层。首先对于其作用,这里不展开细说,目的就是为了更好的前后过渡的混合效果 - 然后是如果将 image-pair 和 group 合并成一个行不行?答案是:不行。我们先假设如果分开,你可以在 group 上做背景色,而不会被 image-pair 所影响。因为
new
和old
的 mix-blend-mode 只会干扰到 image-pair 就终止了。如果合成一个,那么背景色就会被参与到混合中去。 - 这里给一个例子 Isolation Demo
- 如果这个 name 在
五、定制你的专属转场:CSS 大显身手
默认的淡入淡出太普通?没问题!既然是伪元素,我们就可以用 CSS 来定制它们的样式和动画!
1. 改变默认动画时长或效果 (针对整个页面 root)
/* 让默认的交叉淡入淡出变慢一点 */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.5s;
}
/* 自定义动画:比如,旧的向左滑出并淡出,新的从右滑入并淡入 */
@keyframes slide-to-left {
to {
transform: translateX(-30px);
opacity: 0;
}
}
@keyframes slide-from-right {
from {
transform: translateX(30px);
opacity: 0;
}
}
::view-transition-old(root) {
animation: 300ms cubic-bezier(0.4, 0, 1, 1) both slide-to-left;
}
::view-transition-new(root) {
animation: 300ms cubic-bezier(0, 0, 0.2, 1) 90ms both slide-from-right; /* 延迟一点出现 */
}
2. 让特定元素动起来:view-transition-name
想让页面上的某个卡片、头像、标题单独动起来?给它一个名字!
/* HTML 里有个卡片 */
/* <div class="card detailed-card">...</div> */
/* CSS 里给它命名 */
.detailed-card {
view-transition-name: detailed-card; /* 起个名字,必须唯一! */
contain: layout; /* 建议加上 contain,帮助浏览器优化 */
}
一旦你给元素加了 view-transition-name
:
- 浏览器会为这个
detailed-card
创建单独的::view-transition-group(detailed-card)
、::view-transition-image-pair(detailed-card)
等伪元素。 - 这个元素在旧状态和新状态的快照会被单独截取。
::view-transition-group(detailed-card)
会自动从旧元素的位置/大小动画过渡到新元素的位置/大小。::view-transition-old(detailed-card)
和::view-transition-new(detailed-card)
默认还是会进行交叉淡入淡出。
你可以像上面修改 root
一样,针对 detailed-card
这个名字来定制动画:
::view-transition-group(detailed-card) {
/* 可以改变 group 的动画,比如弹跳效果 */
animation-timing-function: cubic-bezier(0.5, 1.5, 0.5, 1.5);
}
::view-transition-old(detailed-card) {
/* 比如让旧卡片直接消失,不淡出 */
animation: none;
opacity: 0;
}
::view-transition-new(detailed-card) {
/* 比如让新卡片从下面飞入 */
animation: 300ms ease-out both slide-from-bottom;
}
@keyframes slide-from-bottom {
from {
transform: translateY(50px);
opacity: 0;
}
}
重要提示:view-transition-name
的值在同一时刻必须是唯一的。如果在拍快照时发现有两个元素用了同一个名字,整个 View Transition 过程会失败(但 DOM 更新还是会执行)。
3. 处理进入和离开动画 (使用 :only-child
)
有时候,某个元素只在新状态或旧状态存在(比如一个侧边栏导航只在某些页面有)。这时 ::view-transition-image-pair
里就只有一个孩子 (::view-transition-old
或 ::view-transition-new
)。我们可以利用 CSS 的 :only-child
伪类来专门处理这种情况:
.sidebar {
view-transition-name: sidebar;
}
/* 侧边栏进入动画 (新状态有,旧状态没有) */
::view-transition-new(sidebar):only-child {
animation: slide-in 300ms ease-out;
}
/* 侧边栏离开动画 (旧状态有,新状态没有) */
::view-transition-old(sidebar):only-child {
animation: slide-out 300ms ease-in;
}
/* 如果新旧状态都有侧边栏,默认的 group 位置/大小动画 + image-pair 淡入淡出可能就够了, */
/* 或者你也可以单独为这种情况写动画 (不加 :only-child) */
六、更骚的操作:JavaScript 动画 (Web Animations API)
CSS 动画很方便,但对于某些交互性强、需要动态计算的动画(比如规范里的那个鼠标点击位置圆形展开的例子),JavaScript 就派上用场了。
最佳时机是等待 transition.ready
Promise resolve 之后:
function navigateWithCircularReveal(data, clickEvent) {
if (!document.startViewTransition) {
updateContent(data);
return;
}
// 获取点击位置
const x = clickEvent?.clientX ?? window.innerWidth / 2;
const y = clickEvent?.clientY ?? window.innerHeight / 2;
// 计算到最远角的距离作为最终半径
const endRadius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y)
);
const transition = document.startViewTransition(() => updateContent(data));
// 等待伪元素准备好
transition.ready.then(() => {
// 使用 Web Animations API
document.documentElement.animate(
{
// 动画目标:从点击位置的小圆变成覆盖全屏的大圆
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
],
},
{
duration: 500,
easing: "ease-in",
// 关键:指定动画作用在哪个伪元素上!
pseudoElement: "::view-transition-new(root)", // 让新内容以圆形揭示出来
}
);
});
}
在这个例子里,我们还需要修改一下 CSS,阻止默认的淡入淡出,并让新旧内容直接叠加:
/* 禁用默认动画 */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
}
/* 让新旧内容直接叠加,由 clip-path 控制谁可见 */
::view-transition-image-pair(root) {
isolation: auto; /* 如果不需要混合模式,可以设为 auto */
}
::view-transition-new(root) {
mix-blend-mode: normal; /* 确保 JS 动画时混合模式正常 */
z-index: 1; /* 确保新内容在上面,被 clip-path 控制 */
}
::view-transition-old(root) {
mix-blend-mode: normal;
z-index: 0;
}
七、注意事项与最佳实践
view-transition-name
唯一性:再强调一次,非常重要!contain: layout
/content-visibility: hidden
:对于指定了view-transition-name
的元素,加上contain: layout;
或类似的 CSS(如contain: strict;
或content-visibility: hidden;
如果适用)可以帮助浏览器更好地隔离该元素的布局和渲染,提升性能。浏览器只需要知道这个元素的大小和位置,内部细节在快照时再处理。- 过渡是增强:时刻记住 View Transitions 是锦上添花。即使动画失败或被跳过,核心功能(DOM 更新)也应该能正常工作。
- 性能考量:虽然浏览器会做优化,但截取快照、创建伪元素、运行动画还是有成本的。对于非常复杂的页面或大量的独立过渡元素,要注意测试性能。避免给太多小元素指定
view-transition-name
。 - 快照内容:快照是渲染结果,包括 CSS 绘制的背景、伪元素内容等。但不包括
<iframe>
内部的内容(除非同源)或某些插件内容。 - 调试:现代浏览器的开发者工具(如 Chrome DevTools)已经开始支持调试 View Transitions,可以检查伪元素树、查看应用的动画等。
八、总结
CSS View Transitions 绝对是近年来 Web 平台最令人兴奋的新特性之一。它精准地抓住了前端开发中关于页面切换动画的痛点,提供了一种优雅、高效且符合渐进增强理念的解决方案。
其核心在于分离 DOM 更新与视觉过渡,通过快照和伪元素树,让浏览器接管了繁琐的过渡动画协调工作,开发者只需要:
- 用
document.startViewTransition
包裹你的 DOM 更新逻辑。 - (可选)用
view-transition-name
标记需要独立动画的元素。 - (可选)用 CSS 或 JavaScript (Web Animations API) 定制伪元素的动画效果。
从此,打造原生 App 般丝滑的页面转场不再是难事。赶紧去试试吧,让你的网页也“纵享丝滑”!
参考资料: