View Transitions API (Level-1 single-document) 进阶
上次咱们聊了 View Transitions 的基础,那感觉就像发现新大陆,丝滑得不行。但真正在复杂的 SPA(单页应用)场景里用起来,尤其是想模拟原生 App 那种细腻的转场效果时,你可能会发现,这“丝滑”背后,可能藏着一些“蛋疼”的细节。
核心矛盾点:View Transitions Level 1
的设计哲学是针对单个文档内 DOM 状态变化的视觉过渡。而 SPA 的常见模式是在单个文档里模拟多个“页面”的导航切换。这种模式上的错位,是许多复杂问题的根源。Level 1 并没有“页面”或“路由”的概念,它只关心“变化前”和“变化后”的 DOM 快照。
接下来,咱们通过一些实践案例,深入探讨在 SPA 中应用 View Transitions 的复杂性、局限性以及特定场景下的思考。
一、SPA 导航模拟:看起来很美,做起来费心
首先给出 DEMO 链接: ios-navigation demo by view-transition
咱们来看一个常见的需求:在 SPA 里模拟类似 iOS 的导航栏切换效果。这个效果细节不少:
- 页面整体:新页面从右侧滑入,覆盖旧页面。
- 返回按钮图标 (backIcon):在切换过程中,位置保持不动(视觉上像钉在那里)。
- 返回按钮文字 (backText):由旧页面的标题 (title) “变形”而来。它不是简单地淡入淡出,而是从旧标题的位置平滑移动并变成返回文字。
- 新页面标题 (title):随着新页面整体从右侧滑入。
听起来用 View Transitions 的 view-transition-name
标记一下对应元素,浏览器就该自动搞定了吧?比如 Compose 或 SwiftUI 里的 sharedElement
/ matchedGeometryEffect
,声明一下就完事儿了。
然而,在 View Transitions Level 1 里,事情没那么简单。
因为我们是在同一个文档里操作,当你触发 startViewTransition
时,浏览器需要拍“旧状态”和“新状态”两张快照。如果你在拍快照 之前,新旧两个“页面”的对应元素(比如两个标题)都设置了相同的 view-transition-name
(例如都叫 title
),那么在拍快照的那个瞬间,DOM 里就存在了两个叫 title
的元素。浏览器:“???这名字重了,过渡搞不了!”——于是整个 View Transition 会失败(但你的 DOM 更新回调还是会执行)。
Compose/SwiftUI 为何简单? 因为它们的 sharedElement
通常是和导航库/页面生命周期耦合的。框架知道你正在从 Page A 导航到 Page B,它能清晰地识别“哪个是旧的 title
,哪个是新的 title
”,并自动处理它们之间的过渡。
View Transitions Level 1 怎么做? 它不耦合导航,只认 DOM 变化。所以,为了让它正确配对,我们必须手动“导演” view-transition-name
的变化过程,通常分三步:
startViewTransition
调用前:- 旧页面的元素标记为
old-name
(e.g.,old-title
)。 - 新页面的元素(此时可能还未插入 DOM,或插入了但暂时隐藏/移出屏幕)标记为
new-name
(e.g.,new-title
),或者干脆先不加view-transition-name
。 - 在这个例子里,还需要特殊处理:旧标题
old-title
需要过渡到新页面的返回文字new-backText
,旧返回图标old-backIcon
要过渡到新返回图标new-backIcon
。这涉及到更复杂的名字配对。
- 旧页面的元素标记为
startViewTransition
的回调函数内 (DOM 更新时):- 这是最关键的一步,我们要“告诉”浏览器新旧元素的对应关系。
- 旧页面的元素可能要移除
view-transition-name
,或者设置一个仅用于“离开动画”的名字。 - 新页面的元素,此时要正式赋予那个我们希望它从旧状态过渡而来的名字。
- 例如,在回调里,把旧标题的
view-transition-name
移除(或者让它准备滑走),同时把新页面的 返回按钮文字 (backText
) 的view-transition-name
设置为old-title
(这样它就会从旧标题的位置过渡过来)。 - 把旧返回图标 (
backIcon
) 的view-transition-name
移除(可能伴随透明度变化),同时把新页面的backIcon
的view-transition-name
设置为old-backIcon
(让它看起来像是同一个元素)。 - 新页面的标题 (
title
) 则保持new-title
,它会执行自己的“进入”动画(比如从右滑入)。
- 例如,在回调里,把旧标题的
过渡结束后 (
transition.finished
):- 清理工作。移除不再需要的旧页面 DOM。可能还需要清理掉临时设置的
view-transition-name
或样式。
- 清理工作。移除不再需要的旧页面 DOM。可能还需要清理掉临时设置的
看下面这个简化的 JS 逻辑示意(完整代码参考你提供的链接):
// 简化示意逻辑
class ViewTransitionController {
// ... (省略获取元素等)
beforeStart() {
// 1. 准备阶段:获取新旧元素引用,准备新页面内容
// ...
this.oldEles = this.getEles(canvas.querySelector(".page"));
const newPageContent = tmp.content.cloneNode(true);
this.newEles = this.getEles(newPageContent);
this.updateText(); // 更新新页面的标题和返回文字内容
canvas.appendChild(newPageContent); // 将新页面添加到 DOM (可能先放在屏幕外)
// --- 关键:设置初始名字 ---
this.oldEles.page.style.viewTransitionName = "page-old";
this.oldEles.title.style.viewTransitionName = "title-old"; // 旧标题
this.oldEles.backIcon.style.viewTransitionName = "backIcon-old"; // 旧图标
this.newEles.page.style.viewTransitionName = "page-new"; // 新页面整体
this.newEles.title.style.viewTransitionName = "title-new"; // 新标题
// 新返回图标和文字先不给名字,或者给个临时的,避免冲突
this.newEles.page.classList.add("from-right"); // 先放右边
}
doStart() {
// 2. DOM 更新回调:在这里“重新配对”名字
const { oldEles, newEles } = this;
// 旧元素处理(准备离开)
oldEles.page.classList.add("to-left"); // 旧页面整体左滑
oldEles.title.style.viewTransitionName = ""; // 旧标题不再参与共享过渡,准备淡出或滑出
oldEles.backIcon.style.viewTransitionName = ""; // 旧图标也类似
// 新元素处理(准备进入和变形)
newEles.page.classList.remove("from-right"); // 新页面移动到位置
newEles.backIcon.style.viewTransitionName = "backIcon-old"; // 新图标继承旧图标的名字,实现“不动”效果
newEles.backText.style.viewTransitionName = "title-old"; // 新返回文字继承旧标题的名字,实现“变形”效果
// newEles.title 保持 'title-new',执行自己的进入动画
// 可能还需要配合 opacity 等样式变化
}
afterFinish() {
// 3. 清理阶段
this.oldEles.page.remove(); // 移除旧页面 DOM
// 可能还需要清理新元素上临时的 viewTransitionName
}
}
// 使用
const vtc = new ViewTransitionController();
btnPlay.addEventListener("click", async () => {
vtc.beforeStart();
const vt = document.startViewTransition(vtc.doStart);
await vt.finished;
vtc.afterFinish();
});
小结:在 SPA 中用 Level 1 实现复杂的多元素协调过渡,需要开发者手动管理 view-transition-name
的生命周期,模拟出跨“页面”共享元素的效果。这比声明式 UI 框架的写法要繁琐得多,心智负担也更重。
二、深入 View Transitions 的限制与“脾气”
除了 SPA 模拟带来的复杂性,View Transitions 本身也有一些特性或限制需要注意:
渲染抑制 (Rendering Suppression)
- 现象:在
startViewTransition
被调用、快照生成后,直到过渡动画完成(transition.finished
resolve),整个原始文档(除了::view-transition
伪元素树)在视觉上是不可见且不可交互的。浏览器会暂停渲染常规内容,只渲染那个特殊的过渡层。 - 影响:这意味着如果你的过渡动画时间较长,用户会有一段时间无法与页面进行任何交互(点击、滚动等)。即便你尝试用
clip-path
裁剪::view-transition
伪元素,露出来的也不是底下的原始文档,而是一片空白(或浏览器的背景色)。 - 结论:目前无法绕过渲染抑制。因此,View Transitions 不适合做长时间运行的动画,否则会严重影响用户体验。动画时长应尽量控制在用户可接受的范围内(通常几百毫秒)。
- 现象:在
快照与元素上下文
- 现象:当一个元素被赋予
view-transition-name
后,浏览器为它拍快照时,可以理解为是把它暂时“抠”了出来。这个快照包含了元素自身及其子元素的渲染结果,但它在某种程度上脱离了原始的布局上下文。 - 影响:
overflow: hidden
失效:如果一个带名字的元素原本被父元素的overflow: hidden
或clip-path
裁剪了,在过渡动画中,这个裁剪效果可能会丢失,导致元素完整地显示出来,因为它被渲染在了更高层级的::view-transition-group
中。你可能需要手动在::view-transition-group(name)
或对应的old/new
伪元素上重新应用clip-path
来模拟裁剪,但这在元素平移时计算会变得复杂。- 其他上下文相关的样式:某些依赖于父级或兄弟元素的样式(比如特定的混合模式应用)可能在过渡中表现不一致。
- 建议:为带名字的元素添加
contain: layout;
或类似属性(如contain: paint;
,contain: strict;
,content-visibility: hidden;
),可以帮助浏览器优化并减少意外情况,但不能完全解决上下文脱离的问题。
- 现象:当一个元素被赋予
CSS 自定义属性 (Custom Properties) 的作用域
- 现象:
::view-transition
及其子伪元素位于一个非常高的渲染层级,并且它们的样式似乎与常规 DOM 的级联关系有些微妙。直接在:root
或html
/body
上通过style.setProperty('--my-var', value)
设置的自定义属性,可能无法直接在::view-transition-group(*)
等伪元素的 CSS 规则中生效。 - 解决方案:如果你需要在过渡动画中动态改变并在 CSS 中使用自定义属性(比如通过 JS 计算动画参数),目前看来比较可靠的方法是动态创建或修改 CSS 规则本身。例如:
- 创建一个
<style>
标签,动态写入包含自定义属性值的 CSS 规则。 - 使用
document.adoptedStyleSheets
API,动态添加或修改包含这些规则的CSSStyleSheet
对象。
- 创建一个
- 现象:
特定元素或上下文的不支持 除了快照带来的上下文问题,还有一些特定的场景经过测试发现 Level 1 目前无法支持:
- Shadow DOM 内元素:如果你尝试在 Web Component 的 Shadow Root 内部给一个元素设置
view-transition-name
,你会发现它并不会参与到 View Transition 动画中。这似乎是因为 View Transition 的伪元素树是在主文档上下文中创建和渲染的,当前的匹配和渲染机制可能无法有效地跨越 Shadow Boundary 来识别和处理内部元素的过渡。这意味着,如果你的关键过渡元素封装在 Shadow DOM 里,你需要寻找变通方法(比如将该元素通过 Slot 暴露到 Light DOM,或者状态提升后在主文档中渲染过渡副本)。 display: contents
元素:给设置了display: contents;
的元素添加view-transition-name
同样是无效的。display: contents
的核心作用是让元素自身不产生盒子 (box),将其子元素“提升”到父级。而 View Transitions 是基于元素渲染后的几何快照(位置、尺寸)进行工作的,一个没有自身渲染盒子的元素,自然无法被捕获其几何信息,也就无法作为独立的过渡单元参与动画了。
- Shadow DOM 内元素:如果你尝试在 Web Component 的 Shadow Root 内部给一个元素设置
在 view-transition 过程中,视图窗口不可发生变化 一旦变化会直接跳过变换(skipTransition),注意这跟 DOM 元素的变化不一样
- 比如窗口视图的 resize 行为会导致跳过变换
- 比如窗口视图完全遮挡行为会导致跳过变换(这应该跟操作系统的遮挡识别有关系,它会改变浏览器的出啊窗口视图的属性,从而导致 view-transition 失效)
- 比方说浏览器标签、或者最小化浏览器,都会触发这个问题。
- 这也就导致了,在 devtools 调试的时候很糟心的一个点:在 Animations 面板暂停一个 View Transition 动画后,如果你做了上面所提到行为(比如你要切换到别的 tab 页去看资料;比如你的编辑器切换上来遮挡了浏览器窗口),就会导致
::view-transition
直接消失。
三、View Transitions 的适用场景与挑战
了解了复杂性和限制后,哪些场景适合用,哪些场景要三思?
启动屏幕 (Splash Screen)
- 优势:渲染抑制在这里反而是个优点。你可以在应用初始化、加载资源、准备首屏 DOM 的过程中,用 View Transitions 显示一个启动画面(比如 Logo 淡入淡出)。由于原始文档被抑制渲染和交互,正好符合启动阶段的需求。
- 代码结构:
// 伪代码 prepareSplashScreenDOM(); // 先把启动屏DOM准备好 const vt = document.startViewTransition(async () => { // 在这个回调里,异步加载应用资源、初始化状态、渲染主界面DOM await loadAppResources(); await initialRender(); // DOM 准备好后,可以开始“关闭”启动屏的动画逻辑(比如让Logo飞走) triggerSplashScreenExitAnimationLogic(); // 这部分逻辑可能是更新某些元素的name或样式 }); // 可选:监听 finished 后彻底移除启动屏DOM vt.finished.then(removeSplashScreenDOM);
- 挑战:如果想做带有加载进度条的启动屏,会遇到前面提到的问题。简单的进度条(比如一个色块变长)可能还行,但如果进度条本身有复杂纹理或效果,View Transitions 默认的 bounds(边界框)动画可能会导致纹理拉伸变形。你需要:
- 将纹理独立为一个带
view-transition-name
的元素。 - 可能需要隐藏 DOM 元素的原始纹理,在
::view-transition-group(texture)
或::view-transition-new(texture)
上重新绘制或应用纹理。 - 如果想实现平滑的进度更新动画,而不是简单的两点过渡,可能需要放弃 CSS 动画,在
transition.ready
后介入,使用 Web Animations API 精确控制::view-transition-new(progressBarValue)
的clip-path
或transform
。 - 结论:对于复杂的、需要实时更新的加载动画,View Transitions 可能增加的复杂性远超其带来的便利,此时不如用传统 JS+CSS 动画方案。
- 最后这里给出 DEMO 链接 progress demo by view-transition
- 将纹理独立为一个带
简单的内容切换/卡片展开等
- 对于不涉及复杂多元素协调、动画时长可控的场景,View Transitions 依然是很好的选择,比如列表项点击展开为详情、页面主体内容的简单淡入淡出或滑动切换。
总结与思考
CSS View Transitions Level 1 无疑为 Web 带来了强大的原生级动画能力,尤其是在处理简单的状态切换时,能极大简化代码。但它并非银弹。
- 核心优势在于解耦了 DOM 更新和视觉过渡,让状态管理更清晰。
- 核心挑战在于其 Level 1 的设计基于单文档状态快照,当我们在 SPA 中模拟多页面流时,需要手动填补这种模式差异带来的沟壑,尤其是对
view-transition-name
的精细管理。 - 局限性(如渲染抑制、快照上下文问题)也决定了它不适用于所有动画场景,特别是长耗时或需要高度交互保障的过渡。
对于简单的、符合其设计模型的场景,大胆用,享受它带来的便利。 对于复杂的、尤其是试图在 SPA 中复刻原生多页面导航细节的场景,请仔细评估引入它的成本(代码复杂性、潜在的限制)是否值得。 有时候,传统的 JS 动画库或纯 CSS 动画可能仍然是更灵活或更稳妥的选择。