
最近在给一个 SPA 项目补链路安全做 JWT 登录态和验证码校验时顺手把页面切换动画也整体重做了一遍。以前这类动效基本靠手动管理 CSS 类名或者上一个重量级的动画库工程复杂度一下就上去了。但今年再看浏览器原生已经有了一套非常优雅的方案——View Transitions API在单页应用里做页面切换动画代码量可以压缩到不可思议的程度而且观感相当顺滑。这篇文章我就以 SPA 页面切换动画为线索从 View Transitions API 的核心原理讲到 React Router、Vue Router 里的真实接法再聊几个生产环境才会踩到的细节坑。如果你正在给后台系统、内容型站点或者移动端 H5 加转场效果这篇文章应该能帮你省不少调研时间。1. 为什么 SPA 页面切换必须“动起来”1.1 无感切换才是好的交互体验SPA 的优势是页面切换不需要整页刷新路由一变组件替换整个过程快得没有中间态。但快并不等于好体验尤其是内容结构差异大的两个页面直接“啪”一下替换掉用户视线会失去焦点上一秒还在看列表下一秒突然跳到详情眼睛根本追不上信息的迁移。加上过渡动画本质上是给用户一个“视点引导”。页面切换时旧页面从哪里消失、新页面从哪里出现用户的注意力会顺着动画路径自然迁移到目标区域。这在移动端尤其明显App 里几乎所有页面切换都带转场用户已经被“训练”得默认页面切换应该有动效了Web 端如果没有就会有一种说不出的生硬感。我自己的切身体会后台管理系统一堆表格和表单页面之间来回跳纯静态切换久了会让人觉得“卡”加了简短的 fade 轻微位移过渡之后同样的操作居然感觉流畅了不少。这不是玄学而是视觉连续性能掩盖一部分真实的渲染帧消耗。1.2 传统 SPA 动画方案为什么让人头大在 View Transitions API 出现之前SPA 页面切换动画常用这么几类方案CSS 类名方案路由切换时给旧页面加exit类名新页面加enter类名配合transition属性实现淡入淡出。问题在于要精确控制两个页面同时存在的“双帧”状态处理不当就会出现新旧页面重叠、元素错位、动画未触发等一堆问题。动画库方案Framer Motion、GSAP、React Transition Group 等。功能强大但要引入额外依赖API 风格也和原生 Web 标准差异很大换个团队接手就要重新学一遍。模块间通信方案把页面容器封装成组件路由变化时通过事件或状态控制动画播放。这个方案扩展性尚可但每个页面都得写配套的生命周期逻辑代码量膨胀得很快。这些方案共同的痛点在于它们都是在“强制两个 DOM 同时存在”的前提下做动画而 SPA 路由切换的本质是“把旧 DOM 移除把新 DOM 插入”两者天然冲突。于是开发者不得不想尽办法去绕绕到最后代码里全是补丁维护成本很高。View Transitions API 把这件事从根本上简化了它不需要你同时维护新旧两个页面而是由浏览器在 DOM 更新前自动拍一张“旧状态快照”DOM 更新后再拍一张“新状态快照”然后浏览器自己完成两张快照之间的平滑过渡。开发者要做的只是说一句“开始过渡然后更新 DOM”仅此而已。2. View Transitions API 核心原理先拍快照再放动画2.1 理解 document.startViewTransitionView Transitions API 的入口很简单就是document.startViewTransition(callback)这个方法。调用方式大致是这样const transition document.startViewTransition(() { // 在这里同步更新 DOM updateTheDOMSomehow(); });这段代码干了这样几件事浏览器捕获当前页面视觉状态生成一组“旧快照”。执行传入的回调函数你在这个回调里完成路由切换、组件替换等 DOM 操作。DOM 更新完成并渲染一帧后浏览器再捕获一组“新快照”。浏览器把新旧两组快照放进一个独立的“视图过渡层”中执行默认的交叉淡入淡出动画动画结束后移除过渡层。整个过程中页面里的真实 DOM 其实只改变了一次就是回调里的那一次。这比你手动维护两个页面同时存在、再手动触发动画要干净得多也几乎不存在“动画过程中用户能选中隐藏元素”之类的边界问题。还有一点值得注意startViewTransition返回一个transition对象这个对象身上有几个 Promise 状态可以供你感知动画进度transition.ready // 新旧快照准备完成即将开始动画 transition.finished // 动画完全结束 transition.updateCallbackDone // DOM 更新回调执行完成注意不等于动画完成这三个 Promise 在真实业务里很有用后面讲路由集成时会用到。2.2 视图过渡层与伪元素结构View Transitions API 的实现里有一个关键的“视图过渡层”它在动画期间临时挂载在页面最顶层里面是一组带固定命名的伪元素::view-transition └── ::view-transition-group(root) └── ::view-transition-image-pair(root) ├── ::view-transition-old(root) └── ::view-transition-new(root)::view-transition-old(root)承载旧快照::view-transition-new(root)承载新快照浏览器默认给这两个伪元素做了淡入淡出动画。你可以把它们理解成两张截图一张是切换前的画面一张是切换后的画面浏览器把这两张截图叠在一起然后对截图本身做动画。这就是这个 API 和传统 CSS 动画最本质的区别传统动画是“对真实 DOM 做动画”而 View Transitions API 是“对快照图片做动画”。真实 DOM 在切换完成后就稳定下来了动画发生在快照层上不会影响页面布局也不会引发额外的 reflow。如果你想自定义动画只需要在 CSS 里给这些伪元素写规则比如把默认的opacity过渡改成transform位移::view-transition-old(root) { animation: slide-out 0.3s ease forwards; } ::view-transition-new(root) { animation: slide-in 0.3s ease forwards; } keyframes slide-out { to { transform: translateX(-30%); opacity: 0; } } keyframes slide-in { from { transform: translateX(30%); opacity: 0; } }更妙的是你还可以给 DOM 元素指定view-transition-name让它参与独立的过渡容器而不是和整个页面一起走默认的root过渡。这一点在做“共享元素过渡”时特别好用比如从列表页的卡片点击进入详情页时让卡片图标的缩略图平滑放大成详情页的大图观感非常接近原生 App。3. SPA 路由集成实战React Router 和 Vue Router 都怎么接3.1 React Router v6 集成方案React Router 里接入 View Transitions API 的核心思路是拦截路由跳转动作在跳转时用startViewTransition包裹状态更新。先看一个最直接的接入方式。假设你用useNavigate做页面跳转可以这样改造import { useNavigate } from react-router-dom; function useTransitionNavigate() { const navigate useNavigate(); return (to, options) { // 如果不支持就退回普通跳转 if (!document.startViewTransition) { navigate(to, options); return; } document.startViewTransition(() { navigate(to, options); }); }; }这个方案能跑但有一个隐患navigate触发的是 React 的状态更新而这个更新是异步批处理的startViewTransition的回调执行完时React 可能还没把新页面 DOM 渲染出来浏览器捕获的新快照会拿到过渡期的“中间帧”动画表现就不对了。更稳妥的做法是把“DOM 更新完成”的信号显式交给startViewTransition。这里可以利用 React 18 的flushSyncimport { flushSync } from react-dom; import { useNavigate } from react-router-dom; function useTransitionNavigate() { const navigate useNavigate(); return (to, options) { if (!document.startViewTransition) { navigate(to, options); return; } document.startViewTransition(() { flushSync(() { navigate(to, options); }); }); }; }flushSync会强制 React 同步刷新状态更新确保startViewTransition回调返回之前新页面的 DOM 已经真实挂载到文档里了。浏览器此时捕获的新快照就是完整的新页面动画效果才是干净利落的。这个方案我自测下来观感最好唯一的成本是引入react-dom里的flushSync。不过要注意flushSync属于 React 的“逃生舱”API别在事件处理之外滥用只用在路由切换这个场景里是完全合理的选择。3.2 Vue Router 集成方案Vue Router 里接入更自然一些。因为 Vue Router 的路由切换是响应式驱动DOM 更新通常在组件树的updated钩子之后才完成所以你需要把startViewTransition和“DOM 已更新”的信号对齐。比较实用的方案是结合nextTickimport { nextTick } from vue; import { createRouter, createWebHistory } from vue-router; const router createRouter({ history: createWebHistory(), routes: [ // 你的路由表 ], }); let pendingTransition null; router.beforeEach((to, from, next) { if (!document.startViewTransition) { next(); return; } pendingTransition document.startViewTransition(async () { next(); await nextTick(); }); });这里的关键在于next()让路由真正切换nextTick()等待 Vue 把新组件渲染到 DOM然后startViewTransition的回调才算完成浏览器这时拿到的才是新页面快照。如果去掉nextTick回调会在 DOM 更新前就结束新快照内容不对动画效果会“闪空”。我测试下来的体验是 Vue Router 的接入比 React Router 给人的心智负担更小因为nextTick是一个很明确的可等待时机。如果你用的是 Vue 3.5 以上版本也可以借助onUpdated钩子做更精细的控制但多数场景里nextTick已经足够。3.3 多视图过渡给不同页面定义独立动画把startViewTransition接入路由之后还要考虑一个业务层问题不同页面之间可能需要不同的转场效果。比如从列表页到详情页想做横向滑动从表单页到成功页想做淡入这就要用到view-transition-name了。思路是给路由容器或页面根元素设置不同的view-transition-name.page-list { view-transition-name: page-list; } .page-detail { view-transition-name: page-detail; }然后在 CSS 里为不同的 name 定制动画::view-transition-group(page-list) { animation-duration: 0.4s; } ::view-transition-group(page-detail) { animation-duration: 0.4s; } ::view-transition-old(page-list) { animation: slide-out-left 0.4s ease both; } ::view-transition-new(page-detail) { animation: slide-in-right 0.4s ease both; }这相当于告诉浏览器page-list这个元素有自己的过渡快照page-detail也有自己的过渡快照它们独立执行动画。其余没有被单独命名的区域仍然统一走默认的root过渡。需要注意view-transition-name在同一个页面里必须是唯一的不能给两个 DOM 元素设置同一个名字否则浏览器会报错并放弃这次过渡。实际开发时建议统一用一个约定比如每个路由组件的根节点类名就叫page-${routeName}由构建工具或手动维护保证唯一性。3.4 数据加载顺序先拿到数据再过渡SPA 里还有一个老大难问题页面跳转后数据往往需要异步请求。如果直接启动过渡新页面先渲染出来的是 loading 状态动画切换过去再看到加载态视觉上非常割裂。我的建议是尽量在“进入动画”之前就把数据准备好。具体策略是点击跳转时先不下发路由更新而是先发起数据请求。数据回来后用startViewTransition包裹“路由更新 渲染数据完成页”。如果请求超过一定时长比如 300ms就先走普通跳转并展示 loading避免过渡层挂起太久。伪代码大概是async function goToDetail(id) { const data await fetchDetail(id); if (!document.startViewTransition) { navigate(/detail/${id}, { state: { data } }); return; } document.startViewTransition(() { navigate(/detail/${id}, { state: { data } }); }); }这样做的代价是页面跳转有了延迟感但换来的是“切换过去即完整页面”的干净体验。对于主要浏览路径这个取舍我认为是值得的。4. 自定义动画的进阶玩法与设计细节4.1 从淡入淡出到滑动、缩放设计你的转场语言默认的startViewTransition就是一个约 0.25 秒的淡入淡出视觉上偏保守。想做出更有辨识度的转场效果就要动上面的伪元素动画。比如做一个“新页面从右侧滑入、旧页面向左滑出”的效果::view-transition-old(root) { animation: slide-out-left 0.35s ease-in-out both; } ::view-transition-new(root) { animation: slide-in-right 0.35s ease-in-out both; } keyframes slide-out-left { to { transform: translateX(-20%); opacity: 0.6; } } keyframes slide-in-right { from { transform: translateX(20%); opacity: 0.6; } }注意我加了微透明而不是完全透明这是个小技巧如果旧页面完全透明后再消失画面会闪出一瞬间的空白背景保持一点透明度过渡会更柔和。想做强缩放效果也可以配合scale但幅度不要太大1.05 以内的缩放观感最自然超过 1.1 就容易引发眩晕感尤其在大屏显示器上特别明显。4.2 共享元素过渡列表到详情的“放大动画”View Transitions API 最有吸引力的玩法之一是共享元素过渡也就是让同一张图片或卡片从列表位置平滑放大到详情页位置。这个效果以前基本只有原生 App 才能做得自然现在 Web 端也能实现了。做法分两步。第一步给列表里的元素和详情页里对应的元素设置相同的view-transition-name.card-cover, .detail-cover { view-transition-name: cover; }第二步列表点击时启动startViewTransition并切换路由。浏览器会发现新旧快照里都有cover这个命名的元素于是自动把它们单独放进一个过渡组里做这个元素的“位置和尺寸补间动画”而不是走默认的root过渡。实际操作时有个细节路由切换前列表里的card-cover和路由切换后详情页里的detail-cover在 DOM 里不是同一个元素传统 CSS 无法对两个不同元素做补间但 View Transitions API 借助快照机制做到了。浏览器在动画期间会把两个命名元素的视图放在同一个::view-transition-group(cover)伪元素里位置大小自动插值。还有一点如果你用了图片懒加载进入详情页时图片可能还没加载出来快照里就会出现空白。我的建议是详情页里给这张图片加上fetchpriorityhigh或者提前预加载保证过渡时能拿到完整图像。4.3 配合 prefers-reduced-motion 做无障碍适配动效虽好但不是所有用户都喜欢甚至有些用户会因为前庭敏感之类的原因在看到大幅动画时感到不适。浏览器提供了prefers-reduced-motion媒体查询建议在动画层做一次降级media (prefers-reduced-motion: reduce) { ::view-transition-old(root), ::view-transition-new(root) { animation: none; } }这个做法给你的站点留了一条“保守路线”检测到用户系统级关闭动画时直接跳过过渡动画只保留瞬间切换保证基本可用性不受影响。4.4 用 CSS 变量统一管理动画参数页面多了以后动画时长、位移距离、缓动函数这些参数最好抽成 CSS 变量方便全局调整:root { --vt-duration: 0.35s; --vt-slide-distance: 30px; --vt-easing: cubic-bezier(0.22, 0.61, 0.36, 1); } ::view-transition-old(root) { animation: vt-slide-out-left var(--vt-duration) var(--vt-easing) both; } ::view-transition-new(root) { animation: vt-slide-in-right var(--vt-duration) var(--vt-easing) both; } keyframes vt-slide-out-left { to { transform: translateX(calc(-1 * var(--vt-slide-distance))); opacity: 0.6; } } keyframes vt-slide-in-right { from { transform: translateX(var(--vt-slide-distance)); opacity: 0.6; } }这样如果要统一缩短动画时长或者改成从下方滑入只需要改几处变量不用逐个页面去找。5. 生产环境里的踩坑排查与降级策略5.1 动画一闪而过或黑屏白屏最常见的问题是动画执行时背景闪黑或闪白。原因一般是捕获快照时页面没有正确渲染内容或者过渡层默认背景色与你页面不匹配。排查思路检查startViewTransition回调里是否用了flushSync/nextTick去等 DOM 更新如果不等就会捕获到空白中间帧。检查页面根元素是否设置了背景色。如果 body 或 html 本身没有背景色过渡层背景是透明的叠在动画里就会透出浏览器默认白色或深色模式下的黑色。检查嵌套的滚动容器是否影响了快照位置。快照捕获的是元素相对视口的“绘制结果”如果元素在屏幕外或者被裁剪快照里就会缺一块。还有一个我从实际项目里总结的经验如果页面有position: fixed的弹层或者吸附性元素快照往往会把它们一并捕获进来。动画期间弹层会跟着一起飞观感很怪。解决方案是给这些元素加view-transition-name: none不对应该是把弹层从正常的流内元素改成不加 view-transition-name 即可因为只有命名元素才会被单独捕获。但如果没命名它们还是走 root 过渡仍然会被拍进快照。真正想排除某块区域只能在切换前先隐藏它等过渡结束再恢复。5.2 多个视图同时设置了 view-transition-name 导致冲突前面提过同一个页面中不允许两个活跃元素使用相同的view-transition-name。这条规则在路由切换时会自动放开因为旧页面和新页面在逻辑上是两个不同的时间点可以同名。但如果一个页面里同时存在两个相同命名的隐藏元素比如一个详情弹层和一个抽屉组件它们都在 DOM 里但只有一个可见这时一旦触发过渡就可能报错。我踩过这个坑列表页里有一个“筛选弹层”和一个“排序抽屉”两个组件根节点都顺手写了view-transition-name: panel结果用户切换筛选条件时页面刷新很频繁动画时好时坏。排查到最后才发现是两个组件的命名冲突了。解决方案是保证页面内所有可能同时存在的元素view-transition-name都必须唯一。你要是习惯用 BEM 或者 CSS Modules可以按“组件名-用途”来命名比如panel-filter和panel-sort。5.3 异步路由组件加载导致新页面快照不完整SPA 大多会做路由级代码分割切换到一个还没加载过的路由时需要动态 import 组件的 JS 文件。这个过程是异步的如果startViewTransition里直接做路由跳转新页面可能还没渲染出来就结束了回调。我在项目里是这么处理的在启动过渡前先探测一下目标路由的 chunk 是否已加载如果没加载就先别启动过渡直接跳转等组件加载完成后的首次渲染再补一次视觉过渡。具体判断方式可以检查 Webpack 的模块缓存或者 Vite 的话用import.meta.glob配合预加载。这个方案和具体构建工具相关但思路是一致的视觉过渡应该只在“内容已经准备好”的情况下触发否则宁可没有动画也不能给用户看到一个空壳页面再闪烁。5.4 与第三方弹窗、错误提示组件的冲突如果你项目里用了全局的 Message 提示、Modal 弹窗这类挂载在 body 下的组件它们同样会被捕获进根快照。用户触发页面切换时如果屏幕上刚好有 toast 在展示动画过程里 toast 会跟着整个页面一起运动看起来像“飘过”虽然不致命但确实不精致。我的临时做法是启动startViewTransition前先把 toast 和弹窗隐藏等动画结束再恢复。这个逻辑可以封装到公共的跳转函数里比如function withViewTransition(updateFn) { if (!document.startViewTransition) { updateFn(); return; } const overlay document.querySelector(.global-message); if (overlay) overlay.style.visibility hidden; const t document.startViewTransition(() { updateFn(); }); t.finished.finally(() { if (overlay) overlay.style.visibility ; }); }这样虽然多了一点代码但过渡画面干净很多尤其是 Gutenberg/富文本编辑器场景里各种浮层很多隐藏一下再过渡是最省事的方案。5.5 兼容性判断与优雅降级View Transitions API 目前的主流支持情况是Chrome/Edge 111 已经默认支持Safari 18 也开始支持Firefox 目前处于开发中旧版本浏览器无法使用。所以在生产环境接这个特性第一步就是做能力检测function supportsViewTransition() { return document.startViewTransition ! undefined; }凡是能力检测不通过就直接走普通路由跳转什么额外逻辑都不要加。这样降级之后老浏览器只是没有动画页面的功能完全不受影响。我个人的建议是把这个检测抽成一个工具函数封装所有跳转逻辑而不是在业务组件里到处判断。这样等将来 Firefox 或者其他浏览器补齐支持你只需要删掉这一层工具函数里的兜底逻辑业务代码完全不需要动。5.6 从 JWT 验证码实现里收获的一个小技巧前面提到我最近在做的 SPA 项目里有 JWT 和验证码校验这里再延伸几句。登录成功后从验证码校验跳转到主面板是一个特别适合加过渡动画的场景用户在登录页停留很久突然跳到全新的系统界面如果没有动画会产生一种“页面被硬切”的突兀感如果加一个轻微的淡入放大动画整个登录成功的反馈会自然很多。具体做法是验证码校验通过、JWT 写入本地存储后先等一帧再启动startViewTransition跳转主面板。因为登录成功后的跳转是主动行为没有路由懒加载的异步问题动画表现相当稳定。这个场景给我最大的启发是页面过渡动画不应该只服务于“好看”它还能承担一部分状态反馈的职责告诉用户“你已经从一个状态进入另一个状态了”。这也是 View Transitions API 值得在业务里推广的重要原因。6. 一段可以直接抄的封装代码聊了这么多最后分享一个我在 React React Router 项目里实际在用的封装把它放在一个工具文件里所有需要带动效的跳转都走它// transitionNavigate.js import { flushSync } from react-dom; export function isViewTransitionSupported() { return typeof document ! undefined !!document.startViewTransition; } export function startViewTransition(updateFn) { if (!isViewTransitionSupported()) { updateFn(); return Promise.resolve(); } return document.startViewTransition(() { flushSync(() { updateFn(); }); }).finished; }使用方式import { startViewTransition } from /utils/transitionNavigate; import { useNavigate } from react-router-dom; function Page() { const navigate useNavigate(); const handleGoDetail (id) { startViewTransition(() { navigate(/detail/${id}); }); }; return div onClick{handleGoDetail}查看详情/div; }Vue 把updateFn里换成next().then(nextTick)即可。这个封装的好处是很薄不侵入路由配置也不全局订阅路由事件想给哪个跳转加动画就给哪个跳转加粒度完全可控。再补充一个和我自己的业务场景相关的心得如果你的 SPA 里同时引入了状态持久化、路由守卫、数据预取这类复杂的路由逻辑尽量让动画层保持“最后执行、最早结束”的原则。动画只是修饰不能成为路由主流程的阻塞点。判断标准很简单把startViewTransition全部删掉页面应该依然能正常工作只是没有动画罢了。凡是做不到这一点的封装都是在给未来埋雷。View Transitions API 给我的最大感受是Web 平台原生能力越来越强了过去需要昂贵的库和大量 hack 才能实现的视觉体验现在一个原生 API 就能做得更自然。它不激进不要求你推翻现有架构只需要在路由切换的入口处加一层薄薄的封装就能给 SPA 带来接近原生应用的页面转场质感。你也完全可以先在一个业务场景里试点比如列表进详情、登录成功跳主页这类高频路径跑顺了再逐步铺开。等你试过一次共享元素过渡的放大效果大概率就不想再回到纯静态切换的界面了。