ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

View Transitions API实战:为SPA页面切换添加丝滑过渡动画

View Transitions API实战:为SPA页面切换添加丝滑过渡动画 给 SPA 加页面切换动画这件事我已经惦记了很久。SPA 的体验优势是路由切换不用刷新页面但换来的是页面瞬间“换脸”列表跳到详情、详情退回列表整个过程没有任何空间过渡用户眼睛根本追不上页面变化。直到 View Transitions API 陆续进入主流浏览器我才觉得这问题终于有了原生解一个能在 DOM 更新的同时自动捕获旧页面快照、渲染新页面快照、并让两者按 CSS 动画平滑切换的浏览器级能力。这篇文章不聊空泛的概念就讲清楚它背后的原理、怎么接入 React/Vue 路由以及我在实际项目里踩过的坑。1. 这个 API 到底解决了什么问题1.1 为什么 SPA 页面切换会“生硬”SPA 的路由本质是“同一个 HTML 文档里的视图替换”。用户点了详情按钮框架把当前组件卸载、挂载新组件浏览器直接渲染出新页面。对用户来说这个操作快是快但没有层级感、没有方向感就像幻灯片在一瞬间被抽走再放上另一张。很多团队会自己造轮子来解决比如给旧页面加一个离场动画类等动画结束后再卸载再给新页面加入场动画。听起来不难真的做起来就知道痛苦你得手动管理两个页面的生命周期、处理路由取消/重复点击、控制滚动位置、还要保证动画被中断时不出现 UI 状态错乱。更麻烦的是列表页到详情页想要“同一张卡片放大变成头图”这种效果几乎要把 DOM 结构、定位逻辑全部绑在一起稍不留神就崩。而 View Transitions API 把“旧页面到新页面的过渡”这件事抽象成了浏览器原生能力框架开发者不用再手动协调卸载、挂载、动画时机了。只要在 DOM 更新的外层包一个document.startViewTransition()浏览器会自动完成截取旧状态快照、执行 DOM 更新、截取新状态快照、播放两个快照之间的过渡动画。它解决的正是 SPA 页面切换里最脏最累的这一段流程。1.2 View Transitions API 做了什么这个 API 的核心入口只有一个方法document.startViewTransition(updateDOM);updateDOM是你自己的函数里面负责改 DOM比如切换路由、切换状态、切主题。调用之后浏览器会先拍下当前页面的快照然后执行updateDOM等新页面渲染完成后拍下新快照最后用一组特定的伪元素把两张快照组合起来播放一段默认的交叉淡入淡出动画。好处很直观无需引入任何动画库CSS 就能控制全部动画细节。浏览器用快照做过渡动画过程中操作的是已经序列化的视图层对页面交互的侵入极小。很多高级效果比如让两个元素共享过渡都被封装成语义化的 API复杂度比手写低一个量级。在我实际体验里这个 API 对“页面切换”的场景近乎是量身定做。它不关心你用的是 React、Vue 还是原生 JS只关心你能不能把“DOM 更新”放进那个回调里。1.3 方案选型为什么不是 CSS 动画库有人会问Framer Motion、GSAP 不也能做页面过渡吗确实能但用法完全不一样。动画库适合做“局部元素级别的精细动画”比如侧边栏滑入、弹窗缩放、表单校验抖动等。一旦上升到“两个页面整体切换”动画库需要你显式控制两组页面元素的出场/入场时机还要处理组件卸载、异步数据渲染、浏览器前进后退等边界情况实施成本很高。View Transitions API 的优势在于“接管状态变化”。它的设计假定就是我们已经有了旧状态和新状态你只需关心两张快照之间如何演变化。因此代码量通常会减少到原来方案的十分之一而且在动画期间如果 DOM 又发生了变化浏览器会自己合并处理不容易产生撕裂感。这不是说动画库没用而是从“做整页切换”这个需求出发浏览器原生方案的性价比已经高到值得优先尝试。后续如果要在局部元素上做更花哨的效果再叠加动画库也不迟。2. 核心原理一次过渡的完整生命周期2.1 startViewTransition 背后到底发生了什么不理解原理直接写很容易写出“动画不触发”或者“黑屏闪一眼”的问题。我按实际调用过程拆解一下调用startViewTransition(callback)后浏览器先读取并冻结当前页面视觉状态生成旧快照。然后执行你传入的 callback在这个函数里完成 DOM 更新。浏览器等待新页面渲染完成再生成一份新快照。浏览器创建一组动画专用的伪元素树把旧快照和新快照分别放进旧/新视图层里。默认情况下旧快照执行淡出动画新快照执行淡入动画整个过程约 250ms。这里有个关键词是“快照”。它并不是真的截图而是对页面渲染结果的视觉捕获所以即使页面里有视频、动画快照也只会保留一帧图像。这也是为什么这个 API 特别适合做“从一个页面切换到另一个页面”的动画——两个静态画面之间的过渡天然比实时处理两个动态页面要简单可靠。细节上startViewTransition()返回一个ViewTransition对象包含三个 Promise 属性ready旧快照捕获完成、动画即将开始前触发适合在这个时机做一些交互动画补充。updateCallbackDone你的回调执行完成返回的 Promise resolve后触发。finished整个过渡动画播放完毕。用生活中最常见的类比你拍了一张旧房间的照片然后把房间重新布置了一遍再拍一张新照片最后用投影仪把两张照片叠在一起做交叉淡化过渡。浏览器干的正是“拍照”和“放投影”这两件事你只需要告诉它“房间重新布置好了没有”。2.2 用伪元素控制过渡动画过渡动画不是写死在 JS 里的而是通过一组动态生成的伪元素暴露给 CSS 控制。这是 View Transitions API 最精巧的设计之一。当一次过渡开始时浏览器会在根节点下动态生成如下结构非真实 DOM类型上是伪元素::view-transition整个过渡动画的根容器覆盖在页面最上层。::view-transition-group()管理某一组旧/新快照的整体位置、尺寸和变换。::view-transition-image-pair()同一组快照中旧快照和新快照的混合容器。::view-transition-old()旧快照层默认展示淡出效果。::view-transition-new()新快照层默认展示淡入效果。默认情况下没有被单独命名的内容都归入root组你可以直接控制根组的动画。比如我想让页面切换变成“旧页向左滑动退场新页从右侧滑入”只需要写::view-transition-old(root) { animation: slide-out 0.3s ease-in both; } ::view-transition-new(root) { animation: slide-in 0.3s ease-out both; } keyframes slide-out { to { transform: translateX(-30%); opacity: 0; } } keyframes slide-in { from { transform: translateX(100%); } to { transform: translateX(0); } }注意这里both填充模式会让动画开始前/结束后都保持关键帧状态避免出现动画播放前旧快照一闪、动画播放后新快照位置不对的意外。这类小细节临时调用时最容易漏。如果你希望某块内容不参与动画直接把它对应的动画设为none就好。比如侧边栏在切换页面时保持静止就在 CSS 里对根组下的侧边栏视图单独处理。2.3 命名视图与共享元素过渡如果只是整页淡入淡出那还不足以打动我。View Transitions API 真正吸引人的一点是“命名视图”你可以给页面里的任意元素设置view-transition-name它就会从整页快照中独立出来成为单独的一组视图拥有自己的过渡动画。最常见的应用就是列表页到详情页的效果点击列表卡片卡片放大过渡成详情页头图。实现思路是在列表页里给每张卡片设一个名称详情页里给头图设同样的名称然后浏览器就会自动对这两个元素做位置、尺寸、圆角等属性的渐变过渡。比如列表项这样设置div style{{ viewTransitionName: list-card-${id} }} /详情页的头图也设置成同一个名称div style{{ viewTransitionName: list-card-${id} }} /页面切换时浏览器会识别旧页面和新页面中名字相同的视图自动为它们生成一个group并把这个组从旧位置平滑变化到新位置、从旧尺寸过渡到新尺寸。命名视图很好用但需要特别注意同一时刻一个页面里只能出现一个同名的视图多个同名元素会被浏览器忽略导致过渡失效。列表页多个卡片时通常会用 ID 差异化名称如果两个页面里只有一个会同时存在同一个名称没问题但如果新旧页面同时渲染了多个同名元素就会出事。这块我在实战部分会再展开。3. React 实战给 React Router 加上页面切换动画3.1 最小可运行接入方案先说核心难点React 的状态更新是异步批处理的。如果你在startViewTransition的回调里直接setState浏览器执行完回调时DOM 可能还没更新新快照捕获的仍然是旧页面动画自然就失效了。解决办法是在回调里用flushSync强制 React 同步提交更新。以 React Router v6 为例import { flushSync } from react-dom; import { useNavigate } from react-router-dom; const navigate useNavigate(); function handleNavigate(to: string) { if (!document.startViewTransition) { navigate(to); return; } document.startViewTransition(() { flushSync(() { navigate(to); }); }); }这段代码是最小接入方案。flushSync会把navigate触发的状态更新同步推到 DOM 上这样浏览器在startViewTransition回调执行完后能立刻拿到新页面快照。我在实际项目里不会把切换逻辑散落在每个按钮里而是直接在路由层封装一个useTransitNavigate钩子export function useTransitNavigate() { const navigate useNavigate(); return useCallback((to: string) { if (!document.startViewTransition) { navigate(to); return; } document.startViewTransition(() { flushSync(() { navigate(to); }); }); }, [navigate]); }这样所有需要动画跳转的组件统一调用transitNavigate(/detail/123)代码简洁也不会漏掉兼容判断。一个容易踩的细节是startViewTransition的回调里如果抛了错误比如路由命中失败整个过渡会中断。所以有条件的话最好在回调外先做好路由检查或者在回调里用try/finally包一层确保异常时页面状态不会卡在半路。3.2 自定义全局滑动与缩放动画接入之后默认的交叉淡入淡出效果比较平淡。我一般会根据页面层级设计“前进时新页从右侧滑入、后退时旧页从左侧退场”的导航感。直接在全局 CSS 里定义根组动画::view-transition-old(root) { animation: slide-out-to-left 0.3s ease-in-out both; } ::view-transition-new(root) { animation: slide-in-from-right 0.3s ease-in-out both; } keyframes slide-out-to-left { from { transform: translateX(0); opacity: 1; } to { transform: translateX(-20%); opacity: 0.6; } } keyframes slide-in-from-right { from { transform: translateX(100%); } to { transform: translateX(0); } }很多人在动画里加上位移之后发现背景露出了白底其实是html/body的根背景没有快照进来。建议在启动动画前给::view-transition本身设置背景色或者保证body的背景在切换前后一致::view-transition { background-color: #f5f6fa; }另一个我比较常用的技巧是给旧页面加轻微缩放产生“页面在缩小退后”的感觉配合新页面放大进入视觉层次感会强很多。下面是两个方向都可以用的通用动画模板::view-transition-old(root) { animation: fade-and-shrink 0.3s ease both; } ::view-transition-new(root) { animation: fade-and-grow 0.3s ease both; } keyframes fade-and-shrink { to { opacity: 0; transform: scale(0.96); } } keyframes fade-and-grow { from { opacity: 0; transform: scale(1.02); } to { opacity: 1; transform: scale(1); } }注意动画时长控制在 250ms 到 350ms 之间最好。太短了看不出过渡层次太长了用户会有等待感尤其在高频路由跳转时这种迟钝感会被放大。3.3 列表页到详情页的共享元素过渡这个场景是我项目里最能体现 View Transitions API 价值的地方。以前要做一个“列表卡片放大变成详情头图”的效果需要手动记录卡片位置、计算头图位置、用 FLIP 动画去对齐流程长且容易抖动。用命名视图后代码量缩减了一大半。列表页里给卡片设置viewTransitionName{posts.map((post) ( Card key{post.id} onClick{() transitNavigate(/post/${post.id})} style{{ viewTransitionName: post-cover-${post.id} }} img src{post.cover} alt / div{post.title}/div /Card ))}详情页里给头图相同的参数PostHeader style{{ viewTransitionName: post-cover-${postId} }} /这样命名相同浏览器就会把列表卡片和详情头图识别为“同一个元素的变化”自动完成位置、尺寸、圆角的过渡。不过这里有几个坑我逐个说列表页其他卡片也会参与根组过渡。如果你只想突出当前卡片建议给列表项里不相关的区域设置view-transition-name: none或者用 CSS 让它们不参与动画。详情页头图如果用了object-fit: cover新快照里的裁切方式和卡片里的显示比例不一致时过渡过程中图片会被拉伸看起来不舒服。我的处理方法是给图片容器设置固定的aspect-ratio并且在过渡期间临时让图片对齐方式一致。卡片点击后如果发生了异步路由加载新页面渲染有延时命名视图的过渡可能会对着一个空白页面进行。这个问题下面的 Vue 部分我会给出通用解法。另外命名视图会创建独立图层页面里如果有position: fixed的弹层位于设置了view-transition-name的容器内部弹层的定位基准会变成这个容器视觉上飘忽不定。这属于 CSS 层面的“陷阱”排查时需要留意。4. Vue 实战给 Vue Router 加上页面切换动画4.1 通过包装 router.push 接入Vue Router 的接入思路和 React 不太一样。Vue 不需要flushSync但需要确保路由切换导致的 DOM 更新完成后再让浏览器捕获新快照。比较稳妥的做法是包装 Router 的push和replace方法import { nextTick } from vue; import router from ./router; if (document.startViewTransition) { const originalPush router.push.bind(router); router.push (...args) { return document.startViewTransition(async () { await originalPush(...args); await nextTick(); }).finished; }; const originalReplace router.replace.bind(router); router.replace (...args) { return document.startViewTransition(async () { await originalReplace(...args); await nextTick(); }).finished; }; }这里async回调返回一个 Promise浏览器会等待这个 Promise 完成后再捕获新快照。await originalPush(...args)保证路由导航完成await nextTick()保证 Vue 把新页面 DOM 更新到视图上两步都齐了再进入动画捕获。实际项目里我还会在router.afterEach里根据to.meta判断当前页面是否需要动画不需要的直接跳过包装这样登录页、错误页这类场景可以保持瞬时切换避免动画过度。还有一个和权限相关的点SPA 项目开发中接口鉴权流程JWT 验证码那套东西通常发生在路由的前置守卫里如果路由跳转被守卫拦截到登录页那么包装后的动画也会执行一次“切到登录页”的过渡。为了避免误导用户我建议在守卫拦截逻辑里提前判断拦截场景不触发动画只有真正到达目标页时才进入过渡流程。4.2 与异步数据加载配合含鉴权请求的坑SPA 页面切换时详情页的数据往往需要从接口请求。如果直接切过去新快照捕获的是 loading 态的空页面过渡结束、数据加载完又突然弹出内容视觉上反而更突兀。我的做法是“先拿数据再触发过渡”。也就是在点击跳转时先请求接口数据到位后再启动startViewTransitionasync function goToDetail(id) { // 先预取详情数据带上必要的鉴权信息JWT 等 const detail await fetchDetail(id, { token: authStore.token }); if (!document.startViewTransition) { router.push({ name: detail, params: { id }, state: { detail } }); return; } document.startViewTransition(async () { await router.push({ name: detail, params: { id }, state: { detail } }); await nextTick(); }); }这个方案的好处是动画启动时新页面已经拿到完整数据过渡完成后直接看到最终内容。但要注意预取期间用户可能又点击了其他项目出现竞态后发请求先返回、先发请求后返回最终展示的数据可能不是用户最后一次点击的目标。解决办法是在预取前生成一个自增序列号每次请求都携带当前序列号返回时只有序列号最新的才允许触发过渡let requestSeq 0; async function goToDetail(id) { const currentSeq requestSeq; const detail await fetchDetail(id, { token: authStore.token }); if (currentSeq ! requestSeq) return; // 不是最后一次请求丢弃 // ... 后续触发动画 }另外如果请求失败我建议不要启动动画直接留在原页面弹提示避免用户看到一次“切到一半又弹回来”的奇怪体验。这种降级策略对整体观感影响很大。5. 常见问题与排查技巧实录5.1 为什么动画就是不触发最常见的几个原因按出现频率排序浏览器不支持startViewTransition。目前 Chrome/Edge 较新版本、Safari 18 已经支持Firefox 的支持度还在路上。代码里没有判断document.startViewTransition是否存在就直接调用轻则控制台报错重则整个路由切换都失败。稳妥做法是必须做特性检测和降级。回调里没有同步更新 DOM。React 项目里尤其容易踩直接在startViewTransition(() setState(...))里写以为自己传了函数就够了实际上 setState 是异步的浏览器捕获新快照时 DOM 可能还没变。解决方案就是前面说的flushSync。回调还没执行完页面就被外部操作打断比如弹窗关闭、导航取消等。这种情况需要自己在代码里加状态标记避免在页面已经卸载时报错。页面同时有多个startViewTransition在排队。浏览器默认会等到前一个过渡结束再执行下一个如果连续点击不同入口动画会被合并或者跳过看起来就像“没触发”。排查这类问题最快的办法是在startViewTransition返回的ready、updateCallbackDone、finished三个 Promise 上分别挂.then()打印日志看到底卡在哪一步const transition document.startViewTransition(() { flushSync(() navigate(/next)); }); transition.ready.then(() console.log(旧快照已捕获)); transition.updateCallbackDone.then(() console.log(DOM 已更新)); transition.finished.then(() console.log(动画播放完成));这个日志习惯帮我解决了不少“以为触发了但实际上没触发”的问题。5.2 过渡结束瞬间出现的闪烁/白屏常见表现是动画播放完成后新页面“闪”了一下再稳定。这个闪烁通常来自几个方向图片等媒体资源在新快照捕获时还没加载完成快照里是空白占位等资源加载完又显示出来产生明显跳变。解决方案关键图片提前预加载用new Image()预热或者对列表页的图片设置loadingeager。新页面里有字体切换导致文字宽度变化快照和最终布局不一致。可在启动过渡前用document.fonts.ready等字体加载完成。过渡结束时浏览器把新快照从动画层“提交”到真实页面如果某些元素带动画属性且填充模式不对也会出现一瞬间的视觉回跳。把animation-fill-mode设置为both能缓解大部分问题。白屏则多半是::view-transition根容器没有背景色。如果body的背景是透明的动画开始时旧页面快照一隐藏底层露出浏览器默认的白色或深色当新旧页面背景都是浅色时这条不明显一旦新页面是深色主题白底闪一下特别刺眼。所以我会给过渡根容器设置一个和页面主题匹配的底色。5.3 兼容性降级与减少动画偏好第三方浏览器或旧版本浏览器不支持这个 API 时页面功能不能被动画绑架。我习惯抽一个统一封装所有调用走同一条入口export function startViewTransition(callback) { if (document.startViewTransition) { return document.startViewTransition(callback); } callback(); return Promise.resolve(); }把兼容判断收敛在一个函数里整个项目不用到处写分支。对于用户开启了“减少动态效果”偏好prefers-reduced-motion的场景也应该主动关闭过渡动画。做法很简单media (prefers-reduced-motion: reduce) { ::view-transition-old(root), ::view-transition-new(root) { animation: none; } }同时 JS 侧也不需要再启动过渡了检测到用户偏好后直接用降级路径const prefersReducedMotion window.matchMedia( (prefers-reduced-motion: reduce) ).matches; if (!prefersReducedMotion document.startViewTransition) { document.startViewTransition(callback); } else { callback(); }这个偏好检测不仅是无障碍要求也直接影响动画相关代码的结构放在早期封装里最省事。5.4 性能踩坑与前进后退的处理页面很大的项目里整个页面参与快照过渡会产生很大的合成层低端手机上动画掉帧非常明显。我的经验是把参与过渡的区域尽量缩小。比如只在内容区做动画侧边栏、顶栏这些固定元素通过设置view-transition-name: none排除在过渡之外或者干脆让它们不参与根组动画。这样快照合成的面积小动画帧率能明显提升。另一个和路由强相关的问题是浏览器前进后退。用户点浏览器的返回按钮时SPA 框架内部是通过popstate事件感知的绕过了我们包装好的router.push动画自然不会被触发。如果你希望前进/后退也有动画需要额外监听popstate但要判断方向比较麻烦。我的做法是点击场景统一走包装方法能拿到明确目标路由触发动画浏览器原生后退则不做特殊处理直接交给浏览器默认行为。这样至少保证了主要交互路径的体验又不会在系统级返回时出现方向错误。性能方面实测下来用transform和opacity做动画最稳尽量避免在过渡动画里使用filter或backdrop-filter后者在低端设备上很容易掉到 20fps 以下。能少给元素加动画就少加快照过渡的好处是它本身就在合成器层面工作你只要别在动画帧里触发大量布局计算就行。6. 最后说几点个人体会在真正用到 View Transitions API 之前我一度觉得页面切换动画是个“锦上添花”的事情优先级不高。但经过几个项目实践我更倾向于把它看成“体验基础设施”的一部分。一个跳转明确、过渡自然的 SPA确实会让用户觉得应用更“顺滑”、更接近原生客户端这种感官差异往往比某个按钮的微交互更容易被普通用户感知到。这个 API 目前还在快速演进阶段跨浏览器支持度还不是百分百完美。我个人的建议是优先做兼容降级和减少动画降级用统一封装把入口收敛好然后把动画当作渐进增强去对待。支持的浏览器体验拉满不支持的浏览器功能不受影响。最后分享一个小技巧把startViewTransition封装成完全不影响业务代码的工具函数接入成本会降到极低。后面项目规模大了、页面多了再想统一调整动画策略只需要改一个文件里的一小段逻辑就够了。这比我最初把动画逻辑散落在每个路由跳转里省心太多。
返回列表