CSS View Transitions API:页面过渡动画的原生实现

本文详解CSS View Transitions API实现页面过渡动画的原理与用法,覆盖startViewTransition调用、view-transition-name共享元素过渡、SPA与MPA两种路由的集成方式,并对比JS动画方案的差异,帮助你规避兼容性和性能陷阱,在真实项目中渐进落地。

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

AI technology illustration

这篇文章我会从 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/

(0)
上一篇 1小时前
下一篇 1小时前

相关推荐