
先说一个我最近的真实经历公司后台是个典型的 SPA列表页跳详情页、权限页切设置页组件切换一气呵成但页面是“啪”地一下就换了没有任何过渡。业务方提了几次“能不能像原生 App 那样平滑一点”我一开始还觉得是小事结果越改越复杂。用 Vue Router 的transition包一下路由嵌套一深状态就乱用 Framer Motion / React Transition Group又觉得为一个切换效果引一堆依赖划不来。后来我把目标锁定在浏览器原生能力上——View Transitions API。用完之后整个项目的页面切换动画基本可以“零依赖”解决旧的淡出、新的淡入、局部元素单独过渡全都交给浏览器去算。这篇文章我就把我从认识这个 API 到把它落地到 Vue 和 React 两个项目里的完整过程整理出来包括底层原理、可复现的代码、以及我在真实项目里踩过的几个坑。1. SPA 页面切换动画的滑铁卢为什么以前的方案都那么拧巴1.1 SPA 的天然缺陷路由切换是“瞬时替换”不是“场景切换”SPA 之所以流畅是因为所有页面都在同一个文档里路由变化只是增删 DOM。但这个特性同时也是动画的噩梦你没有任何“旧画面”可以拿来过渡。Vue 的transition组件看起来能解决它会把离开的元素保留一段时间让你画动画。但一旦遇到异步组件、嵌套路由、动态参数变化保留的“旧 DOM”和刚进来的“新 DOM”就很容易打架比如旧列表还没卸载新列表已经挂载两张列表同时抢滚动条。我在实际中感受到的痛点主要有三个一是动画的进入/离开状态必须手动配对页面一多光leave-active-class和enter-active-class就够写一屏二是每个页面都得包一层transition子路由的动画继承关系非常绕三是像“旧页面左移、新页面右滑”这种在移动端 App 里很常见的转场靠 CSS transition 写不仅费劲还要手动处理 3D 变换的层级关系。1.2 第三方动画库的真实体验不是不够好是太重了我也试过在主项目里接 Framer Motion 或者给 Vue 项目引入 GSAP。平滑是真的平滑但代价很快浮现包体积立刻涨性能预算本来就紧。动画库只负责“动”SPA 路由切换时旧页面残留、新页面未渲染完毕的时序问题还是得自己协调。团队协作时新人很难理解“为什么这个动画要写在onExitComplete里”。如果是做营销页动画库无可厚非。但对我来说为一个后台管理系统所有路由的“基础转场动画”引入一个动画框架性价比太低了。1.3 View Transitions API 登场它一口气解决了三件事View Transitions API 最初被设计出来是给 MPA多页应用做页面过渡用的但后来同文档same-document模式也可以用在 SPA 里。它最核心的思路是你把 DOM 更新的活儿交给它它负责在 DOM 改变前截一张旧状态的“快照”等 DOM 更新完再截一张“快照”然后让这两张快照之间播放动画。这对 SPA 开发者意味着什么意味着你不再需要给每个页面维护离开/进入状态不需要把旧组件多留几百毫秒只需要在路由切换时调用一个 API。浏览器原生帮你完成“旧画面存在、新画面替换、旧新之间过渡”的完整闭环。这也是我最终决定在项目里大规模使用它的主要原因。2. View Transitions API 的运转机制一次快照、一次动画2.1 核心 API 就一个document.startViewTransitionSPA 场景下你实际用到的核心方法只有一个const transition document.startViewTransition(async () { // 这里的回调负责更新 DOM // 通常可以调用 router.push 或触发 React 状态更新 });用法本身看着很简单但这里有几个关键点必须理解这个回调必须返回一个 Promise浏览器会等这个 Promise resolve 之后再去捕获“新状态”的快照。捕获旧快照发生在调用startViewTransition的同一帧所以你得在 DOM 还没改变之前调用它。返回的transition对象上有三个 PromiseupdateCallbackDone表示 DOM 更新是否完成。ready旧新快照都准备好动画即将开始。finished动画走完。以我的使用习惯绝大多数场景只需要关心finished是否 resolve用来处理一些“动画结束后才能做”的操作比如移除全局 loading 状态。2.2 浏览器内部发生了什么伪元素树与默认动画document.startViewTransition被调用后浏览器会在页面最上面生成一棵临时的::view-transition伪元素树结构大致这样::view-transition └── ::view-transition-group(root) └── ::view-transition-image-pair(root) ├── ::view-transition-old(root) // 旧状态快照 └── ::view-transition-new(root) // 新状态快照默认情况下old和new之间会做一个 250ms 左右的交叉淡化。这就是为什么你调用startViewTransition之后什么都不用写页面切换就已经有动画了。真正让我觉得“这东西能干活”的点在于这棵伪元素树可以通过 CSS 单独选择所以你可以针对old和new分别定义 keyframes实现完全自定义的转场效果。同时你还可以给页面里的某个元素设置view-transition-name让它在整个页面快照里单独“拆”出来享受独立的过渡动画。这意味着从一个列表详情页跳转时你完全可以让列表里那张被点击的卡片“飞”到详情页头部——类似 iOS 的 App 内转场而且整个过程几乎不用写 JS。2.3 这是同文档模式不是跨文档模式这里有必要强调一个概念避免新手看文档时被绕晕Cross-document view transitions用于 MPA 中两个独立 HTML 文档之间的导航需要给文档加meta nameview-transition contentsame-origin /浏览器才能在同源跳转时捕捉新旧页面快照。Same-document view transitions用于同一个文档内的 DOM 更新对应的就是document.startViewTransition。SPA 说到底还是“同一个文档”所以你的落点一定是document.startViewTransition。不过如果你未来有小程序往 H5 跳、或者纯 MPA 项目想加整页过渡可以去研究 cross-document 模式放在 SPA 里把document.startViewTransition理解透就够了。3. 实战Vue 3 Vue Router 的转场动画最小落地3.1 拦截路由导航的接入点在 beforeEach 里接管Vue Router 的导航过程是在“路由确认”到“DOM 更新”之间有一个空隙最适合调用startViewTransition的位置是全局前置守卫beforeEach。我最初写的版本是router.beforeEach((to, from, next) { if (!document.startViewTransition) { next(); return; } // 取消当前导航手动接管 next(false); document.startViewTransition(async () { await router.push(to); await nextTick(); }); });需要说明一下为什么必须next(false)然后再手动router.push(to)因为startViewTransition的回调必须在“旧 DOM 还在”的时候开始执行而 Vue 的导航一旦放行DOM 会很快更新你无法保证旧快照是在更新前捕捉的。所以正路是阻止默认导航让回调里手动触发第二次导航这样旧快照捕捉时页面还没变化新快照捕捉时 Vue 已经把新页面渲染完了。如果你觉得在项目里到处写这套逻辑很乱也可以把它封装成一个很小的工具函数export function withViewTransition(navigation) { if (!document.startViewTransition) { return navigation(); } return document.startViewTransition(navigation); }然后路由守卫里直接调用。3.2 懒加载与 nextTick等新页面真正渲染完Vue Router 里的动态组件通常长这样{ path: /detail/:id, component: () import(../views/Detail.vue) }这里的坑在于router.push(to)返回的 Promise resolve 只代表“路由确认”了并不代表Detail.vue已经挂载。如果你在startViewTransition回调里直接await router.push(to)就返回浏览器可能拿到的是一个什么都没渲染的空白新快照动画效果就变成“旧页面淡出空白页淡入”。所以我的做法分两步document.startViewTransition(async () { await router.push(to); await nextTick(); });先等路由导航完成再等一个 Vue 的nextTick确保当前 tick 的 DOM 更新已经被提交。确实有极端情况下nextTick还不够比如异步组件的Suspense场景那就在最外层再包一层await new Promise(r requestAnimationFrame(() r()))虽然丑但实测能覆盖大多数“组件还在异步加载”的情况。3.3 自定义转场动效覆盖默认淡入淡出默认的交叉淡化虽然能用但看久了会觉得很“模板化”。我在后台项目里给路由切换定义了一套偏移动端的动效旧页面略微左移并淡出新页面从右侧推进来。CSS 部分长这样/* 旧页面向左退出 */ ::view-transition-old(root) { animation: vt-old-slide-out 0.28s ease both; } /* 新页面从右侧推入 */ ::view-transition-new(root) { animation: vt-new-slide-in 0.28s ease both; } keyframes vt-old-slide-out { to { transform: translateX(-12%); opacity: 0.6; } } keyframes vt-new-slide-in { from { transform: translateX(8%); opacity: 0.8; } }切换之后视觉上接近原生 App 的 push 效果用户感知很明确。而且这套 CSS 是全局的不用在每一个路由组件里重复声明。等你在项目里跑通一次会发现“给所有页面加转场动画”这件事的工作量比原来想象的小了一个数量级。4. React 侧封装一个可复用的 ViewTransition 工具4.1 React 的“渲染异步”问题比 Vue 更明显React 遇到这个 API 时有个天然的麻烦startViewTransition的回调要求你同步触发 DOM 更新或者至少让 DOM 更新在回调返回的 Promise resolve 前完成。但 React 的setState是异步的普通调用navigate(/about)不会立刻反映到 DOM 上新快照大概率会捕获一个还没渲染完的页面。React Router 6.11 之后给数据路由加了viewTransition相关能力可以用Link viewTransition或在navigate时传{ viewTransition: true }。如果项目已经升级到位这是最省事的路径。但现实中很多项目并没有启用数据路由或者升级成本很高。这时候自己封装一个工具函数是更可控的方案import { flushSync } from react-dom; export function startViewTransition(update: () void): void { if (!document.startViewTransition) { update(); return; } document.startViewTransition(() { flushSync(update); }); }flushSync的作用是强制 React 在回调内部同步提交更新。这样浏览器在回调结束并捕获新快照时DOM 已经是目标状态了。这个模式尤其适合给页面上某个大型组件做局部状态切换比如日历视图从月视图切到周视图。4.2 封装一个 usePageTransition hook如果你不想让业务组件感知太多动画细节可以把它收到一个自定义 hook 里import { useCallback } from react; import { useNavigate } from react-router-dom; import { startViewTransition } from ../utils/viewTransition; export function usePageTransition() { const navigate useNavigate(); const navigateWithTransition useCallback( (to: string) { startViewTransition(() { navigate(to); }); }, [navigate] ); return { navigateWithTransition }; }然后列表页里点击跳转时const { navigateWithTransition } usePageTransition(); const handleClick (id: string) { navigateWithTransition(/detail/${id}); };这种做法对兄弟组件零侵入你只在少数的跳转入口去改调用方式其他页面什么都不用动动画就已经生效了。4.3 处理重复点击与异常恢复页面切换动画期间用户连续点击两三次是非常常见的。第一次startViewTransition还没结束第二次又进来了浏览器可能就会因为“同时只允许一个 view transition”而直接忽略第二次调用或者干脆抛错。我在 hook 里加了一个简单的“进行中”标记let transitionActive false; export function startViewTransition(update: () void): void { if (!document.startViewTransition || transitionActive) { update(); return; } const transition document.startViewTransition(() { flushSync(update); }); transition.finished.finally(() { transitionActive false; }); }另外调用update如果抛出异常transition.finished会 reject。稳妥起见在业务入口可以加一层 try/catch或者在这个工具函数里把异常捕获后重新抛出避免用户卡在一个没有新页面的状态。我见过项目里因为忽略这个错误处理导致转场动画失败后整个页面消失的问题触发概率不高但一出现就是事故级体验。5. 让动效不“土”命名视图、滑动转场与主题切换5.1 view-transition-name把某个元素单独拎出来动画如果你做过 App 端的转场应该常见这种效果列表页点开一张卡片卡片本身平滑放大到详情页顶部。SPA 实现这种效果过去需要你精确计算两张图片的位置和尺寸用 transform 做“跨页面动画”代码量非常大。View Transitions API 的解决方案是把该元素标记为“命名视图”.list-card { view-transition-name: active-card; }当旧页面和新页面里都存在一个设置了相同view-transition-name的元素时浏览器不会把整页当成一个快照而是会把这两个元素单独提取出来做特殊的匹配过渡。于是你在列表页点了一张卡片详情页顶部渲染同一张图时浏览器会自动把旧位置“飞到”新位置剩下的页面内容仍然做默认淡入淡出。这种效果在新闻类 App 和电商 App 里非常出彩。但要注意view-transition-name不能重复如果在同一帧里出现两个元素用了同一个名字Native 部分不会排队等着而是直接导致本次转场失败。在列表循环里给每个元素都动态赋名时尤其要小心很容易踩到后面第 6 章我会细说。5.2 实现“进入退出”分向的滑动转场很多团队喜欢让“前进”和“后退”使用相反方向的滑动以贴近 App 的导航模型。做法是给根结点设置不同的view-transition-name或者根据路由层级动态加一个 class/* 前进 */ .forward ::view-transition-old(root) { animation: slide-out-left 0.3s ease both; } .forward ::view-transition-new(root) { animation: slide-in-right 0.3s ease both; } /* 后退 */ .back ::view-transition-old(root) { animation: slide-out-right 0.3s ease both; } .back ::view-transition-new(root) { animation: slide-in-left 0.3s ease both; }在路由守卫里判断to.meta.index from.meta.index就知道是前进还是后退把对应 class 临时加到document.documentElement上。动画结束后移除 class这样下一次切换不会受上一次方向污染。一个细节class 加在html上或者在startViewTransition之前加都可以但最好在startViewTransition之前加好避免动画过程中 class 变化影响了快照的捕获。5.3 同一个 API 还能做暗色模式切换View Transitions API 不止能用在做路由跳转暗色模式切换也是一等一的使用场景。因为主题切换本质上也是一次“旧 DOM → 新 DOM”的变化。代码特别简单const transition document.startViewTransition(() { document.documentElement.classList.toggle(dark); });然后配合一段 CSS::view-transition-old(root), ::view-transition-new(root) { animation-duration: 0.4s; } html.dark::view-transition-new(root) { animation-name: dark-fade-in; }实测下来暗色模式切换的平滑感比直接用纯 CSStransition处理颜色还要自然因为浏览器的快照机制天然地解决了“所有元素颜色变化节奏不一致”的问题。你甚至可以给 Logo 单独设置一个view-transition-name让它在主题切换时做一个更明显的独立过渡。6. 我踩过的坑与性能边界6.1 闪白与闪烁伪元素动画被全局样式干扰我在一个项目里遇到过很诡异的闪白切换路由时旧页面正常淡出新页面在淡入的瞬间先闪一下白然后才显示内容。排查了半天最后发现是全局 CSS 里给所有元素加了一条 reset* { transition: all 0.3s ease; }这条规则把::view-transition-*这些伪元素也覆盖了导致快照元素在浏览器默认动画之外又开始执行自己的 transition两个动画互相叠加就出现了闪白。后来我把全局 reset 改得更克制并给伪元素显式加了保护::view-transition-old(root), ::view-transition-new(root) { animation-duration: 0.25s; animation-timing-function: ease; mix-blend-mode: normal; }如果你在项目里遇到了类似问题优先查两件事一是全局* { transition: ... }二是有没有给 body 或根元素设置overflow: hidden、filter、backdrop-filter这类属性它们会把快照渲染搅乱。6.2 view-transition-name 重复会让转场静默失败前面提到view-transition-name不能重复但真实项目里比我想象中更容易踩中。一次是在列表页给每个卡片都设置了view-transition-name: card-${id}切换到详情页时详情页里也有一个卡片叫card-that-id理论上没问题。但用户如果快速点了两次旧的 DOM 还没完全卸载两个card-${id}同时存在第二次点击的 transition 就直接失败了。解决方案是给当前点击的卡片单独一个固定的命名currentCardRef.current.style.viewTransitionName active-card;只对当前点击的那个元素赋名其他卡片全部保持默认这样就不会撞名了。还有一点设置view-transition-name的元素不能是display: none或者被父级overflow: hidden裁剪得边距为 0否则快照可能是空白。我在轮播图里的一个浮层上踩过最后把容器裁剪去掉才正常。6.3 懒加载 chunk 太大导致动画卡顿SPA 的页面基本都是按路由拆 chunk用户从首页切到详情页时如果详情页的 chunk 还是 200KBstartViewTransition回调里的 Promise 会等比较久。这段时间里旧页面快照一直在屏幕上用户会以为页面卡死了。我处理的方式有两个对高频页面做 prefetch在浏览器空闲时提前加载目标路由的 chunk。给startViewTransition的等待过程加一个最长时间限制超过 400ms 就直接skipTransition()保证用户体验始终是“有反馈的”。代码大致是const timer setTimeout(() transition?.skipTransition(), 400); transition.finished.finally(() clearTimeout(timer));skipTransition()会立即结束动画但不会影响已经完成的 DOM 更新。这个兜底逻辑很重要我在低端安卓 WebView 上实测过动画卡住一帧一帧跳的情况在超大 chunk 场景下非常严重。6.4 什么时候真的不要加动画不是所有页面都适合加转场动画。我把这类“不要加”的场景总结成一张表也是我实际项目里的边界判断依据场景原因建议数据实时更新的 dashboard旧快照冻结后页面动态数据在切换瞬间是“假的”不加或只做极短的 100ms 淡出包含大视频、Canvas 的页面快照无法捕捉播放中的内容视觉断裂明显单独禁用转场无限滚动列表页面新页面如果是另一个列表滚动位置错乱会很怪先重置 scroll 再切换动画低端机大量 DOM 节点页面快照捕获本身耗性能容易掉帧可以降级为“只淡入不淡出”我的判断标准只有一个当动画的“存在感”大于页面本身的“信息传递”时这个动画就该被拿掉。转场动画应该让用户在切换上下文时感到自然而不是让用户每次跳转都停下来欣赏一次。在实际运维中我一般会在根组件里根据路由 meta 控制某个页面是否启用转场if (to.meta.disableTransition) { next(); return; }这个开关看着不起眼但在压测和线上反馈时非常有用能让你针对性地关闭问题页面的动画而不是把整站转场一起撤掉。最后再分享一个小技巧如果你在 Chrome 开发者工具里想看当前页面是否成功创建了 view transition可以打开 Rendering 面板勾选 “Highlight view transitions” 选项。刷新一次页面被快照的区域会被描边出来排查“为什么某个元素没有单独过渡”时特别直观。我自己跑完这套之后最大的体会是View Transitions API 不是来解决“动画怎么做”的而是来解决“动画和 DOM 更新怎么对齐”的。过去我们所有转场方案的核心难点都在于自己维护“旧状态→新状态”的时序。既然浏览器愿意把这件脏活揽过去我们就应该先把旧方案放一放优先把它吃透。