
1. 开局SPA 页面切换为什么总感觉“硬邦邦”做过单页应用SPA开发的朋友应该都有这种体验功能做得越复杂页面切换就越像翻纸质文件——啪一下旧页面没了新页面直接出现没有任何中间过程。数据交互倒是快了但用户在视觉上完全感知不到“我在往前走”尤其是在那种需要表达层级关系的产品里比如列表进详情、详情进编辑体感非常割裂。为什么传统 SPA 很难做好页面切换动画根源在于它的渲染机制。SPA 的“页面切换”本质上是 JavaScript 在同一份文档里替换 DOM 内容浏览器并没有一个“页面即将离开、新页面即将进入”的原生生命周期。你要做动画只能用各种 Hack先用 JS 把当前页面的 DOM 做成快照播放一段退出动画等动画结束再把新页面挂上去再播进入动画。这个方案工作量大、同步复杂、还容易在快速点击时状态错乱。我早期做过一版用 React Framer Motion 硬啃调试退出和进入动画的时序调了一个星期最后上线还是有几个边界 Case 会闪白屏。直到 View Transitions API 出现这个局面才真正有了改观。它的核心思路是直接让浏览器接管“旧状态消失、新状态出现”这个过程自动做新旧两种视觉状态的交叉淡化同时允许我们用一套很简洁的 CSS 语法去定义过渡细节。再配合它提供的 JavaScript 钩子开发者可以在动画播放前后插入自己的业务逻辑做那种高度定制化的转场也不是什么难事。这篇文章我打算从零开始把 View Transitions API 的完整链路梳理一遍。从它到底解决什么问题、在 SPA 里怎么接入到复杂场景下的处理技巧、踩坑记录一步步带你实现一个能直接放到生产项目里的页面切换方案。就算你现在对浏览器新 API 不太敏感看完这篇文章也能直接动手试。2. 先说清楚View Transitions API 到底做了什么2.1 以前做页面转场动画的三个苦逼方案在讲新 API 之前我觉得有必要先把老方案摆出来不然你很难理解 View Transitions 这份“省心”到底省在哪。第一个方案是“整体淡入淡出”实现很粗暴新页面挂载时先设 opacity 为 0然后通过 CSS transition 过渡到 1。这个方案的问题是过渡太单调而且 SPA 里每个组件都要添加这套逻辑代码大量重复。如果遇到那种需要从列表项“放大”到详情页的动效完全做不了。第二个方案是“双页面同屏渲染”。页面切换时老页面不卸载新页面挂载上去两个页面同时在 DOM 里存在一段重叠时间然后老页面播退出动画、新页面播进入动画。这个方案的动效质量很高但内存开销大、DOM 状态容易冲突、滚动位置处理麻烦而且框架层面的路由跳转还得做兼容处理维护成本高得吓人。第三个方案是用 Web Animations APIWAAPI手动控制动画。比前两个更灵活但是需要你把“新页面准备渲染”和“动画可以开始播放”两件事编排清楚本质上还是在写自定义的状态机。这些方案最大的通病是浏览器把“页面替换”当成一个瞬时事件动画是开发者拿应用层代码硬生生“织”上去的。只要哪一步时序对不上就会出现白屏、闪烁、动画卡顿甚至新旧内容短暂叠加的脏状态。2.2 新 API 的核心机制浏览器替你完成“像素级迁移”View Transitions API 算是从浏览器底层把这个问题给解决了。它的工作原理可以提炼成三步第一步调用document.startViewTransition()传入一个更新 DOM 的回调函数。浏览器会立即截取当前屏幕状态生成一张“旧状态截图”。第二步执行你传进去的那个回调。这个回调里通常就是框架的setState或路由更新逻辑DOM 被替换成新页面内容。回调完成后浏览器再把新状态截一张图也就是“新状态截图”。第三步浏览器把旧截图和新截图放在同一个渲染图层里执行一次默认的交叉淡化过渡过程完全是浏览器原生合成的不会触发额外的重排重绘负担。这个过程给人的感觉就像你自己只说了“我要从 A 变成 B”剩下的“怎么从 A 的像素变成 B 的像素”浏览器全包了。开发者不用再操心 DOM 快照、时序、渲染性能这些问题只管写业务逻辑就好。2.3 和 React Router / Vue Router 的配合方式很多朋友第一次接触 View Transitions API 会问它跟 React Router、Vue Router 这些框架路由到底是什么关系我的理解是它们是不同层面的东西路由管的是“该渲染哪个组件”View Transitions 管的是“组件切换时视觉上怎么过渡”。所以接入的时候不是用 View Transitions 替换掉路由而是在路由确认跳转后、真正更新组件树之前用document.startViewTransition()把组件更新包一层。落地的话在 React Router 里通常是在路由组件渲染层做拦截或者在数据加载完成后手动触发在 Vue Router 里可以利用路由守卫或者 RouterView 的过渡钩子把更新操作丢到startViewTransition的回调里。具体到实现层面各家框架都有点讲究后面我会拿真实代码演示一个相对完整的接入方案。3. 手把手实战从零搭一个带转场的 SPA 项目3.1 准备工作环境和项目结构这一节的演示我直接用 Vite React React Router 来搭。为什么选这套生态成熟、上手快而且 View Transitions API 跟 React 的并发渲染机制配合起来兼容性也验证得最好踩坑最少。习惯用 Vue 的朋友也别着急核心思路完全一致差异只在钩子位置。初始化项目npm create vitelatest view-transition-demo -- --template react-ts cd view-transition-demo npm install react-router-dom项目结构不用太复杂先建三个页面首页Home、列表页List、详情页Detail。这三个页面正好可以演示三种转场场景同级切换、列表进详情、返回上一级。3.2 核心封装让 View Transitions 成为路由切换的默认动作我们先把 View Transitions 的调用封装成一个通用工具这样后续所有页面跳转都走同一套逻辑。在src/utils/viewTransition.ts中写export function navigateWithTransition( update: () Promisevoid | void ): void { const prefersReducedMotion window.matchMedia( (prefers-reduced-motion: reduce) ).matches; if (!document.startViewTransition || prefersReducedMotion) { update(); return; } const transition document.startViewTransition(async () { await update(); }); return; }这里面有两个关键点第一先用document.startViewTransition是否存在做能力检测。这个 API 目前已经在最新版 Chrome、Edge 里稳定支持了但 Safari 和 Firefox 的旧版本还不支持。做降级是必须的不支持就老老实实直接更新 DOM不影响功能。第二我查了用户的prefers-reduced-motion偏好。有些用户对动画敏感系统设置了“减少动态效果”这时候再播动画就属于体验事故不是体验加分。作为开发者尊重这个设置比炫技更重要。3.3 在 React Router 中接入组件更新接下来让路由跳转时触发这个工具。核心思路是在 RouterProvider 外面包一层用 useLocation 监听路由变化在变化时记录并用 startViewTransition 包裹组件渲染。实现如下import { useEffect, useRef } from react; import { useLocation } from react-router-dom; export function ViewTransitionOutlet({ children }: { children: React.ReactNode }) { const location useLocation(); const containerRef useRefHTMLDivElement(null); useEffect(() { if (!document.startViewTransition) return; const container containerRef.current; if (!container) return; const transition document.startViewTransition(async () { await Promise.resolve(); }); transition.finished.finally(() { // 清理过渡伪元素状态 }); }, [location.pathname]); return div ref{containerRef}{children}/div; }等等这个写法其实不够优雅它只建立了“路由变了”的通知但组件树的更新跟过渡没有同步。真正的做法应该是把路由更新内联到startViewTransition的回调里。更实用的做法是这样function AppLayout() { const location useLocation(); const navigate useNavigate(); const handleNavigate (to: string) { navigateWithTransition(() { navigate(to); }); }; return ( div button onClick{() handleNavigate(/list)}去列表页/button Outlet / /div ); }注意到没navigate(to)是 React Router 的编程式导航方法它本身会触发 Router 内部状态更新从而重新渲染Outlet /。把这一步放到startViewTransition的回调里浏览器就会在旧页面还在屏幕上时等待新的组件树渲染完成然后自动完成视觉过渡。这里用了个小技巧不直接调用navigate而是包装一层这样以后无论哪里想跳转都能复用这段逻辑。实际的完整封装建议把handleNavigate放到一个自定义 Hook 里用的时候直接useTransitionNavigate()拿方法页面组件里不用再关心 View Transitions 的细节。3.4 页面级转场样式自定义想要的效果默认的交叉淡化其实已经很自然了但既然我们做了 SPA 转场不搞点自定义效果有点说不过去。View Transitions API 提供了两个伪元素::view-transition-old(root)是旧页面截图::view-transition-new(root)是新页面截图。我们可以针对它们写 CSS 动画::view-transition-old(root) { animation: fade-out 0.3s ease-out both; } ::view-transition-new(root) { animation: slide-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) both; } keyframes fade-out { from { opacity: 1; } to { opacity: 0; } } keyframes slide-in { from { transform: translateX(40px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }这样旧页面会快速淡出新页面会从右侧轻微滑入整体感觉就像在原生 App 里切换页面一样轻快。注意过渡时长不要调太长SPA 场景下 0.3 到 0.4 秒是比较舒服的区间拖到 0.6 秒以上用户就会觉得“卡”。3.5 列表进入详情如何让被点击的卡片“飞”过去这是最能体现 View Transitions API 价值的一个场景用户点击列表里某张卡片卡片本身应该有一种“放大铺满屏幕”的视觉效果变成详情页。实现上要给每张卡片设置一个独立的view-transition-name。这样浏览器就不是拿整张页面截图去过渡而是把这一个元素单独拎出来做动画其他部分仍然走默认淡化。.card { view-transition-name: card-1; }然后在列表渲染时动态指定名称{items.map((item, index) ( div classNamecard style{{ viewTransitionName: card-${item.id} }} onClick{() handleNavigate(/detail/${item.id})} {item.name} /div ))}详情页的根元素也要设置同一个名称div style{{ viewTransitionName: card-${id} }} {/* 详情内容 */} /div当这两个元素在切换前后拥有相同的view-transition-name时浏览器就会自动执行“共享元素过渡”旧列表的小卡片平滑放大、移动到新详情页的位置视觉上就是卡片飞过去变成了页面主体。这个效果以前靠手写动画要实现半天现在几行 CSS 就搞定了。不过要提醒一句view-transition-name在同一个时刻不能重复也就是说你在列表里不能同时让两张卡片用同一个名字。对于动态列表必须用唯一 ID 拼进去这个坑我踩过后面还会专门说。4. 进阶优化跳出基础用法做好边界控制4.1 多级路由的转场差异化设置实际项目里页面之间的层级关系往往不一样。比如从首页进入列表页我们希望是新页面“推入”从列表页进入详情页我们希望是卡片“放大”从详情页返回列表页又希望是“缩小退回”。如果所有路由都套同一个动画效果会很别扭。解决思路是给不同类型的跳转打上标记然后根据标记动态设置 CSS 变量。在handleNavigate里增加一个类型参数type TransitionType push | pop | detail; export function navigateWithTransition( type: TransitionType, update: () Promisevoid | void ): void { const transition document.startViewTransition(async () { await update(); }); document.documentElement.dataset.transitionType type; }CSS 里根据>html[data-transition-typepush]::view-transition-old(root) { animation: push-out 0.3s ease-out both; } html[data-transition-typepush]::view-transition-new(root) { animation: push-in 0.3s ease-out both; } html[data-transition-typepop]::view-transition-old(root) { animation: pop-out 0.3s ease-out both; } html[data-transition-typepop]::view-transition-new(root) { animation: pop-in 0.3s ease-out both; }这样你在业务代码里调用navigateWithTransition(detail, () navigate(...))浏览器就会自动选定对应的动画。代码维护起来也比较清晰后面想新增一种过渡类型只要加一组 CSS 就行。4.2 异步数据加载过渡动画别被数据拖住SPA 页面切换经常伴随着异步数据请求。如果新页面等数据渲染完才做过渡用户体验还是“白屏一下然后突然出现”。处理方式是把“页面骨架渲染”和“数据加载完成后的丰富内容”分开处理。第一步在路由切换时先在回调里快速渲染页面的骨架屏Skeleton。浏览器抓到的是“骨架屏状态”的新截图过渡动画这时候就开始了。第二步等数据到达后再更新页面内容这时候页面上就是正常内容从骨架屏淡入视觉上连续且流畅。用startViewTransition配合异步控制async function loadDetailPage(id: string) { const transition document.startViewTransition(async () { // 先切路由渲染骨架屏 navigate(/detail/${id}); // 等待数据加载 const data await fetchDetail(id); // 数据回来后更新页面状态 setDetailData(data); }); await transition.finished; }这样用户点击卡片立刻就能看到卡片飞成详情页骨架数据加载好了再填充内容整个过程没有白屏等待。这在感知性能上是个质的提升。4.3 页面滚动位置的保留与恢复SPA 切换还有个老问题从列表页滚动到很下面点进详情再返回时列表页的滚动位置丢失了。浏览器原生的 View Transitions API 不会自动处理滚动位置但我们可以在过渡开始前记录过渡结束后恢复。这里给出一个简单可行的实现let scrollPositions new Mapstring, number(); function saveScrollPosition(key: string) { scrollPositions.set(key, window.scrollY); } function restoreScrollPosition(key: string) { requestAnimationFrame(() { window.scrollTo(0, scrollPositions.get(key) || 0); }); }然后在跳转前调用saveScrollPosition(location.pathname)在过渡完成且新页面渲染后调用restoreScrollPosition(location.pathname)。注意恢复动作要放在requestAnimationFrame里因为浏览器要等新页面渲染完才能正确滚动否则滚动位置会被重置。如果你用的是 React Router v6.5也可以借助useScrollRestoration这类现成能力但机制不同我这里就不展开了核心思路是一样的记录-恢复。5. 实战中绕不开的坑和解决方案5.1 view-transition-name 重复导致的动画失效这个坑我一开始就踩了。列表页有 20 张卡片如果每张卡片的view-transition-name都叫card那浏览器直接罢工整个过渡不执行。原因是view-transition-name在同一个渲染帧里必须是唯一的浏览器要通过这个名字找到“旧状态里的元素”和“新状态里的元素”形成对应关系。如果重名它没法判断该匹配谁。解决办法很简单用唯一 ID 生成名称style{{ viewTransitionName: card-${item.id} }}但有个隐患如果列表数据里 ID 是数字需要考虑重复问题如果是从接口动态获取的还要保证详情页拿到的是同一个 ID否则匹配不上过渡效果也会失效。生产环境建议用字符串拼接 ID 时加个前缀减少跟其他元素冲突的概率。5.2 图片加载导致过渡瞬间闪白详情页通常会加载高清大图。如果图片没有预加载新页面渲染瞬间图片区域是空白的过渡动画会把空白作为“新状态”截图就表现为一种闪白、闪空的效果。处理方式有两种。第一种是给图片预留占位区域用背景色或者固定宽高的占位元素不让布局塌陷第二种是在过渡开始前用Image对象预加载关键图片function preloadImage(src: string): Promisevoid { return new Promise((resolve) { const img new Image(); img.onload () resolve(); img.onerror () resolve(); img.src src; }); }在startViewTransition的回调里先await preloadImage(url)再更新 DOM这样新状态的截图里就已经有图片了过渡不会闪白。注意这里预加载失败也要 resolve不能因为图片挂了阻塞整个过渡。5.3 用户快速连续点击时如何保证过渡不紊乱这是 SPA 转场里最难搞的问题之一用户手速快连续点了三个不同页面的链接。如果不做控制View Transitions 会叠加执行动画会乱成一片。我的方案是加一个“过渡进行中”的锁let isTransitioning false; export function navigateWithTransition( update: () Promisevoid | void ): void { if (isTransitioning) return; isTransitioning true; const transition document.startViewTransition(async () { await update(); }); transition.finished.finally(() { isTransitioning false; }); }这样当一次转场还没结束时后续的导航请求会被忽略。如果你希望用户最终能跳到最后一次点击的页面可以在锁存期记录一个“待跳转地址”锁释放后再自动执行一次跳转。但一般情况下直接忽略更快用户体验反而更稳。5.4 旧浏览器不支持时如何优雅降级这个 API 的支持情况比我预期要好但还没到可以无脑用的程度。所以降级方案必须提前想好。我们的navigateWithTransition里已经做了能力检测if (!document.startViewTransition || prefersReducedMotion) { update(); return; }当浏览器不支持时直接执行 DOM 更新没有任何过渡动画。对用户来说页面还是正常切换只是没有动画而已功能完全不受影响。这样就算某些浏览器里效果丢失也只是视觉层面的事不至于让应用崩溃或出现脏状态。5.5 中途取消过渡的清理问题某些场景下用户会在过渡动画还没播完时触发别的操作比如点击返回、关闭弹窗。这时候如果过渡的 Promise 挂在那里可能会导致旧 DOM 状态被错误保留。稳妥的做法是在关键清理逻辑里调用transition.skipTransition()强制跳过过渡transition.finished.catch(() { transition.skipTransition(); });这个方法会立即终止过渡过程让新旧状态直接切换不做任何动画。配合 Promise 的 catch能避免未捕获的异常导致控制台报错。6. 性能与体验之间的平衡6.1 不是所有切换都适合做动画做转场效果的初衷是提升体验但用得过度反而适得其反。有些场景比如表单提交后跳转到成功页“唰”一下滑进来用户视觉上是爽了但如果每次跳转都有一两百毫秒的动画延迟高频操作下用户会明显感觉“变慢了”。我的经验是高频、低信息量的切换比如标签页切换、分页跳转尽量不要用大位移动画简单淡化或者直接瞬间切换最舒服低频、层级关系强的切换列表进详情、跨模块跳转才适合用滑动、缩放这类明显的动效。动画不是越多越好而是越“对”越好。6.2 减少过渡截图的绘制开销View Transitions API 的截图机制本身是浏览器底层实现的性能比普通的 canvas 绘制或 DOM 克隆要高效但在低端机型上如果页面特别复杂截图和过渡合成还是会带来一定开销。优化思路主要有三点。第一过渡期间尽量让页面保持静态不要在startViewTransition回调里同时做大量布局操作否则会延长截图生成时间。第二避免对整个 document 做过渡尽量把view-transition-name设置在具体元素上让浏览器只对局部区域做截图处理。第三对图片、视频这类重资源做好懒加载和数据预取别让它们在过渡过程中突然触发解码任务。6.3 让动效符合直觉方向与层级最后聊一个偏设计层面的心得动画方向要跟信息结构一致。从列表点进详情新页面应该“变大变近”旧页面“变小变远”给用户一种“我穿透进去了”的感觉从详情返回列表则反过来。同级切换比如 Tab 切换尽量不要用位移要么淡化、要么轻微上移别强行做左右滑动因为左右滑动在用户心智里是“前进后退”的隐喻用在同级切换上会很怪。方向的实现不需要太复杂的代码只要在 CSS 里定义两套动画按我们之前说的>