View Transitions API实战:为SPA页面切换添加丝滑过渡动画
2026/9/15 18:08:32 网站建设 项目流程

给 SPA 加页面切换动画这件事,我已经惦记了很久。SPA 的体验优势是路由切换不用刷新页面,但换来的是页面瞬间“换脸”,列表跳到详情、详情退回列表,整个过程没有任何空间过渡,用户眼睛根本追不上页面变化。直到 View Transitions API 陆续进入主流浏览器,我才觉得这问题终于有了原生解:一个能在 DOM 更新的同时自动捕获旧页面快照、渲染新页面快照、并让两者按 CSS 动画平滑切换的浏览器级能力。这篇文章不聊空泛的概念,就讲清楚它背后的原理、怎么接入 React/Vue 路由,以及我在实际项目里踩过的坑。

1. 这个 API 到底解决了什么问题

1.1 为什么 SPA 页面切换会“生硬”

SPA 的路由本质是“同一个 HTML 文档里的视图替换”。用户点了详情按钮,框架把当前组件卸载、挂载新组件,浏览器直接渲染出新页面。对用户来说,这个操作快是快,但没有层级感、没有方向感,就像幻灯片在一瞬间被抽走再放上另一张。

很多团队会自己造轮子来解决,比如给旧页面加一个离场动画类,等动画结束后再卸载,再给新页面加入场动画。听起来不难,真的做起来就知道痛苦:你得手动管理两个页面的生命周期、处理路由取消/重复点击、控制滚动位置、还要保证动画被中断时不出现 UI 状态错乱。更麻烦的是,列表页到详情页想要“同一张卡片放大变成头图”这种效果,几乎要把 DOM 结构、定位逻辑全部绑在一起,稍不留神就崩。

而 View Transitions API 把“旧页面到新页面的过渡”这件事抽象成了浏览器原生能力,框架开发者不用再手动协调卸载、挂载、动画时机了。只要在 DOM 更新的外层包一个document.startViewTransition(),浏览器会自动完成:截取旧状态快照、执行 DOM 更新、截取新状态快照、播放两个快照之间的过渡动画。它解决的正是 SPA 页面切换里最脏最累的这一段流程。

1.2 View Transitions API 做了什么

这个 API 的核心入口只有一个方法:

document.startViewTransition(updateDOM);

updateDOM是你自己的函数,里面负责改 DOM,比如切换路由、切换状态、切主题。调用之后,浏览器会先拍下当前页面的快照,然后执行updateDOM,等新页面渲染完成后拍下新快照,最后用一组特定的伪元素把两张快照组合起来,播放一段默认的交叉淡入淡出动画。

好处很直观:

  • 无需引入任何动画库,CSS 就能控制全部动画细节。
  • 浏览器用快照做过渡,动画过程中操作的是已经序列化的视图层,对页面交互的侵入极小。
  • 很多高级效果(比如让两个元素共享过渡)都被封装成语义化的 API,复杂度比手写低一个量级。

在我实际体验里,这个 API 对“页面切换”的场景近乎是量身定做。它不关心你用的是 React、Vue 还是原生 JS,只关心你能不能把“DOM 更新”放进那个回调里。

1.3 方案选型:为什么不是 CSS 动画库

有人会问:Framer Motion、GSAP 不也能做页面过渡吗?确实能,但用法完全不一样。

动画库适合做“局部元素级别的精细动画”,比如侧边栏滑入、弹窗缩放、表单校验抖动等。一旦上升到“两个页面整体切换”,动画库需要你显式控制两组页面元素的出场/入场时机,还要处理组件卸载、异步数据渲染、浏览器前进后退等边界情况,实施成本很高。

View Transitions API 的优势在于“接管状态变化”。它的设计假定就是:我们已经有了旧状态和新状态,你只需关心两张快照之间如何演变化。因此代码量通常会减少到原来方案的十分之一,而且在动画期间如果 DOM 又发生了变化,浏览器会自己合并处理,不容易产生撕裂感。

这不是说动画库没用,而是从“做整页切换”这个需求出发,浏览器原生方案的性价比已经高到值得优先尝试。后续如果要在局部元素上做更花哨的效果,再叠加动画库也不迟。

2. 核心原理:一次过渡的完整生命周期

2.1 startViewTransition 背后到底发生了什么

不理解原理直接写,很容易写出“动画不触发”或者“黑屏闪一眼”的问题。我按实际调用过程拆解一下:

  1. 调用startViewTransition(callback)后,浏览器先读取并冻结当前页面视觉状态,生成旧快照。
  2. 然后执行你传入的 callback,在这个函数里完成 DOM 更新。
  3. 浏览器等待新页面渲染完成,再生成一份新快照。
  4. 浏览器创建一组动画专用的伪元素树,把旧快照和新快照分别放进旧/新视图层里。
  5. 默认情况下,旧快照执行淡出动画,新快照执行淡入动画,整个过程约 250ms。

这里有个关键词是“快照”。它并不是真的截图,而是对页面渲染结果的视觉捕获,所以即使页面里有视频、动画,快照也只会保留一帧图像。这也是为什么这个 API 特别适合做“从一个页面切换到另一个页面”的动画——两个静态画面之间的过渡,天然比实时处理两个动态页面要简单可靠。

细节上,startViewTransition()返回一个ViewTransition对象,包含三个 Promise 属性:

  • ready:旧快照捕获完成、动画即将开始前触发,适合在这个时机做一些交互动画补充。
  • updateCallbackDone:你的回调执行完成(返回的 Promise resolve)后触发。
  • finished:整个过渡动画播放完毕。

用生活中最常见的类比:你拍了一张旧房间的照片,然后把房间重新布置了一遍,再拍一张新照片,最后用投影仪把两张照片叠在一起做交叉淡化过渡。浏览器干的正是“拍照”和“放投影”这两件事,你只需要告诉它“房间重新布置好了没有”。

2.2 用伪元素控制过渡动画

过渡动画不是写死在 JS 里的,而是通过一组动态生成的伪元素暴露给 CSS 控制。这是 View Transitions API 最精巧的设计之一。

当一次过渡开始时,浏览器会在根节点下动态生成如下结构(非真实 DOM,类型上是伪元素):

  • ::view-transition:整个过渡动画的根容器,覆盖在页面最上层。
  • ::view-transition-group():管理某一组旧/新快照的整体位置、尺寸和变换。
  • ::view-transition-image-pair():同一组快照中,旧快照和新快照的混合容器。
  • ::view-transition-old():旧快照层,默认展示淡出效果。
  • ::view-transition-new():新快照层,默认展示淡入效果。

默认情况下,没有被单独命名的内容都归入root组,你可以直接控制根组的动画。比如我想让页面切换变成“旧页向左滑动退场,新页从右侧滑入”,只需要写:

::view-transition-old(root) { animation: slide-out 0.3s ease-in both; } ::view-transition-new(root) { animation: slide-in 0.3s ease-out both; } @keyframes slide-out { to { transform: translateX(-30%); opacity: 0; } } @keyframes slide-in { from { transform: translateX(100%); } to { transform: translateX(0); } }

注意这里both填充模式,会让动画开始前/结束后都保持关键帧状态,避免出现动画播放前旧快照一闪、动画播放后新快照位置不对的意外。这类小细节,临时调用时最容易漏。

如果你希望某块内容不参与动画,直接把它对应的动画设为none就好。比如侧边栏在切换页面时保持静止,就在 CSS 里对根组下的侧边栏视图单独处理。

2.3 命名视图与共享元素过渡

如果只是整页淡入淡出,那还不足以打动我。View Transitions API 真正吸引人的一点是“命名视图”:你可以给页面里的任意元素设置view-transition-name,它就会从整页快照中独立出来,成为单独的一组视图,拥有自己的过渡动画。

最常见的应用就是列表页到详情页的效果:点击列表卡片,卡片放大过渡成详情页头图。实现思路是,在列表页里给每张卡片设一个名称,详情页里给头图设同样的名称,然后浏览器就会自动对这两个元素做位置、尺寸、圆角等属性的渐变过渡。

比如列表项这样设置:

<div style={{ viewTransitionName: `list-card-${id}` }} />

详情页的头图也设置成同一个名称:

<div style={{ viewTransitionName: `list-card-${id}` }} />

页面切换时,浏览器会识别旧页面和新页面中名字相同的视图,自动为它们生成一个group,并把这个组从旧位置平滑变化到新位置、从旧尺寸过渡到新尺寸。

命名视图很好用,但需要特别注意:同一时刻一个页面里只能出现一个同名的视图,多个同名元素会被浏览器忽略,导致过渡失效。列表页多个卡片时通常会用 ID 差异化名称;如果两个页面里只有一个会同时存在,同一个名称没问题;但如果新旧页面同时渲染了多个同名元素,就会出事。这块我在实战部分会再展开。

3. React 实战:给 React Router 加上页面切换动画

3.1 最小可运行接入方案

先说核心难点:React 的状态更新是异步批处理的。如果你在startViewTransition的回调里直接setState,浏览器执行完回调时,DOM 可能还没更新,新快照捕获的仍然是旧页面,动画自然就失效了。

解决办法是在回调里用flushSync强制 React 同步提交更新。以 React Router v6 为例:

import { flushSync } from 'react-dom'; import { useNavigate } from 'react-router-dom'; const navigate = useNavigate(); function handleNavigate(to: string) { if (!document.startViewTransition) { navigate(to); return; } document.startViewTransition(() => { flushSync(() => { navigate(to); }); }); }

这段代码是最小接入方案。flushSync会把navigate触发的状态更新同步推到 DOM 上,这样浏览器在startViewTransition回调执行完后能立刻拿到新页面快照。

我在实际项目里不会把切换逻辑散落在每个按钮里,而是直接在路由层封装一个useTransitNavigate钩子:

export function useTransitNavigate() { const navigate = useNavigate(); return useCallback((to: string) => { if (!document.startViewTransition) { navigate(to); return; } document.startViewTransition(() => { flushSync(() => { navigate(to); }); }); }, [navigate]); }

这样所有需要动画跳转的组件统一调用transitNavigate('/detail/123'),代码简洁,也不会漏掉兼容判断。

一个容易踩的细节是:startViewTransition的回调里如果抛了错误(比如路由命中失败),整个过渡会中断。所以有条件的话,最好在回调外先做好路由检查,或者在回调里用try/finally包一层,确保异常时页面状态不会卡在半路。

3.2 自定义全局滑动与缩放动画

接入之后,默认的交叉淡入淡出效果比较平淡。我一般会根据页面层级设计“前进时新页从右侧滑入、后退时旧页从左侧退场”的导航感。

直接在全局 CSS 里定义根组动画:

::view-transition-old(root) { animation: slide-out-to-left 0.3s ease-in-out both; } ::view-transition-new(root) { animation: slide-in-from-right 0.3s ease-in-out both; } @keyframes slide-out-to-left { from { transform: translateX(0); opacity: 1; } to { transform: translateX(-20%); opacity: 0.6; } } @keyframes slide-in-from-right { from { transform: translateX(100%); } to { transform: translateX(0); } }

很多人在动画里加上位移之后发现背景露出了白底,其实是html/body的根背景没有快照进来。建议在启动动画前给::view-transition本身设置背景色,或者保证body的背景在切换前后一致:

::view-transition { background-color: #f5f6fa; }

另一个我比较常用的技巧是给旧页面加轻微缩放,产生“页面在缩小退后”的感觉,配合新页面放大进入,视觉层次感会强很多。下面是两个方向都可以用的通用动画模板:

::view-transition-old(root) { animation: fade-and-shrink 0.3s ease both; } ::view-transition-new(root) { animation: fade-and-grow 0.3s ease both; } @keyframes fade-and-shrink { to { opacity: 0; transform: scale(0.96); } } @keyframes fade-and-grow { from { opacity: 0; transform: scale(1.02); } to { opacity: 1; transform: scale(1); } }

注意动画时长控制在 250ms 到 350ms 之间最好。太短了看不出过渡层次,太长了用户会有等待感,尤其在高频路由跳转时这种迟钝感会被放大。

3.3 列表页到详情页的共享元素过渡

这个场景是我项目里最能体现 View Transitions API 价值的地方。以前要做一个“列表卡片放大变成详情头图”的效果,需要手动记录卡片位置、计算头图位置、用 FLIP 动画去对齐,流程长且容易抖动。用命名视图后,代码量缩减了一大半。

列表页里给卡片设置viewTransitionName

{posts.map((post) => ( <Card key={post.id} onClick={() => transitNavigate(`/post/${post.id}`)} style={{ viewTransitionName: `post-cover-${post.id}` }} > <img src={post.cover} alt="" /> <div>{post.title}</div> </Card> ))}

详情页里给头图相同的参数:

<PostHeader style={{ viewTransitionName: `post-cover-${postId}` }} />

这样命名相同,浏览器就会把列表卡片和详情头图识别为“同一个元素的变化”,自动完成位置、尺寸、圆角的过渡。

不过这里有几个坑,我逐个说:

  • 列表页其他卡片也会参与根组过渡。如果你只想突出当前卡片,建议给列表项里不相关的区域设置view-transition-name: none,或者用 CSS 让它们不参与动画。
  • 详情页头图如果用了object-fit: cover,新快照里的裁切方式和卡片里的显示比例不一致时,过渡过程中图片会被拉伸,看起来不舒服。我的处理方法是给图片容器设置固定的aspect-ratio,并且在过渡期间临时让图片对齐方式一致。
  • 卡片点击后如果发生了异步路由加载,新页面渲染有延时,命名视图的过渡可能会对着一个空白页面进行。这个问题下面的 Vue 部分我会给出通用解法。

另外,命名视图会创建独立图层,页面里如果有position: fixed的弹层位于设置了view-transition-name的容器内部,弹层的定位基准会变成这个容器,视觉上飘忽不定。这属于 CSS 层面的“陷阱”,排查时需要留意。

4. Vue 实战:给 Vue Router 加上页面切换动画

4.1 通过包装 router.push 接入

Vue Router 的接入思路和 React 不太一样。Vue 不需要flushSync,但需要确保路由切换导致的 DOM 更新完成后再让浏览器捕获新快照。比较稳妥的做法是包装 Router 的pushreplace方法:

import { nextTick } from 'vue'; import router from './router'; if (document.startViewTransition) { const originalPush = router.push.bind(router); router.push = (...args) => { return document.startViewTransition(async () => { await originalPush(...args); await nextTick(); }).finished; }; const originalReplace = router.replace.bind(router); router.replace = (...args) => { return document.startViewTransition(async () => { await originalReplace(...args); await nextTick(); }).finished; }; }

这里async回调返回一个 Promise,浏览器会等待这个 Promise 完成后再捕获新快照。await originalPush(...args)保证路由导航完成,await nextTick()保证 Vue 把新页面 DOM 更新到视图上,两步都齐了再进入动画捕获。

实际项目里我还会在router.afterEach里根据to.meta判断当前页面是否需要动画,不需要的直接跳过包装,这样登录页、错误页这类场景可以保持瞬时切换,避免动画过度。

还有一个和权限相关的点:SPA 项目开发中,接口鉴权流程(JWT 验证码那套东西)通常发生在路由的前置守卫里,如果路由跳转被守卫拦截到登录页,那么包装后的动画也会执行一次“切到登录页”的过渡。为了避免误导用户,我建议在守卫拦截逻辑里提前判断,拦截场景不触发动画,只有真正到达目标页时才进入过渡流程。

4.2 与异步数据加载配合(含鉴权请求的坑)

SPA 页面切换时,详情页的数据往往需要从接口请求。如果直接切过去,新快照捕获的是 loading 态的空页面,过渡结束、数据加载完又突然弹出内容,视觉上反而更突兀。

我的做法是“先拿数据,再触发过渡”。也就是在点击跳转时先请求接口,数据到位后再启动startViewTransition

async function goToDetail(id) { // 先预取详情数据,带上必要的鉴权信息(JWT 等) const detail = await fetchDetail(id, { token: authStore.token }); if (!document.startViewTransition) { router.push({ name: 'detail', params: { id }, state: { detail } }); return; } document.startViewTransition(async () => { await router.push({ name: 'detail', params: { id }, state: { detail } }); await nextTick(); }); }

这个方案的好处是动画启动时新页面已经拿到完整数据,过渡完成后直接看到最终内容。但要注意,预取期间用户可能又点击了其他项目,出现竞态,后发请求先返回、先发请求后返回,最终展示的数据可能不是用户最后一次点击的目标。

解决办法是在预取前生成一个自增序列号,每次请求都携带当前序列号,返回时只有序列号最新的才允许触发过渡:

let requestSeq = 0; async function goToDetail(id) { const currentSeq = ++requestSeq; const detail = await fetchDetail(id, { token: authStore.token }); if (currentSeq !== requestSeq) return; // 不是最后一次请求,丢弃 // ... 后续触发动画 }

另外,如果请求失败,我建议不要启动动画,直接留在原页面弹提示,避免用户看到一次“切到一半又弹回来”的奇怪体验。这种降级策略对整体观感影响很大。

5. 常见问题与排查技巧实录

5.1 为什么动画就是不触发

最常见的几个原因,按出现频率排序:

  • 浏览器不支持startViewTransition。目前 Chrome/Edge 较新版本、Safari 18+ 已经支持,Firefox 的支持度还在路上。代码里没有判断document.startViewTransition是否存在就直接调用,轻则控制台报错,重则整个路由切换都失败。稳妥做法是必须做特性检测和降级。
  • 回调里没有同步更新 DOM。React 项目里尤其容易踩:直接在startViewTransition(() => setState(...))里写,以为自己传了函数就够了,实际上 setState 是异步的,浏览器捕获新快照时 DOM 可能还没变。解决方案就是前面说的flushSync
  • 回调还没执行完,页面就被外部操作打断,比如弹窗关闭、导航取消等。这种情况需要自己在代码里加状态标记,避免在页面已经卸载时报错。
  • 页面同时有多个startViewTransition在排队。浏览器默认会等到前一个过渡结束再执行下一个,如果连续点击不同入口,动画会被合并或者跳过,看起来就像“没触发”。

排查这类问题,最快的办法是在startViewTransition返回的readyupdateCallbackDonefinished三个 Promise 上分别挂.then()打印日志,看到底卡在哪一步:

const transition = document.startViewTransition(() => { flushSync(() => navigate('/next')); }); transition.ready.then(() => console.log('旧快照已捕获')); transition.updateCallbackDone.then(() => console.log('DOM 已更新')); transition.finished.then(() => console.log('动画播放完成'));

这个日志习惯帮我解决了不少“以为触发了但实际上没触发”的问题。

5.2 过渡结束瞬间出现的闪烁/白屏

常见表现是动画播放完成后,新页面“闪”了一下再稳定。这个闪烁通常来自几个方向:

  • 图片等媒体资源在新快照捕获时还没加载完成,快照里是空白占位,等资源加载完又显示出来,产生明显跳变。解决方案:关键图片提前预加载,用new Image()预热,或者对列表页的图片设置loading="eager"
  • 新页面里有字体切换导致文字宽度变化,快照和最终布局不一致。可在启动过渡前用document.fonts.ready等字体加载完成。
  • 过渡结束时,浏览器把新快照从动画层“提交”到真实页面,如果某些元素带动画属性且填充模式不对,也会出现一瞬间的视觉回跳。把animation-fill-mode设置为both能缓解大部分问题。

白屏则多半是::view-transition根容器没有背景色。如果body的背景是透明的,动画开始时旧页面快照一隐藏,底层露出浏览器默认的白色或深色;当新旧页面背景都是浅色时这条不明显,一旦新页面是深色主题,白底闪一下特别刺眼。所以我会给过渡根容器设置一个和页面主题匹配的底色。

5.3 兼容性降级与减少动画偏好

第三方浏览器或旧版本浏览器不支持这个 API 时,页面功能不能被动画绑架。我习惯抽一个统一封装,所有调用走同一条入口:

export function startViewTransition(callback) { if (document.startViewTransition) { return document.startViewTransition(callback); } callback(); return Promise.resolve(); }

把兼容判断收敛在一个函数里,整个项目不用到处写分支。

对于用户开启了“减少动态效果”偏好(prefers-reduced-motion)的场景,也应该主动关闭过渡动画。做法很简单:

@media (prefers-reduced-motion: reduce) { ::view-transition-old(root), ::view-transition-new(root) { animation: none; } }

同时 JS 侧也不需要再启动过渡了,检测到用户偏好后直接用降级路径:

const prefersReducedMotion = window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches; if (!prefersReducedMotion && document.startViewTransition) { document.startViewTransition(callback); } else { callback(); }

这个偏好检测不仅是无障碍要求,也直接影响动画相关代码的结构,放在早期封装里最省事。

5.4 性能踩坑与前进后退的处理

页面很大的项目里,整个页面参与快照过渡会产生很大的合成层,低端手机上动画掉帧非常明显。我的经验是把参与过渡的区域尽量缩小。

比如只在内容区做动画,侧边栏、顶栏这些固定元素通过设置view-transition-name: none排除在过渡之外,或者干脆让它们不参与根组动画。这样快照合成的面积小,动画帧率能明显提升。

另一个和路由强相关的问题是浏览器前进后退。用户点浏览器的返回按钮时,SPA 框架内部是通过popstate事件感知的,绕过了我们包装好的router.push,动画自然不会被触发。如果你希望前进/后退也有动画,需要额外监听popstate,但要判断方向比较麻烦。

我的做法是:点击场景统一走包装方法,能拿到明确目标路由,触发动画;浏览器原生后退则不做特殊处理,直接交给浏览器默认行为。这样至少保证了主要交互路径的体验,又不会在系统级返回时出现方向错误。

性能方面,实测下来用transformopacity做动画最稳,尽量避免在过渡动画里使用filterbackdrop-filter,后者在低端设备上很容易掉到 20fps 以下。能少给元素加动画就少加,快照过渡的好处是它本身就在合成器层面工作,你只要别在动画帧里触发大量布局计算就行。

6. 最后说几点个人体会

在真正用到 View Transitions API 之前,我一度觉得页面切换动画是个“锦上添花”的事情,优先级不高。但经过几个项目实践,我更倾向于把它看成“体验基础设施”的一部分。一个跳转明确、过渡自然的 SPA,确实会让用户觉得应用更“顺滑”、更接近原生客户端,这种感官差异往往比某个按钮的微交互更容易被普通用户感知到。

这个 API 目前还在快速演进阶段,跨浏览器支持度还不是百分百完美。我个人的建议是:优先做兼容降级和减少动画降级,用统一封装把入口收敛好,然后把动画当作渐进增强去对待。支持的浏览器体验拉满,不支持的浏览器功能不受影响。

最后分享一个小技巧:把startViewTransition封装成完全不影响业务代码的工具函数,接入成本会降到极低。后面项目规模大了、页面多了,再想统一调整动画策略,只需要改一个文件里的一小段逻辑就够了。这比我最初把动画逻辑散落在每个路由跳转里省心太多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询