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 的导航栏切换效果。这个效果细节不少:

  1. 页面整体:新页面从右侧滑入,覆盖旧页面。
  2. 返回按钮图标 (backIcon):在切换过程中,位置保持不动(视觉上像钉在那里)。
  3. 返回按钮文字 (backText):由旧页面的标题 (title) “变形”而来。它不是简单地淡入淡出,而是从旧标题的位置平滑移动并变成返回文字。
  4. 新页面标题 (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 的变化过程,通常分三步:

  1. 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。这涉及到更复杂的名字配对。
  2. startViewTransition 的回调函数内 (DOM 更新时):

    • 这是最关键的一步,我们要“告诉”浏览器新旧元素的对应关系。
    • 旧页面的元素可能要移除 view-transition-name,或者设置一个仅用于“离开动画”的名字。
    • 新页面的元素,此时要正式赋予那个我们希望它从旧状态过渡而来的名字。
      • 例如,在回调里,把旧标题的 view-transition-name 移除(或者让它准备滑走),同时把新页面的 返回按钮文字 (backText) 的 view-transition-name 设置为 old-title(这样它就会从旧标题的位置过渡过来)。
      • 把旧返回图标 (backIcon) 的 view-transition-name 移除(可能伴随透明度变化),同时把新页面的 backIconview-transition-name 设置为 old-backIcon(让它看起来像是同一个元素)。
      • 新页面的标题 (title) 则保持 new-title,它会执行自己的“进入”动画(比如从右滑入)。
  3. 过渡结束后 (transition.finished):

    • 清理工作。移除不再需要的旧页面 DOM。可能还需要清理掉临时设置的 view-transition-name 或样式。

看下面这个简化的 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 本身也有一些特性或限制需要注意:

  1. 渲染抑制 (Rendering Suppression)

    • 现象:在 startViewTransition 被调用、快照生成后,直到过渡动画完成(transition.finished resolve),整个原始文档(除了 ::view-transition 伪元素树)在视觉上是不可见且不可交互的。浏览器会暂停渲染常规内容,只渲染那个特殊的过渡层。
    • 影响:这意味着如果你的过渡动画时间较长,用户会有一段时间无法与页面进行任何交互(点击、滚动等)。即便你尝试用 clip-path 裁剪 ::view-transition 伪元素,露出来的也不是底下的原始文档,而是一片空白(或浏览器的背景色)。
    • 结论:目前无法绕过渲染抑制。因此,View Transitions 不适合做长时间运行的动画,否则会严重影响用户体验。动画时长应尽量控制在用户可接受的范围内(通常几百毫秒)。
  2. 快照与元素上下文

    • 现象:当一个元素被赋予 view-transition-name 后,浏览器为它拍快照时,可以理解为是把它暂时“抠”了出来。这个快照包含了元素自身及其子元素的渲染结果,但它在某种程度上脱离了原始的布局上下文
    • 影响
      • overflow: hidden 失效:如果一个带名字的元素原本被父元素的 overflow: hiddenclip-path 裁剪了,在过渡动画中,这个裁剪效果可能会丢失,导致元素完整地显示出来,因为它被渲染在了更高层级的 ::view-transition-group 中。你可能需要手动在 ::view-transition-group(name) 或对应的 old/new 伪元素上重新应用 clip-path 来模拟裁剪,但这在元素平移时计算会变得复杂。
      • 其他上下文相关的样式:某些依赖于父级或兄弟元素的样式(比如特定的混合模式应用)可能在过渡中表现不一致。
    • 建议:为带名字的元素添加 contain: layout; 或类似属性(如 contain: paint;, contain: strict;, content-visibility: hidden;),可以帮助浏览器优化并减少意外情况,但不能完全解决上下文脱离的问题。
  3. CSS 自定义属性 (Custom Properties) 的作用域

    • 现象::view-transition 及其子伪元素位于一个非常高的渲染层级,并且它们的样式似乎与常规 DOM 的级联关系有些微妙。直接在 :roothtml/body 上通过 style.setProperty('--my-var', value) 设置的自定义属性,可能无法直接在 ::view-transition-group(*) 等伪元素的 CSS 规则中生效。
    • 解决方案:如果你需要在过渡动画中动态改变并在 CSS 中使用自定义属性(比如通过 JS 计算动画参数),目前看来比较可靠的方法是动态创建或修改 CSS 规则本身。例如:
      • 创建一个 <style> 标签,动态写入包含自定义属性值的 CSS 规则。
      • 使用 document.adoptedStyleSheets API,动态添加或修改包含这些规则的 CSSStyleSheet 对象。
  4. 特定元素或上下文的不支持 除了快照带来的上下文问题,还有一些特定的场景经过测试发现 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 是基于元素渲染后的几何快照(位置、尺寸)进行工作的,一个没有自身渲染盒子的元素,自然无法被捕获其几何信息,也就无法作为独立的过渡单元参与动画了。
  5. 在 view-transition 过程中,视图窗口不可发生变化 一旦变化会直接跳过变换(skipTransition),注意这跟 DOM 元素的变化不一样

    • 比如窗口视图的 resize 行为会导致跳过变换
    • 比如窗口视图完全遮挡行为会导致跳过变换(这应该跟操作系统的遮挡识别有关系,它会改变浏览器的出啊窗口视图的属性,从而导致 view-transition 失效)
      • 比方说浏览器标签、或者最小化浏览器,都会触发这个问题。
    • 这也就导致了,在 devtools 调试的时候很糟心的一个点:在 Animations 面板暂停一个 View Transition 动画后,如果你做了上面所提到行为(比如你要切换到别的 tab 页去看资料;比如你的编辑器切换上来遮挡了浏览器窗口),就会导致 ::view-transition 直接消失。

三、View Transitions 的适用场景与挑战

了解了复杂性和限制后,哪些场景适合用,哪些场景要三思?

  1. 启动屏幕 (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-pathtransform
      • 结论:对于复杂的、需要实时更新的加载动画,View Transitions 可能增加的复杂性远超其带来的便利,此时不如用传统 JS+CSS 动画方案。
      • 最后这里给出 DEMO 链接 progress demo by view-transition
  2. 简单的内容切换/卡片展开等

    • 对于不涉及复杂多元素协调、动画时长可控的场景,View Transitions 依然是很好的选择,比如列表项点击展开为详情、页面主体内容的简单淡入淡出或滑动切换。

总结与思考

CSS View Transitions Level 1 无疑为 Web 带来了强大的原生级动画能力,尤其是在处理简单的状态切换时,能极大简化代码。但它并非银弹。

对于简单的、符合其设计模型的场景,大胆用,享受它带来的便利。 对于复杂的、尤其是试图在 SPA 中复刻原生多页面导航细节的场景,请仔细评估引入它的成本(代码复杂性、潜在的限制)是否值得。 有时候,传统的 JS 动画库或纯 CSS 动画可能仍然是更灵活或更稳妥的选择。