
最近在给手上的 SPA 项目做页面切换动画优化调研了一圈才发现浏览器原生已经出了一套叫 View Transitions API 的方案可以直接接管 DOM 更新过程的动画过渡。这篇文章就是我在实际项目里从了解到落地再到处理各种坑的一份完整记录。如果你也正被 SPA 路由切换时的空白闪烁、生硬跳转困扰或者想找一个比手写 CSS 动画、引入第三方动画库更轻量的方案这篇内容应该能帮你省下不少时间。我会从原理讲起把 Vue Router 和 React Router 两种接入方式都过一遍后面再附上我实测过的自定义动画和排查经验。1. 为什么 SPA 的页面切换始终绕不开动画问题1.1 白屏闪烁与生硬跳转SPA 路由切换的真实体验SPA 应用本质上只有一个 HTML 页面路由切换是在同一个文档里替换视图。听起来很顺畅但实际体验往往不太好。页面组件要异步加载、重新渲染、执行生命周期这个过程中用户看到的是先出现短暂空白再突然跳出一整个新页面。尤其在大屏数据看板、后台管理系统这类信息密度高的场景里生硬跳转会让用户觉得卡了一下甚至分不清自己是刷新了页面还是切换了页面。我最早处理这个问题用的是最原始的做法给每个路由组件包一层淡入动画进入时 opacity 从 0 到 1。但这样做问题很明显旧页面瞬间消失新页面才开始淡入视觉上还是断层。后来换成 Vue 的transition modeout-in其实也是同样思路只是把离开和进入动画串行执行。真正让我觉得 SPA 切换缺的不是动画而是连贯性的是我第一次看到 View Transitions API 的演示——旧页面平滑移出新页面同时滑入两块内容在切换过程中是重叠、连贯的不是先结束再开始。1.2 传统方案为何不够干脆传统 SPA 动画方案大致分三类框架自带的过渡组件、CSS 动画配合路由钩子、以及第三方动画库。框架过渡组件确实开箱即用但在复杂场景下要处理多路由嵌套、keep-alive 缓存、异步组件加载时序配置会变得很重。CSS 动画配合路由钩子的方式更灵活但核心难点在于新旧页面同时存在这件事需要你手动把两个页面组件临时塞进同一个容器再控制各自的动画状态实现成本不低。第三方动画库比如 GSAP、Framer Motion能做出很华丽的动效但为了一个页面切换引入一整套动画引擎在后台项目里通常是不划算的。而且这些方案在 SPA 里都有同一个理论短板它们是对两个独立 DOM 节点做动画并没有真正解决浏览器在样式计算、布局、绘制层面对一次性大面积 DOM 替换的感知。页面切换本质上是文档状态的变化动画库只是在这个状态变化的边缘做手脚而 View Transitions API 是让浏览器自己接管从旧状态到新状态的视觉过渡这个思路从根本上不一样。1.3 View Transitions API 到底改进了什么View Transitions API 最核心的价值是它提供了一种声明式的状态过渡能力。你只需要告诉浏览器我准备更新 DOM 了请帮我做一个过渡。浏览器会在 DOM 更新前自动截取当前页面的快照DOM 更新完成后再截取新页面的快照然后在这两张快照之间生成一组伪元素并运行动画。整个过程不需要你手动维护新旧节点的位置和层级也不影响 DOM 的正常更新逻辑。对 SPA 来说这意味着路由切换终于有了一套原生方案不需要为动效去改变路由的组织方式也不需要为了让新旧页面共存而做额外 DOM 操作。代码层面只需要在路由更新外层包一个document.startViewTransition剩下的交还给浏览器。除此之外你还可以通过view-transition-name给指定元素命名让某个局部元素单独参与过渡动画比如列表页的卡片飞入详情页这种联动效果这在以前需要大量坐标计算和绝对定位处理现在几行 CSS 就能实现。2. 先搞懂 View Transitions API 的核心模型2.1 startViewTransition 与 promise 时序同文档过渡的入口是一个方法document.startViewTransition(callback)。调用之后它会立即截取当前页面的快照然后执行 callback。callback 里应包含所有会导致 DOM 变化的任务并且要返回一个 Promise浏览器会等这个 Promise resolve 之后再截取新快照并开始播放过渡。这里有个细节值得注意很多人第一次用会把顺序搞反。我见过有同事写成这样先做了 DOM 更新再调用startViewTransition结果动画根本不生效因为调用时页面已经是新状态了旧快照根本没抓到。正确的用法一定是先启动过渡再在回调里更新 DOMconst transition document.startViewTransition(async () { await updateTheDOMSomehow(); }); await transition.ready;startViewTransition返回一个transition对象上面有三个主要 Promise理解它们的触发时机非常关键ready新旧快照都准备好过渡动画即将开始适合在这里做与动画并行的事情比如滚动位置恢复。updateCallbackDonecallback 里的 DOM 更新 Promise resolve 时触发表示 DOM 已经是新状态。finished整个过渡动画播放完毕适合在这里做动画后的清理工作比如移除临时状态类名。这三个 Promise 是踩坑重灾区。尤其容易忽略的是ready和finished的区别一个是动画开始前的信号一个是动画结束后的信号。如果你要恢复滚动位置放在ready之后会比放在finished之后视觉上更顺滑因为滚动已经恢复动画才刚开始播放用户看到的是一张完整的新页面在滑动而不是先看到滚动到底部的旧状态快照。2.2 浏览器背后的快照与伪元素原理View Transitions 会在你的页面之上动态生成一组伪元素结构大致是这样::view-transition └── ::view-transition-group(root) └── ::view-transition-image-pair(root) ├── ::view-transition-old(root) └── ::view-transition-new(root)::view-transition是根容器默认覆盖在整个视口上。::view-transition-group对应参与过渡的视图块默认名为 root也就是整个页面。::view-transition-old是旧状态快照::view-transition-new是新状态快照两者叠在同一个 image-pair 容器里。你通过 CSS 修改这些伪元素的animation属性就能控制过渡动画的具体表现。在过渡期间旧快照会一直保留在页面上方新快照则根据动画逐渐覆盖它。这也是 View Transitions 与普通元素动画的本质差异它不是把两个 DOM 节点放到一起做运动而是对浏览器截取的位图快照做动画。这种做的最大好处是动画过程非常流畅因为快照本身就是一张图浏览器只需要做图层合成不需要反复经过布局和绘制流程。但反过来说因为操作的是位图快照你无法直接给快照内部的具体文字、图片做独立动画。想实现文字逐个飞入这种效果需要通过命名视图的方式把具体元素提升为独立的 view-transition-group。下一节我会详细说。2.3 给元素取个名字view-transition-name 让你能做更精细的联动如果你想做列表页点卡片卡片飞到详情页顶部这种联动动画只对 root 做整体切换是不够的需要给这个卡片设置一个名字.card-item { view-transition-name: card; }设置之后浏览器在过渡期间会为这个元素单独生成一组::view-transition-group(card)伪元素并自动计算从旧位置到新位置的位移和缩放。你甚至可以只让某个卡片做动画页面其余部分不做整体过渡只要不把 root 的伪元素动画写得太花哨即可。命名视图还有个用途就是解决 SPA 切页面时我不想让整个页面都动只让内容区动导航栏保持固定的诉求。给导航栏一个名字并把它放在根容器之外的特殊处理就能实现局部过渡。这里有个小坑需要特别提醒同一个view-transition-name在同一时刻不能有两个元素同时使用。SPA 路由切换时如果旧页面和新页面里都有同名的元素比如两边都叫 card浏览器会直接跳过该视图的动画或报异常。处理办法是在切换前用 JS 把不需要入场的命名视图临时清掉或者给旧页面和新页面的对应元素起不同的名字并在动画中途切换这个我会在第 4 节里细讲。2.4 兼容性现状与降级思路截至写这篇文章时Chrome 和 Edge 已经在较新版本中完整支持同文档 View TransitionsChrome 111 起可用。Safari 和 Firefox 的支持进度相对滞后Safari 18 在跨文档过渡上有了进展但同文档的支持仍不完整。跨文档即 MPA 页面跳转的版本则通过 CSS 的view-transition { navigation: auto; }开启目前也是 Chrome 系支持较好。所以接进项目之前一定要做能力检测。好消息是降级成本非常低因为它本质上只是一个渐进增强function navigateWithTransition(updateFn) { if (document.startViewTransition) { return document.startViewTransition(updateFn); } return updateFn(); }这样在不支持的环境里页面就是普通的路由跳转没有任何副作用。我一直建议团队采用这种策略动画是加分项不是刚需别让动画逻辑阻塞正常的页面访问。需要提醒的是document.startViewTransition这个 API 名称本身可能在未来继续变化但目前主流用法已经稳定可以放心用。3. 在 SPA 路由切换里实战接入Vue / React 双示例3.1 接入前的准备包装路由操作的统一入口在 SPA 里接入 View Transitions最关键的不是会写 CSS 动画而是找到一个能统一拦截路由操作的入口。很多人的第一反应是在路由守卫里调用startViewTransition但实际做下来会发现时序很难控制因为守卫和组件渲染是分离的守卫里触发跳转以后你无法确切知道新页面组件什么时候渲染完成。我更推荐的做法是封装一个统一的导航函数所有路由跳转都走这个函数在函数里包一层 View Transition。这样做有两个好处一是逻辑收敛后续想加方向判断、滚动恢复、异常兜底都在一个地方处理二是方便在测试环境里直接关闭动画进行 A/B 对比。如果你觉得全项目替换跳转调用成本太高也可以从登录跳转、从列表到详情这类高频且关键的路径先开始验证效果后再逐步铺开。3.2 Vue Router 接入在跳转前启动过渡以 Vue Router 为例我项目里的封装大概是这样的import router from /router; import { nextTick } from vue; async function navigateWithTransition(to) { if (!document.startViewTransition) { await router.push(to); return; } const transition document.startViewTransition(async () { await router.push(to); await nextTick(); }); await transition.ready; window.scrollTo(0, 0); }这里有两个细节当时踩了坑。第一router.push返回的 Promise 在导航确认后就会 resolve但此时新页面的 DOM 不一定已经渲染完成所以必须再await nextTick()确保 DOM 更新已经反映到页面上否则浏览器截到的新快照可能还是一半旧的。第二滚动位置恢复要放在transition.ready之后而不是finished之后。如果放在finished后面等整个动画播完再恢复滚动用户会在动画过程中看到旧滚动位置的残留体验非常割裂。如果是用到beforeEach全局守卫的项目也还是建议以这种封装为主不要在守卫内部直接调用startViewTransition。守卫的职责是权限校验、动态标题、进度条开关之类的事页面动画放在导航完成后和 DOM 更新绑定在一起处理思路会清晰很多。3.3 React Router 接入在 layout 层处理React Router 的接入思路类似但因为没有nextTick这种响应式机制需要换一种方式获取新页面已渲染的信号。在函数组件里比较实用的是在路由出口处配合useLayoutEffect来处理import { useLayoutEffect } from react; import { useLocation, useNavigate } from react-router-dom; function AppLayout() { const location useLocation(); const navigate useNavigate(); useLayoutEffect(() { if (!document.startViewTransition) return; document.startViewTransition(async () { // 这里通过 React 的渲染机制state 更新后组件已经重新渲染 await new Promise((resolve) requestAnimationFrame(() resolve())); }); }, [location.pathname]); // ... }严格来说React 的并发渲染机制会让手动控制 DOM 更新完成时机变得不是那么直观。更省心的方式是用 React Router 官方在 v6.22 之后提供的unstable_viewTransition选项它本质上是把 View Transitions API 集成进了路由导航流程。使用方式很直接Link to/detail/1 unstable_viewTransition 查看详情 /Link如果你用的是旧版本 React Router又不想大规模改造页面结构我建议还是回到统一封装 navigate的思路参考 Vue Router 那个版本把navigate(to)包进startViewTransition里。React Router 的navigate返回的是 Promise 或者 undefined需要根据版本确认最稳妥的写法是在setTimeout(0)外层包一层 Promise。3.4 前进与后退如何根据方向做不一样的动画页面切换动画只有统一一种方向看多了会觉得单调。理想状态是模仿原生 App进入下一页时新页面从左往右推入返回上一页时新页面从右往左推入或者反直觉一点至少前进和后退的方向要相反。在 View Transitions 里实现方向差异并不难难点在于如何判断这次导航是前进还是后退。我在项目里是用一个模块级变量来标记方向的let navigationDirection forward; const originalPush router.push; router.push function (...args) { navigationDirection forward; return originalPush.apply(this, args); }; window.addEventListener(popstate, () { navigationDirection backward; });道理很简单调用router.push说明是前进触发popstate浏览器返回按钮或router.back()说明是后退。拿到方向之后在启动过渡前给html加一个临时类名CSS 里根据类名切换动画方向html.forward::view-transition-old(root) { animation: slide-out-left 0.3s ease both; } html.forward::view-transition-new(root) { animation: slide-in-right 0.3s ease both; } html.backward::view-transition-old(root) { animation: slide-out-right 0.3s ease both; } html.backward::view-transition-new(root) { animation: slide-in-left 0.3s ease both; }注意用完要清理类名否则下一次切换会被上一次的方向污染。清理时机放在transition.finished之后最安全因为整个动画期间 CSS 类名都需要保持。3.5 登录鉴权与验证码场景登录成功跳转的过渡处理说个实际场景。我们项目里有 JWT 认证和图形验证码的登录逻辑登录成功后从登录页跳转到主控台。以前这个跳转是非常生硬的输入完账号密码、验证码点击登录loading 转一圈然后整个页面瞬间变掉总感觉少了点解锁的仪式感。用 View Transitions 之后我在登录成功跳转这里做了个轻量缩放过渡视觉上像是整个界面从登录态放大到了主控台体验明显顺滑了。代码大致是这样async function loginAndEnter() { const res await loginApi({ username, password, captcha }); if (res.code ! 0) { message.error(res.msg); return; } localStorage.setItem(token, res.data.token); const transition document.startViewTransition(async () { await router.push(/dashboard); await nextTick(); }); await transition.finished; }这里有个容易忽略的细节登录成功但还没有跳转之前页面已经拿到了 token如果此时页面上某些区域因为 token 变化而重新渲染比如导航栏的用户名就会在旧快照截取前触发一次无动画的 DOM 变化。我以前就被这个问题干扰过后来调整为先跳转、再在跳转后的组件里读取 token或者干脆把 token 写入这一步放在startViewTransition的回调里保证旧快照和新快照之间的差异完全由动画覆盖。顺带一提JWT 登录后通常还有一个问题是 token 过期后重新登录的跳转。用 View Transitions 做这种被踢回登录页的过渡要特别注意别让动画结束后还残留敏感信息比如旧页面快照里可能有用户数据。稳妥做法是在启用动画前先确认当前页面没有需要清理的敏感 DOM或者直接对这类跳转禁用过渡。我是直接判断document.startViewTransition时同时判断路由是否进入白名单白名单外一律跳过动画。4. 动画效果定制与性能调优4.1 快速上手滑入滑出、缩放、淡入淡出View Transitions 的默认动画其实就是淡入淡出但如果你想让页面切换有更明显的方向感就需要覆写伪元素的动画。滑入滑出是最常见的需求核心思路是给旧快照和新快照各定义一个动画让它们朝同一个方向运动::view-transition-old(root) { animation: slide-out-left 0.3s ease both; } ::view-transition-new(root) { animation: slide-in-right 0.3s ease both; } keyframes slide-out-left { from { transform: translateX(0); } to { transform: translateX(-30%); opacity: 0.8; } } keyframes slide-in-right { from { transform: translateX(30%); } to { transform: translateX(0); } }如果想做那种从中心放大进入的效果比如我上面提到的登录跳转就把 transform 改成 scale从 0.96 放大到 1同时透明度从 0.4 变回 1。实测下来这种动画在后台管理系统里观感最稳不会让用户觉得页面被甩来甩去又能感知到你到了一个新地方。有一点要特别注意::view-transition-old和::view-transition-new的默认动画时长都是 0.25 秒覆写时一定要带上both填充模式否则动画在开始前或结束后会有一个瞬间回到默认状态的问题表现为闪一下。另外如果你只想让新页面动、旧页面静止直接把 old 的动画改成animation: none就行这比单独设置transition-duration: 0更干净。4.2 命名视图联动列表到详情的实战案例接下来是 View Transitions 最吸引人的能力命名视图联动。我拿一个经典场景举例商品列表页点击某个商品进入商品详情页希望点击的那张卡片从列表位置平滑移动到详情页头部。第一步给列表里的卡片和详情页头图设置同样的名字.product-card--active { view-transition-name: product-cover; } .product-detail-header { view-transition-name: product-cover; }这里的问题就来了同一个名字在旧页面和新页面里同时出现浏览器会认为这是同一个元素的两个状态从而自动计算位置差值生成动画。但列表页有很多卡片你不能让所有卡片都叫product-cover否则浏览器直接报错。我的做法是在启动过渡前给当前点击的那张卡片临时加上product-card--active类其他卡片不加然后在详情页里给对应头图设置同样的名字这样浏览器才能正确匹配。第二步给这个命名视图单独设置动画时长和缓动::view-transition-group(product-cover) { animation-duration: 0.45s; z-index: 100; }由于浏览器会自动计算新旧位置和缩放你几乎不需要写位移 keyframes就连图片在过渡过程中的边移动边改变圆角这类效果也可以靠给product-cover的 image-pair 设置border-radius过渡来实现。但要注意如果你在列表页和详情页给同一个名字设置了不同的border-radius浏览器在快照里体现的是各自的最终样式动画只在位移缩放上做插值不会自动插值圆角。想要圆角平滑变化一般是在 group 伪元素上写动画。4.3 性能优先级只用 transform 与 opacityView Transitions 已经帮你把新旧页面拍成了位图快照理论上动画性能不会太差但如果你在 CSS 里写了大量width、height、top、left这类会触发布局的动画性能依然会打折扣。我的经验是在所有自定义动画里只写transform和opacity这两个属性不会触发布局和绘制只会进入合成层动画保持在 60 帧基本没有问题。还有两个容易忽视的性能细节。一是命名视图的数量不要无上限增加每个view-transition-name都会生成一组伪元素命名太多会占用不必要的合成层资源通常一个页面同时做联动动画的元素控制在 3 到 5 个以内比较合理。二是如果你的页面里有大图或视频尽量在过渡完成后让浏览器有足够时间回收旧快照已经在动画里出现短暂卡顿的话可以试试给::view-transition-old(root)的z-index设一个较低值让新页面处于上层减少过渡区域的绘制压力。5. 常见问题与排查技巧实录5.1 常见问题速查表我把这段时间在项目里遇到的高频问题整理成了一个速查表方便后续排查时直接对照问题现象可能原因解决办法动画完全没触发浏览器不支持document.startViewTransition先执行能力检测不支持则直接走普通跳转过渡卡住页面半透明状态冻结callback 里返回的 Promise 一直没有 resolve检查router.push是否 await组件渲染后记得nextTick页面闪烁后直接跳转调用了startViewTransition后才更新 DOM导致旧快照没有抓到把 DOM 更新逻辑放进 callback 里不要在调用前执行同名字的多个元素报错页面里多个元素同时使用了同一个view-transition-name只给当前活跃元素命名过渡结束后及时移除类名动画方向固定不支持返回没有记录前进/后退方向用模块变量标记方向切换时给html加临时类名动画结束后滚动位置不对滚动恢复放在了finished之后改到transition.ready之后立即恢复过渡期间字体闪烁、图标错位字体图标在快照里没有正确渲染提前加载字体或在 transition 期间暂停字体切换大图页面过渡卡顿旧快照图层太大合成压力高减少命名视图数量动画属性只用 transform 和 opacity5.2 我在这几次实战中踩过的坑第一个坑是异步组件加载时序。我们项目路由全部做了懒加载开始时我把router.push(to)直接丢给startViewTransition的 callback结果在新页面首次加载时经常卡住。排查后发现router.push返回的 Promise 在路由确认后 resolve但懒加载组件和它的子组件还需要再渲染一轮此时 DOM 还是旧的。解决方法是像第 3 节里那样在router.push后面再补await nextTick()确保 Vue 的 DOM 更新流程已经走完。第二个坑是 CSS 里常见的overflow: hidden和transform父容器会影响快照截图。有一个页面因为外层容器设置了transform: translateZ(0)去触发 GPU 加速结果 View Transitions 的 root 快照被限制在那个容器的边界里动画范围明显不对看起来像整个页面被切掉了一块。排查了很久才定位到最后把触发 GPU 加速的容器往内层移了一级。如果你遇到动画区域不正确优先检查是否存在 transform、perspective、filter 这类会影响包含块的 CSS 属性。第三个坑是动画期间用户连续快速点击导航startViewTransition被触发多次上一次的过渡还没结束就开始了新的过渡。Chrome 里这会导致上一次的finishedPromise 永远不 resolve动画状态混乱。后来在封装函数里加了一个简单的节流锁过渡未结束时直接忽略新的跳转请求。但对一定要执行的跳转比如登录后跳转我用了transition.updateCallbackDone来等待上一次 DOM 更新完成再启动新的过渡。5.3 落地建议从登录跳转这种小场景开始如果你刚开始在项目里引入 View Transitions我不建议一上来就把所有路由切换都包上动画。先从登录跳转主控台、列表进详情这类路径清晰、影响面小的场景开始验证视觉效果和生产环境的稳定性等技术团队都适应了再逐步推广到全局路由。推广时务必建立降级意识。任何时候都别忘记View Transitions 应该是一个增强功能而不是核心依赖。有一次我在调试时误以为document.startViewTransition在所有浏览器下都存在结果一个同事用老版本浏览器打开页面跳转直接卡住非常影响工作。后来我们把能力检测判断提到了所有封装函数的最前面不通过就直接走原始跳转再也没出过类似问题。我在实际项目中还有一个体会动画不是越花哨越好SPA 页面切换的感觉更像用户从一个房间走到另一个房间过渡要短、要顺、要有方向感但不能让人头晕。我个人现在的推荐值是滑动 0.25 到 0.35 秒缩放 0.3 到 0.4 秒低于 0.2 秒感觉不到过渡高于 0.4 秒就会觉得拖沓。希望这些经验能帮你少走一些弯路。