资讯详情

资讯详情

用View Transitions API优雅实现SPA路由切换动画

SPA 项目做久了你会发现一个特别尴尬的问题页面切换太“硬”了。点击一个菜单内容唰一下换掉整个过程没有任何过渡用户经常不知道新页面从哪儿来、上一个页面去哪儿了。早年我为了解决这个问题在 Vue 项目里折腾过transition组件在 React 项目里试过framer-motion效果能做出来但复杂度也跟着上来了——组件要包裹、动画状态要管理、滚动位置要重置一个不小心就会引入新的 bug。直到后来接触到 View Transitions API我才觉得这条路子对了。它的思路完全不一样浏览器自己给当前页面拍一张“旧照片”等 DOM 更新完再拍一张“新照片”然后帮你把两张照片平滑地交叉淡化。也就是说我们不需要手动控制元素的进入和离开动画只需要告诉浏览器“我要切换了”剩下的交给原生机制。这篇文章我就从原理讲到实战结合 React 和 Vue 两种常见 SPA 框架手把手带你把它接入自己的项目里。1. 为什么 SPA 页面切换动画是个“老大难”问题1.1 SPA 的切换特性反而成了体验短板传统多页面网站每次跳转都是重新加载文档虽然慢但浏览器自带的加载进度条、新页面的整体进入效果其实给用户传递了“我正在前往另一个地方”的心理暗示。SPA 不一样它只有一个 HTML 外壳路由切换本质上是 JavaScript 动态替换页面里的内容区域。这个过程的优点是快缺点是没有任何视觉连续性。你可以把 SPA 的切换想象成舞台剧换幕如果只把背景板瞬间换成另一块观众会觉得很突兀但如果有幕布拉上、拉开或者在灯光上做个过渡观众心理上就能接受“场景已经变化”这件事。页面切换动画本质上就是这个“幕布”它帮助用户在连续的操作流里建立空间位置感。没有这层过渡用户在一个后台系统里频繁点击菜单时很容易产生“页面是不是出 bug 了”的错觉。我最早在做后台管理系统的时候被产品经理提过好多次“页面跳得太生硬了”当时我心想这不是技术问题是交互细节。后来仔细一想这确实是技术问题因为传统实现一个好的页面切换动画成本是真的高。1.2 传统动画方案为什么又慢又容易出问题先说说过去我们怎么给 SPA 加动画。最常见的方案是 Vue 的Transition和 React 的framer-motion。这两种工具的核心思路是给页面元素的状态变化定义进入和退出动画。听起来很简单但真正落到路由级别时问题就来了。首先路由切换涉及到“老页面退出”和“新页面进入”两个动画本质上是两个组件实例在同一区域内的交替显示。你需要把老页面“冻结”住但又不能真的卸载它否则退出动画没法播放。以 React Router 为例常见的做法是在Outlet外层包一层动画容器然后用useLocation()监听路由变化在framer-motion里用AnimatePresence配合modewait来管理退出和进入。这个方案能用但有几个隐藏的坑动态路由、懒加载组件、带参数的详情页切换这些场景下动画时机非常难控制。你说不清到底等异步组件加载完再播进入动画还是让退出动画先把空间空出来。处理不好就会出现“先空一下再突然弹出内容”的闪白问题。另一个方案是完全用 JS 驱动自己监听路由变化抓取新旧页面标题、背景色、滚动位置等元素然后用 Web Animations API 或 DOM 手动做位移动画。这种方案灵活度最高但工程量也最大而且跟具体业务耦合很深。换一个路由结构或者改一次页面布局动画代码可能就要跟着调整维护成本太高了。我当时给一个项目做过一版这种高度定制的切换效果用了三周改了两轮最后在性能优化阶段又被砍掉了因为动画过程中主线程占用太高低端设备上掉帧明显。1.3 View Transitions API 解决了什么核心问题View Transitions API 的设计目标其实就是解决上面所有痛点它把“抓取旧状态 - 更新 DOM - 生成新状态 - 过渡”这条链路完全交还给浏览器。我们作为开发者唯一要做的就是在路由切换时启动一次视图过渡然后在 CSS 里用伪元素定制动画细节。这个方案的第一个好处是“零侵入”路由组件、页面布局都不需要大幅改造只需要在切换动作外面包一层document.startViewTransition()。第二个好处是性能上有保障浏览器底层会自己处理快照合成和动画运行不需要 JavaScript 逐帧操作主线程占用很低。第三个好处是 CSS 的可扩展性很强通过::view-transition-old和::view-transition-new这两组伪元素你可以做出淡入淡出、左右滑动、缩放、圆形揭幕等效果改动也很小。做个不恰当的类比传统方案像你自己在家里拆墙装门每一步都要亲力亲为View Transitions API 像你请了个装修队你只需要告诉他们“我要换一扇门”剩下的测量、拆装、收尾他们全包了。当然你得知道这个装修队的脾气也就是它的运行机制和坑点否则同样会出问题。2. View Transitions API 到底是怎么工作的2.1 一个最简单的例子开始在浏览器里启动一次视图过渡只需要一行代码document.startViewTransition(() { // 这里更新 DOM updateTheDOMSomehow(); });startViewTransition接收一个回调函数浏览器会先截取当前页面的一张快照然后执行你的回调一般在回调里做 DOM 更新等 DOM 更新完成后再截取新页面的一张快照。最后它把这两张快照以伪元素的形式挂到页面上默认执行一次交叉淡化整个过程结束后自动清理。你看其实并不复杂。核心机制是“快照”而非“真实元素”。也就是说动画播放过程中页面上的 DOM 其实已经切换成新版了只是浏览器用两张图片在视觉上做了过渡。这个设计很精巧它意味着动画不会影响页面的真实交互逻辑——比如用户快速点击第二个路由时新的 DOM 已经准备好了不会因为动画没播完而卡住交互。我建议你在本地随便找个已有页面在控制台手动跑一段document.startViewTransition(() {})里面什么都不做都会看到页面有个轻微的“闪一下再回来”的效果。这就是快照差异造成的切换能帮你直观理解 API 的基本行为。2.2 核心快照机制与伪元素层级默认的交叉淡化效果虽然能用但大多数项目需要的肯定不是这么简单。这时候就要理解 View Transitions API 在动画期间插入到页面里的那一组伪元素了。当 transition 启动时浏览器会在文档顶层生成一棵“伪元素树”::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代表整个页面。后续我们可以给不同元素命名生成多个 group。::view-transition-image-pair是“新旧快照”的容器old 和 new 都在里面。默认情况下这两个快照是叠在一起的通过 opacity 交叉淡化。::view-transition-old代表旧页面的快照。::view-transition-new代表新页面的快照。最重要的一点是::view-transition-old默认有一个animation叫fade-out把透明度从 1 变到 0::view-transition-new默认有一个fade-in把透明度从 0 变到 1。你覆盖这些默认动画就能做出各种自定义效果。如果你希望某个元素在切换过程中表现出“不同步”的动画可以给他设置一个唯一名称.detail-title { view-transition-name: detail-title; }命名之后这个元素就会被浏览器单独抽取为一个独立的 group而不是跟在整个页面根快照里走。比如列表页跳详情页时你希望新页面的标题能单独从右边滑入而其他内容只是整体淡化就可以单独给标题命名。这里要注意view-transition-name要求是唯一的两个元素不能重复命名否则浏览器会直接忽略整个过渡效果。2.3 当前浏览器兼容性现状与选型建议这个 API 的诞生时间其实挺早的但真正铺开是近一两年的事。目前 Chrome 和 Edge 从 111 版本开始默认支持这是最大的两个桌面浏览器阵营所以大量内部管理系统、后台工具只面向 Chrome 内核浏览器完全可以放心用。Safari 在后续 18.x 版本中也开始支持了但 Firefox 的支持进度一直比较保守到目前为止还需要标记。这里我给一个务实的建议如果你做的是公网产品或者面向的浏览器不可控不要直接把 View Transitions API 作为唯一方案。你可以做一个渐进增强——在支持document.startViewTransition的浏览器里使用它在不支持的浏览器里直接走原来的“无动画切换”。function withViewTransition(updateDOM) { if (document.startViewTransition) { return document.startViewTransition(updateDOM); } return updateDOM(); }这个兼容性判断成本极低却能保证你接入新技术时不会搞挂老浏览器用户。社区里也有一个官方的view-transitions-apipolyfill能让 Firefox 这类浏览器“假装”支持但它是用 JS 模拟快照动画的性能上不如原生实现而且代码体积不小。我个人的建议是内部项目用原生 API 优雅降级公网项目初期先只给 Chrome 用户开这个效果等其他浏览器跟上了再全量放开。3. 在 SPA 里接入路由级页面切换动画3.1 核心难点在于“如何确定 DOM 更新完成的时机”现在回到 SPA 场景。在上面那个最简单的例子中startViewTransition的回调里直接同步更新 DOM浏览器能自动感知 DOM 更新完成。但在真实 SPA 里路由切换并不是同步的React 的setState是异步批处理的Vue 的组件渲染也是异步更新到 DOM 的更别提路由组件还会懒加载。如果你在startViewTransition里调用setState后立刻返回浏览器可能拍到的是“还没更新的旧页面 DOM”然后它拿新快照和旧快照一对比发现差不多动画效果就没出现或者只出现一个奇怪的闪烁。这是接入 SPA 时最容易踩的第一个坑。解决方案其实就一句话让 StartViewTransition 的回调返回一个 PromisePromise 在真实 DOM 渲染完成后再 resolve。官方类型定义里回调是() Promisevoid或() void所以返回 Promise 是完全受支持的。只要你在 Promise 里等待 React 或 Vue 的 DOM 更新完成快照时机就是正确的。3.2 React Router 实战接入示例以 React 为例假设你的项目用的是 React Router 6。最简单的做法是在组件的状态更新外层包一层startViewTransition。我常用的是一个包装函数import { useLocation, useNavigate } from react-router-dom; function useAnimatedNavigate() { const navigate useNavigate(); const location useLocation(); const animatedNavigate (to: string, options?: { state?: unknown }) { if (!document.startViewTransition) { navigate(to, options); return; } document.startViewTransition(async () { // 关键先把 navigate 同步执行再等待 React 完成渲染 navigate(to, options); await flushReactRender(); }); }; return animatedNavigate; } function flushReactRender() { return new Promisevoid((resolve) { // 用两次 requestAnimationFrame 确保 React 的异步渲染已提交到 DOM requestAnimationFrame(() { requestAnimationFrame(() resolve()); }); }); }为什么等两次requestAnimationFrameReact 18 的并发模式里setState到 DOM 更新之间是不保证同步完成的虽然大多数情况下一次requestAnimationFrame就够了但保守起见两次能在绝大多数场景下拿到正确的快照。如果你的路由组件里还有异步数据请求比如 useEffect 里拉详情数据光等渲染还不够因为新页面可能已经渲染了骨架屏真实数据还没回来。这种情况我建议分两层处理数据请求放在路由加载器或者组件渲染的 Suspense 边界之外或者干脆接受“骨架屏也参与动画”的行为因为骨架屏本身就是用户界面的一部分动画过渡过去反而是自然的。更好的方案是用 React 的useTransitionHook 结合startViewTransition。React 官方文档里说过startTransition内部的更新是可以中断的但从用户体验上你很难知道它什么时候真正完成。所以在实际项目里我更倾向于用上面这种requestAnimationFrame双帧等待方案简单直接不容易出问题。3.3 Vue Router 实战接入示例Vue 的实现思路跟 React 略有不同因为 Vue 有实例方法$nextTick可以直接拿到 DOM 更新后的回调。如果你用的是 Options API 或 Composition API 都可以关键是找到路由切换的“钩子点”。我比较推荐在router.afterEach全局后置守卫里处理因为此时路由已经确认跳转组件即将更新而新组件可能还没渲染完。如果“先切路由、再补动画”会丢失新页面的第一帧内容。更好的做法是在beforeEach守卫里调用startViewTransition然后在回调里next()// router/index.js import { createRouter, createWebHistory } from vue-router; const router createRouter({ history: createWebHistory(), routes, }); let pendingTransition null; router.beforeEach((to, from, next) { if (!document.startViewTransition || pendingTransition) { next(); return; } pendingTransition document.startViewTransition(async () { next(); await nextTickRender(); }); }); async function nextTickRender() { await new Promise((resolve) { requestAnimationFrame(() { requestAnimationFrame(() resolve()); }); }); } router.afterEach(() { if (pendingTransition) { pendingTransition.finished.finally(() { pendingTransition null; }); } });这段代码的思路是把next()放进startViewTransition的回调里让路由跳转动作本身被快照更新流程包裹同时把待处理的 transition 存在一个全局变量里防止快速点击路由导致多重动画叠加。pendingTransition的清理放在afterEach之后的finished.finally中能保证每次跳转动画都必须等上一个彻底完成才允许新的启动。如果你用的 Vue Router 版本比较高还可以利用路由scrollBehavior配合新页面进入的滚动重置。路由切换动画改变了用户对页面位置的感知所以在动画启动前把滚动条重置到顶部通常体验更好关键是要放在新页面快照生成之前不然图片里会带着旧滚动位置。3.4 并不是所有页面都适合开动画实际项目中不是每一次路由跳转都适合播放动画。我在接手一个有 JWT 登录体系的后台项目时给登录页到主页面、主页面到登录页的跳转一律关闭了动画。原因很简单登录、登出、token 过期这种涉及安全状态的切换用户需要的是“确定性”生硬地切换到登录页反而会强化“会话已失效”这个信息的心理反馈。这种场景要是也加丝滑的动画会让用户觉得页面是不是还停留在上一个状态反而造成困惑。另外startViewTransition启动后浏览器会为整个页面生成快照。如果页面上有无限循环的动画元素比如 loading 动画、播放中的视频、倒计时、canvas 动效快照生成后视觉上会出现“非静态图像”的粘滞感。所以我在封装动画函数的时候会额外加一个检查允许业务方给特定路由设置enableTransition: false从源头跳过动画。// 路由配置项例子 const routes [ { path: /login, component: Login, meta: { enableTransition: false } }, { path: /dashboard, component: Dashboard, meta: { enableTransition: true } }, ];这样做的好处很明显动画作为增强功能可以按页面粒度精细化控制而不是一刀切全部生效。真的不是所有页面加动画都更好你要体会用户在特定页面切换前的心理预期这其实是做交互设计非常微妙的一点。4. 自定义动画效果从交叉淡化到方向感知4.1 用 CSS 覆盖默认伪元素动画默认的交叉淡化确实能用但有点单调。要做成类似移动 App 里的“左滑进入、右滑退出”只需要覆盖给定的默认动画即可。比如想实现“后退时新页面从左侧滑入前进时从右侧滑入”可以这样写::view-transition-old(root) { animation: 300ms ease both fade-out-slide-left; } ::view-transition-new(root) { animation: 300ms ease both fade-in-slide-right; } keyframes fade-out-slide-left { from { opacity: 1; transform: translateX(0); } to { opacity: 0; transform: translateX(-30%); } } keyframes fade-in-slide-right { from { opacity: 0; transform: translateX(30%); } to { opacity: 1; transform: translateX(0); } }注意这里的animation简写里一定要加both填充模式否则动画开始前或结束后元素的样式可能会跳回默认值导致闪烁。另外如果你只想让新页面做位移动画旧页面保持静止直接把 old 的动画替换成animation: none就行。一个容易忽略的点是这些伪元素是“快照”本质上是图像所以你在里面添加filter、clip-path、border-radius这类属性都是可以生效的因为它们只影响块级图像的渲染不会触发重排。这让 View Transitions API 做特效时性能非常稳你可以放心地加transform和opacity这类合成器属性。4.2 订阅路由变化方向做“方向感知”动画上面的 CSS 虽然好但它永远是固定方向。实际 SPA 场景里从列表点进详情是“前进”从详情返回列表是“后退”动画方向应该相反才算合理。这就需要我们在启动 transition 时告诉 CSS当前是前进还是后退。在 React 里可以用一个useRef记住上一次的location.pathname然后在新的跳转发生时比较两个路径的“深度”。我这里的“深度”是自己定义的没有统一标准你可以根据业务规则来。比如列表页/list深度是 1详情页/list/:id深度是 2从 1 到 2 就是前进反之为后退const prevPathRef useRef(location.pathname); function getPathDepth(pathname: string) { return pathname.split(/).filter(Boolean).length; } // 跳转时 const prevDepth getPathDepth(prevPathRef.current); const nextDepth getPathDepth(to); const direction nextDepth prevDepth ? forward : back; document.documentElement.dataset.transitionDirection direction; document.startViewTransition(async () { navigate(to); await flushRender(); prevPathRef.current to; });然后在 CSS 里通过html[data-transition-directionforward]选择器区分方向html[data-transition-directionforward]::view-transition-new(root) { animation: 300ms ease both fade-in-slide-right; } html[data-transition-directionback]::view-transition-new(root) { animation: 300ms ease both fade-in-slide-left; }这里要注意清理状态如果用户在动画中途又点了别的路由>/* 列表页卡片 */ .card { view-transition-name: shared-card; } /* 详情页容器 */ .detail-container { view-transition-name: shared-card; }然后给这个 group 单独写动画::view-transition-group(shared-card) { animation: 400ms ease both card-expand; } keyframes card-expand { from { border-radius: 12px; transform: scale(0.8); opacity: 0.6; } to { border-radius: 0; transform: scale(1); opacity: 1; } }这里最关键的一点是两个页面里同名元素的位置和尺寸差异会被浏览器自动计算成位移。但要注意如果在第一个页面里元素存在第二个页面里元素不存在比如从详情页返回列表页时详情页容器消失浏览器会只保留其中一个快照动画效果会退化成仅针对新页面的淡入。所以如果你在两页之间传递同一个元素最好保证两个页面都有对应元素哪怕详情页的容器是个占位也行。另一个进阶玩法是“容器型动画”给全屏蒙层、弹窗这类组件加 view-transition-name实现像移动端 App 那样的弹窗从底部滑入效果。这个思路我实际用得很爽整个弹窗的出现不再依赖组件内部状态配合 CSS 写进入动画而是让快照机制自动处理组件代码里只需要在开关弹窗时调用startViewTransition包一下即可。5. 常见问题与排错实录5.1 动画不生效或者只是闪了一下遇到这种问题第一时间检查浏览器版本是不是支持startViewTransition第二时间检查有没有报错。但我排下来发现最常见的其实是“回调函数里没有真正触发 DOM 更新”。比如在 React 里你把navigate调用放在了setTimeout里或者放在了startViewTransition外面的作用域里回调节拍执行结束时 DOM 没变化浏览器就会认为“没有变化”直接跳过动画。第二个常见原因是浏览器正在执行其他同步任务导致“快照被截断”。比如你在点击事件里先做了大量同步计算再调 startViewTransition浏览器拍摄旧快照时可能已经错过了页面之前的稳定帧。我建议把 startViewTransition 放在事件处理的最开始再执行任何重量级同步操作确保旧快照能拍到稳定状态。第三个坑是元素带view-transition-name但页面里有重复。一旦有两个元素用了同一个 name整个 transition 会被浏览器判定为非法直接静默失败。这属于非常隐蔽的错误因为不会报任何 console 错误。排查方法是在动画不生效时全局搜索一下view-transition-name是否有重复定义尤其要小心列表循环里生成的元素它们最容易造成命名冲突。5.2 异步路由与懒加载组件导致动画期间“白屏”SPA 项目里路由组件大概率是懒加载的。路由跳转时需要先动态加载 chunk再渲染组件。如果这个加载过程发生在startViewTransition的回调外层那么新页面快照拍摄到的可能是一片空白或者 loading 占位导致动画效果是“新白屏淡入”。我的处理思路是把懒加载的等待动作也并入 transition 的 Promise 链。例如 React 中可以用const loadComponent lazy(() import(/pages/Detail)); document.startViewTransition(async () { await Promise.all([componentLoader(), flushRender()]); });具体一点如果路由配置里已经有懒加载组件React Router 默认会在渲染时才触发 chunk 加载。为了等待 chunk你可以在navigate之前手动await loadComponent或者用路由组件的 Suspense fallback 状态作为“新页面”的快照。我个人比较推荐后者让懒加载期间显示一个骨架屏然后整个骨架屏作为新快照参与过渡。骨架屏本身是页面 UI 的一部分过渡过去完全正常用户不会觉得“白屏”。这个策略在未来新页面数据加载时同样适用。5.3 与已有动画库冲突的问题一个项目里如果既有 View Transitions API又有旧的 CSS 过渡动画库比如 Vue 的transition组件或者 framer-motion冲突是必然的。因为两个动画系统都在操作元素显示状态就会产生“双倍动画”的效果旧路由元素在退出动画的同时又参与 View Transitions 的旧快照淡化看起来拖泥带水。我的建议是接入 View Transitions API 后把路由组件的 “进入/离开” 动画代码全部移除只保留之前那些交互级的小动画比如按钮 hover、弹窗内部阶段的动画避免动画系统叠加。所有路由切换级别的动画统一走 View Transitions API。如果业务代码里非要保留一个局部过渡比如点击卡片打开详情时卡片本身有个缩放那么这两个动画就需要串联先让 View Transitions API 等待卡片自身过渡完成再启动页面切换。但实际复杂度很高我碰到这种情况都会直接跟设计沟通尽量简化动画层级。毕竟用户体验的角度动效不是越多越好干净统一才是成功的关键。5.4 常见问题速查表问题可能原因解决方案动画完全没有效果浏览器不支持 API / 回调内 DOM 没有更新检查document.startViewTransition是否存在确保 DOM 更新发生在回调 Promise 内动画出现闪白或短暂空白快照捕获时懒加载组件未加载完成将懒加载等待动作并入 transition 回调或使用骨架屏作为新页面快照动画不自然或卡顿动画属性触发了重排动画尽量只用 transform 和 opacity避免使用 width、height、top 等属性控制台无报错但过渡失效view-transition-name重复声明全局搜索 name确保唯一尤其警惕列表循环快速点击路由导致动画混乱前一次 transition 未结束就触发第二次维护全局 pendingTransition 状态前一次未结束不启动新的CSS 自定义动画结束后元素跳动伪元素动画缺少填充模式在 animation 简写里加上both填充模式或者明确animation-fill-mode: both跟旧动画库效果叠加路由级动画没有完全移除统一由 View Transitions API 接管路由切换动画移除旧代码这张表是我在真实项目里踩完坑之后提炼的每一条都对应过实际报错或者视觉异常。排查动画问题的时候我习惯先把 CSS 动画代码全注释掉只保留默认交叉淡化看能不能正常过渡出来。如果能说明问题出在自定义动画层如果不能说明问题出在 JS 接入层。这种二分法能快速缩小排查范围省去很多无谓的时间。最后分享一个我自己很受用的经验接入 View Transitions API 的时候不要一上来就追求花哨的视觉特效。先把它默认的交叉淡化平稳跑通再逐步添加你想要的个性化效果。这个 API 最大价值不是让页面多炫酷而是用极低成本把 SPA 的切换体验从“生硬”拉回“自然”。等你把全局的路由切换统一用上它之后再回头点其他没有接入动画的页面那种对比会让你深刻理解用户体验里“过渡感”的价值。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →