View Transitions API (Level-1 single-document)

今天咱们来聊一个前端圈儿里越来越火的新玩意儿——CSS View Transitions。这东西就好比给你的网页换场加了个丝滑的电影转场特效,告别过去那种生硬的“啪嗒”一下切换页面的体验。

W3C 的大佬们捣鼓出的这个 CSS View Transitions Module Level 1 规范,目前已经是 CR(Candidate Recommendation)阶段,说明离咱们大规模用上不远了。咱们的目标是彻底搞懂它,从“这啥玩意儿?”到“哦豁,有点意思”再到“爷青回,这动画我自己写!”。

一、告别刀耕火种:View Transitions 要解决啥蛋疼问题?

想想以前,尤其是在 SPA(单页应用)里搞页面切换动画,那叫一个折腾:

  1. DOM 大乱炖:为了让新旧两个状态能同时存在并产生动画效果(比如旧的淡出,新的淡入),你可能得手动控制 DOM,让两个页面的内容在某个时间段内都挂在页面上。这 DOM 结构,简直是为了动画效果“牺牲色相”,乱七八糟。
  2. JS 胶水代码:得写一堆 JavaScript 来协调 DOM 的增删、CSS class 的切换、动画的开始结束监听。逻辑复杂,还容易出 bug。
  3. 性能与体验:DOM 结构复杂了,性能可能受影响;动画过程中,焦点管理、可访问性(ARIA)也容易出问题,用户体验可能打折。比如动画过程中,屏幕阅读器是读旧的还是新的?按钮能点吗?

核心痛点视觉过渡效果DOM 状态更新 这两件事,在过去是紧密耦合、互相掣肘的。为了视觉效果,我们不得不扭曲 DOM 结构和更新逻辑。

二、View Transitions 的核心思想与哲学:解耦!分离!

W3C 的大佬们说:“不行,这太 low 了!咱们得想个办法把这两件事分开!”

于是,View Transitions 的核心哲学诞生了:

将视觉层面的过渡动画 与 DOM 结构和数据的更新彻底分离。

怎么分离呢?想象一下:

  1. DOM 更新?瞬间完成! 你该怎么更新 DOM 还怎么更新,别管动画的事儿。调用 API 后,你的更新代码(比如 React 的 setState,Vue 的数据修改,或者原生 JS 操作)会立即或异步执行,DOM 状态瞬间变成新的。
  2. 视觉过渡?交给浏览器! 在你更新 DOM 的前后,浏览器会像个摄影师,“咔嚓”给旧状态拍张照,“咔嚓”给新状态拍张照(这里的“照片”是渲染层面的快照,不是真的图片文件)。
  3. 幕后动画师:浏览器拿到这两张“照片”后,在一个特殊的、凌驾于普通内容之上的图层里,基于这两张快照玩起了动画。默认是简单的交叉淡入淡出(cross-fade)。旧“照片”慢慢消失,新“照片”慢慢显现。
  4. 动画结束,收工! 动画播完,这个特殊的图层和里面的“照片”就被清理掉了,用户看到的就是真实的新 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: "新的页面内容" });

发生了什么?

  1. 调用 startViewTransition(),浏览器:“收到!准备拍旧照片!”
  2. 浏览器拍下当前页面的旧状态快照
  3. 浏览器执行你传入的那个回调函数 () => updateContent(data)
  4. 你的 updateContent 函数执行,DOM 瞬间变成了新状态。
  5. (如果回调返回 Promise,浏览器会等它 resolve)浏览器:“OK,DOM 更新完了,拍新照片!”
  6. 浏览器拍下当前页面的新状态快照
  7. 浏览器在幕后创建一个特殊的伪元素树(后面细讲),把旧快照和新快照放进去。
  8. 浏览器应用默认的 CSS 动画(交叉淡入淡出)到这些伪元素上。
  9. 动画开始播放。用户看到旧内容淡出,新内容淡入。
  10. 动画结束。
  11. 浏览器清理掉伪元素树。
  12. transition.finished Promise resolve。

就这么简单,一个基础的页面交叉淡入淡出效果就有了!你几乎没改动原来的 DOM 更新逻辑,只是把它包了一层 startViewTransition

ViewTransition 对象:你的动画小助手

startViewTransition 返回一个 ViewTransition 对象,它有几个重要的属性(都是 Promise):

还有一个方法:

四、深入幕后:揭秘伪元素树

前面提到,浏览器在幕后创建了一个“伪元素树”来承载动画。这棵树长啥样?

::view-transition                    (根节点,覆盖整个视口)
└─ ::view-transition-group(name)     (每个独立过渡元素的“分组”)
   └─ ::view-transition-image-pair(name) (新旧快照的“容器对”)
      ├─ ::view-transition-old(name)   (旧状态快照的“图片”)
      └─ ::view-transition-new(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

你可以像上面修改 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;
}

七、注意事项与最佳实践

  1. view-transition-name 唯一性:再强调一次,非常重要!
  2. contain: layout / content-visibility: hidden:对于指定了 view-transition-name 的元素,加上 contain: layout; 或类似的 CSS(如 contain: strict;content-visibility: hidden; 如果适用)可以帮助浏览器更好地隔离该元素的布局和渲染,提升性能。浏览器只需要知道这个元素的大小和位置,内部细节在快照时再处理。
  3. 过渡是增强:时刻记住 View Transitions 是锦上添花。即使动画失败或被跳过,核心功能(DOM 更新)也应该能正常工作。
  4. 性能考量:虽然浏览器会做优化,但截取快照、创建伪元素、运行动画还是有成本的。对于非常复杂的页面或大量的独立过渡元素,要注意测试性能。避免给太多小元素指定 view-transition-name
  5. 快照内容:快照是渲染结果,包括 CSS 绘制的背景、伪元素内容等。但不包括 <iframe> 内部的内容(除非同源)或某些插件内容。
  6. 调试:现代浏览器的开发者工具(如 Chrome DevTools)已经开始支持调试 View Transitions,可以检查伪元素树、查看应用的动画等。

八、总结

CSS View Transitions 绝对是近年来 Web 平台最令人兴奋的新特性之一。它精准地抓住了前端开发中关于页面切换动画的痛点,提供了一种优雅、高效且符合渐进增强理念的解决方案。

其核心在于分离 DOM 更新与视觉过渡,通过快照伪元素树,让浏览器接管了繁琐的过渡动画协调工作,开发者只需要:

  1. document.startViewTransition 包裹你的 DOM 更新逻辑。
  2. (可选)用 view-transition-name 标记需要独立动画的元素。
  3. (可选)用 CSS 或 JavaScript (Web Animations API) 定制伪元素的动画效果。

从此,打造原生 App 般丝滑的页面转场不再是难事。赶紧去试试吧,让你的网页也“纵享丝滑”!

参考资料: