View Transitions API实战:SPA页面切换动画的原理与落地 📅 发布时间:2026/9/15 13:32:58 👁 浏览次数: 1. SPA 切换动画看起来不难做起来全是坑做 SPA单页应用的朋友应该都有这种体会业务功能写起来很顺一到“页面切换动画”就卡壳。单页应用的优势是路由切换不刷新页面但代价是视觉上往往“啪”地一下整个内容区就换了尤其是在内容结构差异比较大的页面之间那种生硬感特别明显。我之前在项目里为了做一套过渡动画尝试过各种方案给路由出口套transition、在组件的beforeEnter/afterLeave钩子里手动拼 class、甚至引入整套动画库来管理切场状态。动效倒是做出来了但维护成本高得离谱得手工清场、处理并发路由、还得对付组件卸载时序稍微复杂一点的页面切换就开始出现“上一个动画还没走完下一个页面已经渲染出来”的错乱。后来换了思路直接用 View Transitions API 重构了页面切换动画效果比预期好太多。这个 API 最核心的价值就是它允许你在不手动管理任何过渡状态的前提下让浏览器自动完成两个视觉状态之间的平滑切换。开发者只需要告诉它“我要换 DOM”它负责截图、做补间、播放动画、清场。这篇文章我不想把 API 文档抄一遍而是从实际接入项目的角度把我从基础原理到踩坑排查的全过程整理出来希望对正在做 SPA 页面切换动画的你有用。1.1 传统动画方案最大的问题是“状态管理”先说清楚传统方案为什么麻烦。SPA 的页面切换本质上是路由变化引来两件事旧页面视图销毁和新页面视图挂载。动画要做得自然就得让这两个过程重叠起来——旧页面边退场边让位新页面边进场边填充。听起来不复杂但落到代码层面你要处理的是新页面什么时候开始渲染如果太早旧页面的退场动画会被“顶掉”如果太晚过渡中间会有一段空白或者闪屏。动画期间用户又点了一次导航怎么办是否要中断当前动画还是排队执行组件卸载时动画相关的事件监听、定时器要不要清理怎么清理才不泄漏旧页面和新页面里都有一个“相似”的元素比如列表页的卡片和详情页的封面怎么让它们自然地“融合”而不是生硬地消失再出现这些问题分别对应生命周期、并发控制、资源清理、元素身份映射四个维度。传统方案里每一个维度都要自己实现而且实现方式高度依赖框架版本和页面结构。所以很多项目做到后面都放弃了完整过渡只在全局加一个 0.2 秒的淡入淡出形式上“有个动画”就行了。1.2 View Transitions API 把问题还原成了“截图差异”View Transitions API 的思路完全不一样。它不关心你的页面结构、框架类型、组件生命周期它只做一件事在 DOM 更新前截一张图在 DOM 更新后再截一张图然后在这两张图之间做过渡。这就像你用相机拍下了改造前的客厅再拍下改造后的客厅然后让两张照片在屏幕上完成交叉溶解——至于客厅里具体拆了几面墙、搬了几件家具你完全不用管。这个思路对 SPA 特别对症。因为 SPA 的页面切换本来就是“同一文档内的 DOM 大规模替换”View Transitions API 恰好擅长处理这种大规模差异。你不需要告诉它哪个组件进场、哪个组件退场它通过对整个视口做快照天然就把“差异”变成了“过渡”。这也是我后来决定全面重构动画方案的根本原因与其在框架层面和林林总总的生命周期做斗争不如把“状态差异可视化”这件事交给浏览器原生能力。2. 快照、图腾柱与合成浏览器帮你完成的补间我知道很多人已经看过document.startViewTransition()的用法但没搞懂它内部到底怎么工作。这块如果不吃透后面做自定义动画时一定会踩坑。我用最直白的方式拆一遍。2.1 一次页面切换浏览器内部做了什么当你调用document.startViewTransition(updateDOM)时浏览器内部会经历这么几个阶段捕获旧快照在回调执行之前浏览器把当前视口渲染结果截取下来。这里说的“截图”是浏览器渲染引擎层面的快照不是真正的位图文件但你可以按截图来理解。执行回调你的updateDOM函数被调用里面通常是路由跳转、数据更新、重新渲染等操作。这个回调可以是异步的函数返回的 Promise 会阻塞后续流程。捕获新快照回调返回的 Promise resolve 之后浏览器再把此时的新视口渲染结果截取下来。播放过渡两张快照以伪元素的形式放进 DOM 结构里浏览器根据样式表计算动画播放完成后把伪元素移除。有一点需要特别注意如果回调函数是异步的旧快照捕获后新快照会一直等直到回调返回的 Promise resolve。这意味着如果你想等数据请求回来再截新图完全可以但用户看到的是旧页面“冻结”在那里的画面。这个细节后面专门讲因为它直接影响数据加载场景下的动效节奏。2.2 伪元素树里藏着的“三明治”过渡期间浏览器会往文档根节点挂一组专用伪元素。它的嵌套关系是固定的文档里把它叫作“view transition 伪元素树”我习惯叫它“图腾柱”::view-transition整个过渡的容器默认铺满视口。::view-transition-group(root)一组过渡的父节点负责位置、尺寸、变换的“补间”。::view-transition-image-pair(root)用来承载下面两个快照的独立层级默认是混合模式。::view-transition-old(root)旧快照。::view-transition-new(root)新快照。“root”是默认分组名。如果你给某个元素设置了view-transition-name属性就会生成一组以该名字命名的伪元素树。这棵树里old和new的默认动画是淡出和淡入而group负责处理两个快照之间的位置、缩放、旋转等变换关系。理解这个“三明治”结构的意义在于你后续所有自定义动画本质上就是在覆盖这棵树上不同节点的 CSS 动画。比如你想让整个页面从右侧滑入改的是::view-transition-new(root)的keyframes想让共享元素有缩放效果改的是::view-transition-group(某个name)的动画时长和缓动函数。2.3 为什么这种设计对 SPA 特别友好SPA 应用有一个隐藏痛点页面切换时旧页面的 DOM 很可能已经被卸载了。如果你用传统方式做退场动画就必须赶在卸载之前把动画播放完或者把旧 DOM 保存起来手动处理稍有不慎就会出现“退场动画还没走完DOM 已被清理动画戛然而止”的尴尬。而 View Transitions API 的快照机制从根本上绕开了这个问题——旧快照捕获完成之后旧 DOM 就彻底和动画无关了无论它是卸载、修改还是保留都不影响后续的过渡播放。这等于把“生命周期协调”这个最大的坑帮你填平了。3. 接入 SPA 路由前先想清楚这四个决策点把 API 跑通很简单难的是想清楚“在哪儿接、怎么接”。这四个问题如果没想明白后面大概率要返工。3.1 在哪一层接 startViewTransition第一反应是在路由切换方法比如router.push/navigate外面包一层document.startViewTransition这是最自然的位置也是最高层级的拦截点。但要注意一个问题路由守卫里面的执行顺序。拿 Vue Router 举例beforeEach、beforeResolve、afterEach这些守卫分布在导航链路的不同节点如果你在最外层包了startViewTransition回调里执行router.push那么所有守卫都会在“旧快照已捕获、新快照未捕获”的窗口期中运行这是没问题的因为守卫同步或异步结束后 DOM 才真正更新。我建议的做法是封装一个统一的方法而不是在每个触发导航的地方手动包// navigation.js let pendingViewTransition null export async function navigateWithTransition(router, to, options {}) { // 不支持时直接走原生跳转 if (!document.startViewTransition) { return router.push(to) } // 如果上一次过渡还没结束先快进跳过避免动画互相打架 if (pendingViewTransition) { pendingViewTransition.skipTransition() } const transition document.startViewTransition(async () { await router.push(to) }) pendingViewTransition transition try { await transition.finished } finally { if (pendingViewTransition transition) { pendingViewTransition null } } }这样封装之后业务代码里只需要把原来的router.push(/detail/123)改成navigateWithTransition(router, /detail/123)即可。将来如果要做全局开关、埋点或降级处理都只改这个文件。3.2 异步路由与回调完成时机怎么协调SPA 的路由切换经常伴随异步操作按需加载路由组件、请求页面数据、读取本地状态等。这里有一个微妙的分寸问题startViewTransition的回调返回的 Promise 决定新快照捕获的时机如果你在回调里await了网络请求那么整个过渡会被拉长用户会看到一个“旧页面卡住不动”的空白期。我的建议是区分“路由级异步”和“数据级异步”。路由组件异步加载应该放在过渡回调里await否则新视图还没渲染新快照捕获的是空白页。页面内部的业务数据请求不建议阻塞过渡回调。可以先让页面骨架Skeleton渲染出来数据回来后以“增量更新”的方式填充。这样动画节奏快用户不会感觉到延迟骨架屏的加载过程本身也是视觉反馈的一部分。const transition document.startViewTransition(async () { await import(./views/DetailPage.vue) // 等待组件 chunk 就位 router.push(/detail/${id}) // 这之后框架渲染骨架屏出现 // 注意这里不 await 业务数据请求 })3.3 哪些元素该享受 view-transition-nameview-transition-name是让页面里的某个元素拥有独立过渡身份的属性它是实现“共享元素过渡”的关键。但这个属性不是越多越好每一个命名的元素都会生成独立的伪元素树节点命名太多会导致性能下降而且命名冲突会有诡异的视觉问题。我总结的命名原则只在旧页面和新页面存在“对应关系”时命名。比如列表页的卡片对应详情页的封面它们本该是同一个视觉实体给它俩相同的名字浏览器会自动在过渡中用位置和尺寸变化把两个元素“连”起来。每个名字在任意一个 DOM 状态下都必须是唯一的。两个元素同时用了同一个名字浏览器只会选取其中一个做分组另一个会被忽略视觉上的表现就是“该动的没动不该动的跳了”。不要用随机数作为名字。随机数每次渲染都变旧快照和新快照之间无法匹配名字就失去意义了。用业务 ID、路由路径等稳定标识。有时候你需要动态设置名字可以通过元素的 style 属性直接操作这样就不用在 CSS 文件里写死// 列表卡片点击时把“即将进入详情页”的卡片命名 const cardElement event.currentTarget cardElement.style.viewTransitionName card-${item.id}3.4 快速点击和并发过渡要不要处理答案是一定要处理。用户不会按照你的动画节奏来操作连续点击、快速切路由是常态。如果第二次点击时上一次过渡还在进行浏览器会有明确的策略它会强制跳过当前动画再开始新的过渡。但如果你在代码里没做任何并发控制像这样的调用顺序会导致不可控的结果。我的做法分两层最外层用pendingViewTransition判断有则先skipTransition()内层在路由守卫里加一个忙碌标记防止同一个导航目标被重复触发。skipTransition()的好处是它不会阻止 DOM 更新——它只是瞬间跳过动画播放这和安全地“取消动画”是两回事。4. 三个真实页面切换案例列表进详情、表单翻页、全屏弹层光讲 API 太虚直接看三个我在实际项目中落地的案例。这三个场景基本覆盖了 SPA 页面切换的大多数需求。4.1 案例 A列表卡片自然放大为详情封面这是最常见也最能体现 View Transitions API 价值的场景列表页点击卡片进入详情页卡片“放大”成详情页封面。列表页侧的代码function CardList({ items, onNavigate }) { const openDetail (item) { const card document.querySelector([data-card-id${item.id}]) card?.style.setProperty(view-transition-name, card-${item.id}) const vt document.startViewTransition(() { onNavigate(/detail/${item.id}) }) vt.finished.finally(() { card?.style.removeProperty(view-transition-name) }) } return ( div classNamecard-list {items.map(item ( div key{item.id} classNamecard >function DetailPage({ id, article }) { return ( div classNamedetail-page img classNamedetail-cover style{{ viewTransitionName: card-${id} }} src{article.cover} alt{article.title} / div classNamedetail-content{article.content}/div /div ) }这时浏览器会自动识别旧快照里有一个名为card-123的元素新快照里也有一个名为card-123的元素于是它会把这两者当作“同一个元素”在过渡中做位置、尺寸的连续变化——卡片看起来就是一路放大飞到封面位置的。CSS 部分只需要微调动画节奏默认的位移和缩放已经足够::view-transition-group(card-123) { animation-duration: 0.4s; animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); }这里有一个细节如果列表卡片和详情封面尺寸差异很大默认的过渡效果可能有点“平”你可以给::view-transition-image-pair加上isolation: isolate避免新旧快照互相影响透明度。4.2 案例 B多步表单按方向滑动翻页多步表单的“下一步”“上一步”需要一个有方向感的滑动切换否则用户感受不到页面层级变化。这个场景其实不需要共享元素直接用默认的根级过渡配合两组方向相反的 keyframes 即可。/* 前进旧页向左滑出新页从右侧滑入 */ ::view-transition-old(root) { animation: slide-out-left 0.35s ease both; } ::view-transition-new(root) { animation: slide-in-right 0.35s ease both; } /* 后退反过来 */ .view-transition-backward::view-transition-old(root) { animation: slide-out-right 0.35s ease both; } .view-transition-backward::view-transition-new(root) { animation: slide-in-left 0.35s ease both; } keyframes slide-out-left { to { transform: translateX(-25%); opacity: 0.6; } } keyframes slide-in-right { from { transform: translateX(25%); opacity: 0; } to { transform: translateX(0%); opacity: 1; } }前进和后退的方向控制需要给根元素动态加一个方向标记。比如在路由切换前判断新路由的层级和旧路由的层级关系如果是“更深”的页面则不加类如果是“返回”则给document.documentElement加上view-transition-backward类过渡结束后移除async function formStepTransition(toStep) { const direction toStep currentStep ? forward : backward if (direction backward) { document.documentElement.classList.add(view-transition-backward) } const vt document.startViewTransition(() { setCurrentStep(toStep) }) await vt.finished document.documentElement.classList.remove(view-transition-backward) }这里比较坑的是类名的作用时机。class必须在startViewTransition调用之前加好因为旧快照捕获时会根据当时的类名确定动画方向如果你在回调里才加旧快照的动画已经定死了。4.3 案例 C列表视图与网格视图的原子级补间这个案例的场景是同一批卡片数据用户通过一个按钮在“列表布局”和“网格布局”之间切换。传统做法是用 CSS 过渡给每个卡片的width、height、margin做动画但卡片数量多了之后逐个写过渡样式非常痛苦而且顺序错乱。View Transitions API 在这个场景下反而更简单。每个卡片都分配稳定的名字切换布局时视图结构不变只是 CSS 类变了那么每张卡片在新旧快照中都是在场的浏览器会自动对每张卡片的位置、尺寸做补间。从列表到网格的切换看起来就是卡片们各自移动到新位置、调整大小整个过程不需要任何针对单张卡片的动画代码。const toggleLayout async () { if (!document.startViewTransition) { setLayout(layout grid ? list : grid) return } const vt document.startViewTransition(() { setLayout(layout grid ? list : grid) }) await vt.finished }CSS 里只需要保证所有卡片都有唯一的view-transition-name。比如卡片本身用一个稳定的数据属性>.card::view-transition-name { /* 无效写法只是提个醒view-transition-name 不支持这样批量动态生成 */ }实际上view-transition-name目前不支持根据元素的 data 属性自动生成不同的值所以这一步还是要用 JS 循环设置cards.forEach(card { card.style.viewTransitionName card-${card.dataset.id} })这个案例给我的感受是View Transitions API 特别适合处理“同一批元素在不同布局下的位置变化”这种场景用其他动画方案实现成本极高用它能省掉一大半代码。5. 自定义进出场动画与异步数据请求的相处之道到这里你已经能做出基础的共享元素过渡和滑动过渡了。接下来是真正拉开差距的部分怎么让动画和异步数据请求配合得足够聪明怎么控制动画的进出场方向。5.1 让动画等数据还是让数据等动画这是个典型的两难。如果让动画等数据代价是旧页面会冻结一段时间用户感觉到明显的延迟如果让数据等动画又可能出现新页面内容还没加载完动画已经播完露出一个半空的页面。我的经验是分情况对待数据是“打开页面必需的首屏关键数据”建议在过渡回调里await哪怕多等两三百毫秒。因为动画的意义是让用户感知连贯性如果动画播到一半页面还是空的连贯感反而更差。数据是“页面级但非首屏、可以后加载”的内容比如评论列表、推荐位、广告位就绝对不要阻塞过渡。先渲染骨架和首屏内容等动画结束之后再用fetch或组件内钩子去拉取。判断标准很简单你问自己一句——“动画结束的那一刻用户第一眼看到的内容是不是完整的”如果是就让它等如果动画结束时内容本来就需要加载等待那就别让动画拖着。5.2 用 ready 和 updateCallbackDone 控制节奏View Transition 实例上有两个 Promise 方法很多人用不上但在异步场景里它们几乎是必需的。ready当新快照捕获完成、伪元素树已挂载时触发。也就是说从此刻起动画即将开始播放。updateCallbackDone当你在startViewTransition回调里写的 DOM 更新完成时触发。注意它不等到动画结束只代表 DOM 改完了。有一种常见场景你希望在动画真正开始播放之前让某个 loading 文案消失、或者让某条横线先渲染出来。这时你可以在ready里做这些操作const vt document.startViewTransition(async () { await fetchDetailData(id) setDetailData(data) }) await vt.ready // 此时伪元素树已就位页面即将开始动画 hideGlobalLoadingSpinner()反过来如果你想在 DOM 更新完成后立刻做点“不可见”的事比如预加载下一个页面的数据用updateCallbackDone更合适因为它不会等待动画播放。5.3 进出场方向如何根据页面层级动态变化如果你的应用是多级导航结构比如“首页 → 分类页 → 详情页”你会希望“进入”和“返回”的动画方向相反并且方向感要和层级深度一致。最稳妥的做法不是写死 CSS而是根据当前页面在导航栈中的位置动态计算。我采用过一种方案维护一个pageDepthMap记录每个路由的层级深度切换时比较新旧深度const DEPTH { /: 0, /category: 1, /detail: 2, } router.afterEach((to, from) { const fromDepth DEPTH[from.path] ?? 0 const toDepth DEPTH[to.path] ?? 0 const goingDeeper toDepth fromDepth const root document.documentElement root.classList.toggle(view-transition-deeper, goingDeeper) root.classList.toggle(view-transition-backward, !goingDeeper) })CSS 里就可以把方向和根类绑定起来往深了走是从右侧滑入往回退是从左侧滑入视觉逻辑和移动端 App 的导航习惯保持一致。6. 降级策略和无障碍动画是增强不是主功能任何动效方案都必须考虑降级路径。动画始终是体验增强项如果为了动画牺牲了可用性那就本末倒置了。6.1 功能检测与老浏览器分支document.startViewTransition是一个需要特性检测的 API千万别直接调用。最稳妥的做法是在封装层做检测不支持的浏览器直接走原生的路由切换流程用户看到的是普通跳转虽然没动画但功能完好。function isViewTransitionSupported() { return ( typeof document ! undefined typeof document.startViewTransition function ) }关于浏览器兼容性目前主流现代浏览器都陆续跟进了支持。但不同浏览器的实现细节仍然有差异尤其是一些边缘情况。所以我一直保持一个习惯在网上搜“当前各大浏览器对 View Transitions API 的支持情况”确认版本节点并始终以“可用性检测 原生回退”为基线千万不要假设环境一定可用。6.2 尊重减少动态效果偏好操作系统里有一个“减少动态效果”prefers-reduced-motion选项很多用户开启了这个设置动画和滚动动效都可能引发不适。好的动效方案必须尊重这个偏好。实现有两层第一层是 CSS 层面用媒体查询把动画时长缩短到接近 0或者在 enabled 时干脆不触发过渡第二层是 JS 层面检测到偏好之后直接跳过startViewTransition调用走普通跳转。media (prefers-reduced-motion: reduce) { ::view-transition-old(root), ::view-transition-new(root), ::view-transition-group(*) { animation: none !important; } }const prefersReducedMotion window.matchMedia( (prefers-reduced-motion: reduce) ).matches if (prefersReducedMotion) { return router.push(to) // 直接跳转 }这属于“一条规则终身受益”的投入能避免产品被无障碍使用者诟病。移动端 WebView 里这个偏好通常是沿袭系统设置的需要注意。6.3 页面性能与截图成本View Transitions API 本质是两次视口快照虽然浏览器做了大量优化但遇到超大 DOM、视频框、Canvas 等高成本渲染内容还是会有性能损耗。我的经验是动画时长不要超过 400ms更长的动画并不会让页面显得更流畅反而更容易暴露卡顿。尽量避免在动画期间对新旧快照里的元素做filter、backdrop-filter这类高价属性实测在移动端很容易掉帧。如果页面里有video或高频刷新的图表命名元素时要格外小心考虑只给容器命名而不是直接命名视频或 Canvas 本身。7. 我在接入过程中踩过的几个坑最后这部分是我真正想写的内容。前面几章是方法论这一章全是血泪。你看完如果能少踩一个我就没白写。7.1 动态命名的唯一性问题我最早在列表页里给每个卡片命名时用的是 URL 路径中带的参数来生成名字比如card-${route.query.id}。看似没问题但只要一个路由下有多张卡片就会瞬间出现多个相同的view-transition-name。结果就是浏览器在快照对比时完全不知道哪个对应哪个列表里的卡片会乱跳。后来我改成把业务 ID 也放进去而且命名规则里强制包含一个足够唯一的字段const name card-${pageId}-${item.id}排查这种问题有一个笨但有效的办法在过渡的ready钩子里打印当前页面的view-transition-name集合肉眼对照是不是有重复。7.2 过渡会把滚动位置“吞掉”SPA 路由切换里的滚动位置恢复是个老毛病进入详情页之后再返回列表页希望回到之前的滚动位置。普通场景下浏览器或者路由库能帮你恢复。但一旦套上 View Transitions API滚动位置恢复会变得不可靠。原因很简单新快照捕获时如果页面被强制滚动到顶部那新快照对应的就是顶部画面你即使恢复了滚动位置画面也已经写进快照里了。我的处理方式是把“滚动位置恢复”放到startViewTransition回调之后并且在新快照捕获之前完成。也就是说在回调里先调用框架提供的滚动恢复方法再触发数据渲染。这样新快照捕获到的就是正确滚动位置下的画面。如果你用的是 Vue Router 的scrollBehavior需要确认它在过渡回调的异步链条里执行完毕。我之前在这个问题上花费了不少时间最终的简化做法是不在scrollBehavior里做恢复而是在路由切换完成、页面组件挂载后用window.scrollTo手动恢复记录的位置并在过渡开始前把位置记录存在 sessionStorage 里。7.3 过渡中途出错的清理startViewTransition回调里如果抛出了异常处理方式比较隐蔽。你不要以为异常只是中断了动画它的实际表现是动画会被中断但 DOM 更新的结果仍然保留着而finishedPromise 会一直不 resolve 或者进入 rejected 状态。如果不对finished做兜底处理页面可能出现“动画已经停了但状态还卡在过渡中”的情况。我的做法是在封装层统一管理const vt document.startViewTransition(async () { try { await router.push(to) } catch (err) { vt.skipTransition() throw err } }) vt.finished.catch(() { // 清理所有过渡期间的临时 class 和命名 cleanupTransitionState() })这里skipTransition()是有讲究的它会立即结束动画播放但不会取消 DOM 更新。也就是说即使路由跳转过程中出错DOM 也已经切换到了新状态此时快速跳过动画让页面直接显现总比卡在半黑屏要强。7.4 页面卸载时别忘了移除命名上一个案例里我给点击的卡片设置了一个临时的view-transition-name过渡结束后在finished里移除。这个操作不是可选的。如果忘记移除这个 element 即使已经被卸载它留下的命名规则在下一个页面里仍然可能生效——尤其是新页面里如果有同名的元素会被莫名其妙地接管过渡身份产生完全无法理解的动效。类似的“临时状态”还有我在表单滑动里提到的根类名view-transition-backward这类状态必须保证在动画结束、组件卸载、路由守卫等多个时机都能被清理。我把这类逻辑收敛在一个createTransitionScope辅助函数里每次创建过渡都自动绑定清理回调避免散落在业务代码里。坦白说View Transitions API 并不复杂整个接入过程里真正花时间的不是 API 本身而是想清楚“什么状态该由 API 接管、什么状态必须自己管”。我做完这一轮重构之后最大的感受是以前写页面切换动画每天都在跟“状态”较劲——组件的、路由的、动画库的现在大部分过渡状态都收归浏览器管理代码量少了一半视觉稳定性还提高了不少。如果你正被 SPA 的页面切换动画折磨建议先拿一个非核心页面把路由切换包一层试试。等找到手感了再决定要不要全面铺开。我个人经验是从列表到详情这个最简单的共享元素过渡做起性价比最高。