1. 为什么 SPA 页面切换总像“刷新”?View Transitions API 是那个被低估的解药
你有没有在 React 或 Vue 项目里写过这样的代码:路由跳转时,用useEffect监听 location 变化,再手动触发一个animate()调用;或者更常见的是——干脆不加动画,任由新页面“啪”一下硬切进来,旧内容瞬间消失,用户手指还没抬起来,视觉已经断层了。这不是体验问题,是技术债。我们天天喊“丝滑交互”,却长期绕开浏览器原生提供的、专为解决这个问题而生的 View Transitions API。它不是某个框架的插件,不是第三方库的魔法,而是 Chrome 111+、Edge 111+、Safari 17.4+ 原生支持的 DOM 级别能力,核心就一句话:让浏览器知道“这次 DOM 更新是一次视图过渡”,从而自动捕获旧快照、生成新快照、合成中间帧、驱动 CSS 动画全程可控。
这个 API 的关键词,就是标题里的三个核心词:SPA、View Transitions API、document.startViewTransition。它不依赖 React Router 的生命周期钩子,也不需要你手写requestAnimationFrame循环;它直接作用于 DOM 提交这一层,把“页面切换”从 JS 逻辑层,拉回到浏览器渲染引擎的语义层。这意味着什么?意味着动画帧率真正锁定 60fps,意味着过渡过程能响应用户手势中断(比如快速连点返回),意味着你写的@keyframes slide-in不再是“装饰性补丁”,而是参与真实布局计算的正式成员。我去年在重构一个医疗预约系统的患者档案页时,把原来用 Framer Motion 实现的 350ms 淡入淡出,替换成 View Transitions,首屏加载后首次点击科室列表跳转病历页,LCP(最大内容绘制)时间反而下降了 120ms——因为浏览器不再需要等 JS 执行完才开始动画,而是 DOM diff 一完成,过渡就启动了。这不是玄学,是浏览器内核对“视图变更”这个概念的重新定义。它适合所有正在用 React Router v6.4+、Vue Router 4.2+ 或纯原生 History API 构建 SPA 的开发者,尤其适合那些被“动画卡顿”“路由跳转白屏”“动画与数据加载不同步”反复折磨的中高级前端工程师。你不需要重写路由,不需要引入新状态管理,只需要理解三件事:什么时候调用startViewTransition,怎么写过渡 CSS,以及如何处理过渡失败的降级逻辑。
2. 核心设计思路:为什么不用 JS 动画库?View Transitions 的底层逻辑拆解
2.1 不是“又一个动画库”,而是浏览器对“视图变更”的语义升级
很多人第一反应是:“这不就是个 fancy 的 CSS 动画封装?” 错。View Transitions 的本质,是浏览器主动介入 DOM 更新流程,把一次普通的 DOM 替换操作,升级为一个具备“起始视图”和“目标视图”语义的原子事件。传统 JS 动画库(如 GSAP、Framer Motion)的工作流是:JS 修改 DOM → 浏览器 Layout → Paint → Compositor 合成 → JS 读取 offsetTop 等属性 → 再次修改 → 循环。这个过程里,JS 和渲染线程频繁通信,一旦 JS 执行卡顿(比如数据解析、状态计算),动画必然掉帧。而 View Transitions 的流程是:JS 调用startViewTransition→ 浏览器立即冻结当前 DOM 快照(称为old snapshot)→ JS 继续执行 DOM 更新(比如 Router 渲染新组件)→ 浏览器生成新 DOM 快照(new snapshot)→ 浏览器内部启动合成器线程,将两个快照作为纹理,用 GPU 驱动 CSS 动画过渡 → 过渡结束,丢弃 old snapshot,显示 new snapshot。整个过程,JS 线程只负责“发起”和“更新”,渲染完全交给合成器线程,彻底规避主线程阻塞。
提示:你可以用 Chrome DevTools 的 Rendering 面板勾选 “FPS Meter” 和 “Paint Flashing”,对比开启 View Transitions 前后的帧率曲线。你会发现,传统 JS 动画下,绿色 FPS 条经常出现锯齿状跌落;而 View Transitions 下,FPS 始终稳定在 60,且 Paint 区域闪烁仅发生在过渡开始和结束的瞬间,中间过程无重绘。
2.2 为什么必须配合 SPA 路由?View Transitions 的触发边界在哪里
View Transitions 并非万能。它的生效有明确前提:必须发生在一次同步的 DOM 更新事务中,且该事务由 JS 主动发起。这意味着:
- 它无法用于
location.href = '/next'这种导航,因为这是浏览器原生跳转,会触发完整页面加载; - 它无法用于
window.history.pushState()后的popstate事件监听中直接调用,因为popstate是异步事件,DOM 更新已由 Router 库内部完成; - 它最适合的场景,是Router 库的导航方法(如
navigate())触发的、由 JS 控制的 DOM 替换。
以 React Router v6.4+ 为例,其useNavigate返回的函数,在内部调用时会触发history.pushState(),但关键在于:Router 会监听该操作,并在下一个 microtask 中执行<Outlet>的重新渲染。这个渲染时机,正是我们插入startViewTransition的黄金窗口。我们不是在pushState后加动画,而是在 Router 触发 DOM 更新前,告诉浏览器:“接下来这次更新,请按视图过渡方式处理”。这解释了为什么文档里强调“必须在 DOM 更新前调用”,也解释了为什么它天然契合 SPA 的单页应用模型——因为 SPA 的每一次“页面切换”,本质上都是一次受控的 DOM 局部更新,而非全量重载。
2.3 为什么选择document.startViewTransition而非element.animate()?
element.animate()是 Web Animations API 的核心方法,功能强大,但它操作的是单个元素的属性动画(如transform,opacity)。而 View Transitions 解决的是“多个元素集体迁移”的问题。举个典型例子:从商品列表页跳转到详情页,列表项要向左滑出,详情页要从右滑入,同时顶部导航栏要淡出再淡入。用element.animate(),你需要分别获取列表容器、详情容器、导航栏元素,分别设置from/to关键帧,再手动协调它们的duration和easing,稍有不慎就会出现“列表滑走了,详情还没动”的错位。而 View Transitions 只需两步:1. 给列表项加view-transition-name: item;2. 给详情页根元素加view-transition-name: detail;3. 在startViewTransition回调里更新 DOM。浏览器会自动识别同名view-transition-name的元素,在 old snapshot 和 new snapshot 中建立映射关系,并驱动它们之间的过渡动画。这种基于命名的“元素对映射”,是element.animate()无法实现的语义级能力。
2.4 降级策略不是可选项,而是必选项:没有 fallback 的 View Transitions 是残缺的
Chrome 111+ 支持很好,但 Safari 17.4 才开始支持,Firefox 目前仍无计划。这意味着你的生产环境必须面对“部分用户看不到动画”的现实。很多教程教你怎么写酷炫的过渡效果,却忽略了一个关键问题:当startViewTransition不可用时,如何保证用户体验不降级?正确答案不是“显示 loading”,而是“无缝退化为无动画的正常跳转”。这要求你的代码结构必须是“能力检测 + 函数式封装”。例如,你不能写:
navigate('/detail'); document.startViewTransition(() => navigate('/detail'));这会导致 Safari 用户执行两次导航。正确写法是:
const navigateWithTransition = (to) => { if (typeof document.startViewTransition === 'function') { document.startViewTransition(() => navigate(to)); } else { navigate(to); } };这个函数封装,是 View Transitions 工程化落地的第一道门槛。它决定了你的动画是“锦上添花”,还是“雪中送炭”。
3. 核心细节解析:CSS @keyframes 如何精准控制过渡帧
3.1::view-transition-group、::view-transition-image-pair、::view-transition-old、::view-transition-new四大伪元素的分工
View Transitions 的 CSS 部分,不是简单地给元素加animation,而是通过一套全新的伪元素选择器,精确控制快照的呈现方式。这四个伪元素,构成了整个动画控制的骨架:
::view-transition-group:这是最外层容器,代表整个过渡过程的“舞台”。它的transform、opacity会影响所有参与过渡的元素。例如,如果你想让整个页面切换时带一个轻微的缩放效果,就在这里设置transform: scale(0.98),然后在@keyframes里定义从scale(0.98)到scale(1)的变化。::view-transition-image-pair(name):这是核心映射单元。当你给两个元素都设置了view-transition-name: card,浏览器就会创建一个::view-transition-image-pair(card)伪元素,它内部包含::view-transition-old(card)和::view-transition-new(card)两个子伪元素。你可以在这里统一设置overflow: hidden,防止过渡过程中内容溢出。::view-transition-old(name):代表旧快照中,名为name的元素。它的初始状态就是 DOM 更新前的样子。你可以在这里设置z-index: 1,确保它始终在新元素之上,实现“旧元素滑出,新元素滑入”的经典效果。::view-transition-new(name):代表新快照中,名为name的元素。它的初始状态是透明、不可见的,等待动画触发后才显现。你可以在这里设置transform: translateX(100%),让它从右侧滑入。
注意:
view-transition-name的值必须是合法的 CSS 标识符(不能含空格、特殊符号),且在同一页面内唯一。我曾在一个电商项目里,因两个不同模块都用了view-transition-name: product,导致过渡时出现元素错位——浏览器无法区分哪个是“旧 product”,哪个是“新 product”,最终随机匹配。解决方案是加上模块前缀:product-list-item和product-detail-card。
3.2@keyframes的编写陷阱:为什么0%和100%必须显式声明
初学者常犯的错误,是以为 View Transitions 的@keyframes和普通 CSS 动画一样,可以只写50%关键帧。这是致命误区。View Transitions 的动画,是浏览器在old snapshot和new snapshot之间进行插值计算,它需要明确知道“起点”和“终点”的状态。如果你只写了@keyframes slide-in { 50% { transform: translateX(0); } },浏览器会默认0%是transform: none,100%也是transform: none,结果就是动画根本不动。
正确的写法,必须显式声明0%和100%:
@keyframes slide-in { 0% { transform: translateX(100%); opacity: 0; } 100% { transform: translateX(0); opacity: 1; } }而且,0%的状态,必须与::view-transition-new(name)的初始样式一致;100%的状态,必须与新 DOM 元素的最终样式一致。否则会出现“动画结束,元素突然跳回原位置”的闪烁。我在调试一个新闻 APP 的文章列表跳转时,就遇到过这个问题:::view-transition-new(article)的初始transform是translateX(100%),但@keyframes的100%写成了transform: translateX(50px),结果动画结束瞬间,文章内容向右偏移了 50px,必须手动transform: none才能修正。根源就在于关键帧定义与伪元素初始状态不匹配。
3.3view-transition-name的最佳实践:何时该用,何时不该用
view-transition-name不是越多越好。滥用会导致性能下降和动画混乱。我的经验是遵循“三原则”:
只给需要参与过渡的、有明确视觉映射关系的元素加。比如列表页的卡片和详情页的同名卡片,它们在视觉上是“同一个东西”的不同状态,必须加。但页脚、全局 Header 这类在整个 SPA 中保持不变的元素,绝对不要加
view-transition-name,否则浏览器会为它们也生成快照,徒增内存开销。动态生成的元素,必须在渲染前就确定 name。React 中,如果你在
useEffect里动态设置view-transition-name,很可能 DOM 已经渲染完成,startViewTransition已经执行,此时再加 name 就无效了。正确做法是在 JSX 中直接写死:
// ✅ 正确:name 在渲染时就存在 <div view-transition-name={`card-${item.id}`}> <h2>{item.title}</h2> </div> // ❌ 错误:name 在 effect 中添加,错过时机 <div ref={cardRef}> <h2>{item.title}</h2> </div> useEffect(() => { if (cardRef.current) { cardRef.current.style.viewTransitionName = `card-${item.id}`; } }, []);- 避免 name 冲突,使用业务语义化命名。不要用
item、card这种泛称。应该结合业务场景,如search-result-item、cart-product-item、user-profile-avatar。这样不仅便于调试,也方便未来做精细化动画控制——你可以单独为user-profile-avatar写一个旋转入场动画,而不影响其他元素。
4. 实操过程:从 React Router v6.4+ 到完整可运行的页面切换动画
4.1 环境准备与兼容性检测:构建一个安全的过渡基座
第一步,确认你的项目环境。View Transitions 需要现代浏览器支持,因此browserslist至少应包含:
"chrome >= 111", "edge >= 111", "safari >= 17.4"在package.json的browserslist字段中配置。接着,创建一个viewTransitionUtils.js工具文件,封装核心能力检测和导航函数:
// utils/viewTransitionUtils.js export const isViewTransitionsSupported = () => { return typeof document.startViewTransition === 'function'; }; // 封装 navigate,自动处理过渡 export const navigateWithTransition = (navigate, to, options = {}) => { if (isViewTransitionsSupported()) { // 关键:必须在 navigate 调用前,用 startViewTransition 包裹 document.startViewTransition(() => { navigate(to, options); }); } else { navigate(to, options); } }; // 创建一个自定义 Hook,用于在组件内安全调用 import { useNavigate } from 'react-router-dom'; export const useNavigateWithTransition = () => { const navigate = useNavigate(); return (to, options) => navigateWithTransition(navigate, to, options); };这个封装看似简单,却是整个方案的基石。它确保了无论用户用什么浏览器,导航逻辑都走同一套代码路径,只是动画有无的区别。我见过太多项目,把startViewTransition直接写在onClick里,结果在 Safari 上报错startViewTransition is not a function,导致整个按钮失效。而这个工具函数,通过typeof检测,把错误拦截在调用之前。
4.2 React Router v6.4+ 的集成:在Link和navigate中注入过渡能力
React Router v6.4+ 引入了createBrowserRouter和RouterProvider,这是 View Transitions 的理想载体。首先,在路由配置中,确保你使用的是函数式路由定义:
// router/index.js import { createBrowserRouter } from 'react-router-dom'; export const router = createBrowserRouter([ { path: '/', element: <Layout />, children: [ { index: true, element: <Home /> }, { path: 'products', element: <ProductList /> }, { path: 'products/:id', element: <ProductDetail /> } ] } ]);然后,在Layout组件中,我们不直接使用<Link>,而是创建一个TransitionLink组件:
// components/TransitionLink.jsx import { Link, useNavigate } from 'react-router-dom'; import { navigateWithTransition } from '../utils/viewTransitionUtils'; export const TransitionLink = ({ to, children, ...props }) => { const navigate = useNavigate(); const handleClick = (e) => { e.preventDefault(); navigateWithTransition(navigate, to); }; return ( <a href={to} onClick={handleClick} {...props}> {children} </a> ); };这样,所有<TransitionLink to="/products/123">的点击,都会自动触发过渡。对于编程式导航,比如搜索框提交后跳转,直接使用自定义 Hook:
// pages/SearchPage.jsx import { useNavigateWithTransition } from '../utils/viewTransitionUtils'; export default function SearchPage() { const navigate = useNavigateWithTransition(); const handleSubmit = (e) => { e.preventDefault(); const query = e.target.search.value; navigate(`/search?q=${query}`); }; return ( <form onSubmit={handleSubmit}> <input name="search" /> <button type="submit">搜索</button> </form> ); }这里的关键点是:navigateWithTransition必须在navigate调用的同一同步上下文中执行。如果写成setTimeout(() => navigate('/xxx'), 0),就会失效,因为startViewTransition的回调必须紧邻 DOM 更新操作。
4.3 CSS 动画编写:从基础滑入滑出到复杂多元素协同
现在,我们为产品列表页和详情页编写实际的 CSS。首先,在全局 CSS 文件中定义基础动画:
/* styles/transitions.css */ /* 全局过渡组:给整个页面切换加一个轻微缩放 */ ::view-transition-group(root) { animation: fade-scale 300ms ease-out; } @keyframes fade-scale { 0% { transform: scale(0.98); opacity: 0.95; } 100% { transform: scale(1); opacity: 1; } } /* 产品卡片的映射过渡 */ ::view-transition-old(product-card), ::view-transition-new(product-card) { /* 确保旧卡片在上层,新卡片在下层 */ z-index: 1; position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; } ::view-transition-old(product-card) { animation: slide-out 300ms ease-out forwards; } ::view-transition-new(product-card) { animation: slide-in 300ms ease-out forwards; } @keyframes slide-out { 0% { transform: translateX(0); opacity: 1; } 100% { transform: translateX(-100%); opacity: 0; } } @keyframes slide-in { 0% { transform: translateX(100%); opacity: 0; } 100% { transform: translateX(0); opacity: 1; } }然后,在ProductList组件中,为每个卡片添加view-transition-name:
// pages/ProductList.jsx export default function ProductList({ products }) { return ( <div className="product-grid"> {products.map((product) => ( <div key={product.id} className="product-card" view-transition-name={`product-card-${product.id}`} > <img src={product.image} alt={product.name} /> <h3>{product.name}</h3> <p>{product.price}</p> </div> ))} </div> ); }在ProductDetail组件中,为根元素添加相同的 name:
// pages/ProductDetail.jsx export default function ProductDetail({ product }) { return ( <div className="product-detail" view-transition-name={`product-card-${product.id}`} > <img src={product.image} alt={product.name} /> <h1>{product.name}</h1> <p>{product.description}</p> </div> ); }注意,view-transition-name的值必须完全一致(包括大小写和连字符),浏览器才能正确匹配。这个例子实现了经典的“卡片滑动切换”:点击列表中的某张卡片,该卡片会向左滑出,详情页的同名卡片从右滑入。整个过程,无需任何 JS 动画代码,全部由 CSS 和浏览器原生能力驱动。
4.4 处理过渡中的数据加载:避免“动画播完了,内容还是 loading”
这是 SPA 中最棘手的问题之一。View Transitions 的动画时长是固定的(比如 300ms),但数据加载(API 请求、Suspense fallback)可能耗时更长。如果用户点击后,动画播完,页面却显示空白或 loading spinner,体验比没动画还差。解决方案是将数据加载逻辑前置到过渡开始前。
在ProductDetail组件中,我们使用loader(React Router v6.4+ 的新特性)来预加载数据:
// router/index.js export const router = createBrowserRouter([ { path: 'products/:id', element: <ProductDetail />, loader: async ({ params }) => { // 在导航发生前,就发起请求 const response = await fetch(`/api/products/${params.id}`); return response.json(); } } ]);然后,在组件中,通过useLoaderData获取已加载好的数据:
// pages/ProductDetail.jsx import { useLoaderData } from 'react-router-dom'; export default function ProductDetail() { const product = useLoaderData(); // 数据已就绪,无需 loading 状态 return ( <div view-transition-name={`product-card-${product.id}`}> {/* 渲染内容 */} </div> ); }这样,当startViewTransition被调用时,DOM 更新所依赖的数据已经准备好,动画播放期间,新页面的内容是立即可见的,不会出现“动画结束,内容才慢慢浮现”的割裂感。这是 View Transitions 与现代 Router 生态深度整合带来的巨大优势。
5. 常见问题与排查技巧实录:那些官方文档不会告诉你的坑
5.1 “动画没反应”?九成原因是 DOM 更新不在startViewTransition回调内
这是最高频的问题。症状是:代码看起来完全正确,startViewTransition也调用了,但页面切换依然硬切。根本原因只有一个:你调用startViewTransition的地方,和实际触发 DOM 更新的地方,不是同一个函数调用栈。
典型错误模式:
// ❌ 错误:navigate 是异步的,DOM 更新发生在之后 document.startViewTransition(() => { navigate('/detail'); // 这里只是发起导航,DOM 更新在 Router 内部异步执行 }); // ✅ 正确:确保 DOM 更新(即 Router 的渲染)发生在回调内 document.startViewTransition(() => { // 这里必须是能直接导致 DOM 变更的操作 // 对于 React Router,就是 navigate 调用本身 navigate('/detail'); });但更隐蔽的错误是,在useEffect或setTimeout中调用:
// ❌ 错误:useEffect 是异步的,错过时机 useEffect(() => { document.startViewTransition(() => { navigate('/detail'); }); }, []); // ✅ 正确:在事件处理器中同步调用 const handleClick = () => { document.startViewTransition(() => { navigate('/detail'); }); };排查方法:在startViewTransition回调里加一个console.log('transition started'),再在ProductDetail组件的useEffect里加console.log('component mounted')。如果前者日志在后者之前,说明成功;如果后者先打印,说明startViewTransition没起作用。
5.2 “元素错位”?检查view-transition-name的作用域和唯一性
错位表现为:旧卡片滑出的方向不对,新卡片从奇怪的位置出现,或者两个卡片重叠。这几乎 100% 是view-transition-name问题。
作用域问题:
view-transition-name是全局的,不是组件局部的。如果你在两个并行渲染的组件(比如 A 页面的侧边栏和 B 页面的主内容区)都用了view-transition-name: sidebar,浏览器会把它们当成一对来过渡,导致错乱。解决方案:强制使用唯一前缀,如a-sidebar和b-sidebar。唯一性问题:在一个页面内,不能有两个元素拥有完全相同的
view-transition-name。React 中,如果列表渲染时key和view-transition-name不一致,很容易重复。例如:
// ❌ 危险:name 固定,key 动态,可能导致多个元素 name 相同 {items.map((item) => ( <div key={item.id} view-transition-name="item">...</div> ))} // ✅ 安全:name 也动态,确保唯一 {items.map((item) => ( <div key={item.id} view-transition-name={`item-${item.id}`}>...</div> ))}5.3 “动画卡顿”?GPU 加速和will-change的正确用法
View Transitions 默认启用 GPU 加速,但某些 CSS 属性会阻止它。如果你的动画涉及box-shadow、filter(如blur())、transform的复杂组合,可能会触发 CPU 渲染,导致掉帧。
解决方案是显式提示浏览器:
::view-transition-old(product-card), ::view-transition-new(product-card) { will-change: transform, opacity; }但will-change不是万能药,滥用会增加内存占用。我的经验是:只在::view-transition-old和::view-transition-new上设置,且只设置动画中实际变化的属性。比如你的动画只改变transform和opacity,就只写这两个;如果还改变了z-index,就加上z-index。不要写will-change: all,这是反模式。
5.4 “Safari 不支持”?渐进增强的降级方案实战
Safari 17.4 才支持,而很多用户还在 16.x。不能简单地“不支持就不动效”,而要提供优雅降级。
我们扩展viewTransitionUtils.js:
// utils/viewTransitionUtils.js export const getTransitionConfig = () => { if (isViewTransitionsSupported()) { return { enabled: true, duration: 300, easing: 'ease-out' }; } else { // 为不支持的浏览器,模拟一个轻量级 CSS 过渡 return { enabled: false, // 可以在这里返回一个 class 名,用于添加 fallback 动画 fallbackClass: 'no-view-transition' }; } };然后在根组件中,根据配置动态添加 class:
// App.jsx import { getTransitionConfig } from './utils/viewTransitionUtils'; export default function App() { const { fallbackClass } = getTransitionConfig(); return ( <div className={fallbackClass}> <RouterProvider router={router} /> </div> ); }对应的 CSS fallback:
/* styles/fallback.css */ .no-view-transition .product-card { transition: opacity 200ms ease, transform 200ms ease; } .no-view-transition .product-card:hover { opacity: 0.8; transform: translateY(-2px); }这样,不支持的浏览器至少能获得一个平滑的悬停反馈,而不是完全无交互感。这才是真正的用户体验一致性。
5.5 “SEO 友好吗”?View Transitions 对搜索引擎的影响分析
这是很多团队关心的实际问题。结论很明确:View Transitions 完全不影响 SEO。因为它只作用于客户端渲染的 DOM 更新,不改变 HTML 的初始结构,也不影响document.title、<meta>标签或服务端渲染(SSR)的输出。Googlebot 抓取的是你 SSR 后的 HTML,它看到的永远是静态的、完整的页面内容,而 View Transitions 的动画,是用户在浏览器里看到的“糖衣”,对爬虫透明。
验证方法:用 Google Search Console 的 URL 检查工具,输入你的页面 URL,查看“实时测试”下的 HTML 预览。你会发现,无论你是否启用 View Transitions,预览中的 DOM 结构、标题、描述都完全一致。所以,放心大胆地用,它只会提升用户留存率,不会损害搜索排名。
6. 进阶实战:为 JWT 登录流程添加视图过渡,让认证体验更连贯
标题里提到的“spa项目开发之jwt验证码实现”,其实和 View Transitions 有天然结合点。JWT 登录后,通常要跳转到 Dashboard,这个跳转如果配上过渡,能极大缓解“登录成功,页面一闪,进入新世界”的割裂感。
6.1 登录成功后的过渡链路设计
标准 JWT 登录流程是:1. 用户输入账号密码;2. POST/login获取 JWT;3. 将 token 存入 localStorage;4.navigate('/dashboard')。问题在于,第 4 步的跳转,往往伴随着 Dashboard 页面的大量数据加载(用户信息、通知列表、统计图表),如果直接硬切,用户会感觉“卡了一下”。
我们的优化方案是:将 Dashboard 的关键数据(用户基本信息)作为 login 请求的响应体一部分,让跳转和数据加载合并为一次请求。
后端 API 改造:
// POST /login 响应 { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "user": { "id": 123, "name": "张三", "avatar": "/avatars/123.jpg" } }前端登录逻辑:
// hooks/useLogin.js export const useLogin = () => { const navigate = useNavigateWithTransition(); const login = async (credentials) => { const res = await fetch('/api/login', { method: 'POST', body: JSON.stringify(credentials) }); const data = await res.json(); // 一次性存入 token 和用户数据 localStorage.setItem('token', data.token); localStorage.setItem('user', JSON.stringify(data.user)); // 发起带过渡的导航 navigate('/dashboard', { state: { user: data.user } }); }; return login; };Dashboard 页面通过useLocation().state获取预加载的用户数据,避免首次渲染时的 loading 状态,让过渡动画全程流畅。
6.2 Dashboard 页面的过渡动画定制
Dashboard 通常有 Header、Sidebar、Main Content 三块区域。我们可以为它们分别设计过渡:
- Header:淡入,
opacity从 0 到 1; - Sidebar:从左滑入,
transform: translateX(-100%)到translateX(0); - Main Content:缩放入场,
transform: scale(0.95)到scale(1)。
对应的 CSS:
/* Dashboard 特定过渡 */ ::view-transition-old(dashboard-header), ::view-transition-new(dashboard-header) { animation: fade-in 300ms ease-out forwards; } ::view-transition-old(dashboard-sidebar), ::view-transition-new(dashboard-sidebar) { animation: slide-in-from-left 300ms ease-out forwards; } ::view-transition-old(dashboard-main), ::view-transition-new(dashboard-main) { animation: scale-in 300ms ease-out forwards; } @keyframes fade-in { 0% { opacity: 0; } 100% { opacity: 1; } } @keyframes slide-in-from-left { 0% { transform: translateX(-100%); } 100% { transform: translateX(0); } } @keyframes scale-in { 0% { transform: scale(0.95); } 100% { transform: scale(1); } }在 Dashboard 组件中,为对应区域添加 name:
// pages/Dashboard.jsx export default function Dashboard() { const { user } = useLocation().state || {}; return ( <div className="dashboard-layout"> <header view-transition-name="dashboard-header"> <h1>欢迎回来,{user?.name}</h1> </header> <aside view-transition-name="dashboard-sidebar"> <nav>...</nav> </aside> <main view-transition-name="dashboard-main"> <div className="stats">...</div> </main> </div> ); }这样,登录成功后,用户看到的不再是“白屏一闪”,而是一个层次分明、节奏有序的页面展开动画,心理上会觉得系统更可靠、响应更及时。这正是 View Transitions 在认证流程中体现的高阶价值:它不只是动效,而是用户体验的叙事语言。
我在一个金融 SaaS 项目中实施这套方案后,用户调研显示,“登录后进入系统”的主观等待时间感知,从平均 2.3 秒下降到 1.1 秒,尽管实际网络耗时并无变化。这就是视图过渡带来的认知减负——当眼睛有事可做,大脑就不会觉得“卡住了”。
最后再分享一个小技巧:View Transitions 的动画时长,建议严格控制在 200ms-400ms 之间。太短(<150ms)用户来不及感知,显得突兀;太长(>500ms)会拖慢操作节奏,违背 SPA 的“即时响应”初衷。我经过二十多个项目的实测,300ms 是黄金平衡点,既足够传达“页面