Vue路由过渡动画的实现原理与实战优化技巧
优化前:卡得不行
上周上线新版本后,产品跑来问:“为啥点导航菜单要等半天才进页面?我点完还得等一下,像在按电梯按钮。”
我当场打开 DevTools 录了个 Performance,一看——路由跳转时主线程直接卡住 1.2s,中间还夹着两次 Layout Shift,动画一卡一顿,transition 看着像 PPT 切页。更离谱的是,从 /home 跳到 /product/detail/123,首屏渲染时间(FP)5.3s,FCP 4.8s,LCP 直接飙到 6.1s。用户根本等不到内容出来就切走了。
这不是动画慢,是整个过渡流程被拖垮了:组件 mount 前一堆没拆分的副作用、样式表没做按需加载、CSS-in-JS 的全局注入还在跑、甚至有个 useEffect 里写了 fetch('https://jztheme.com/api/product') 还没加 AbortController……总之,不是“过渡动画卡”,是“压根没开始过渡,先干等 3 秒”。
找到病灶了!
我先用 React DevTools 的 Profiler 按下跳转,发现 ProductDetailPage 组件 mount 后,光 useEffect 就执行了 4 层嵌套,其中 2 个在拉数据,1 个在初始化一个 300 行的富文本编辑器(没错,还没显示就初始化了),还有 1 个在计算一堆没用上的状态。接着用 Performance 面板看火焰图,Layout 和 Scripting 像山峰一样堆在一起,GC 都触发了两次。
再开 Network 面板,发现跳转瞬间发了 7 个请求:3 个图片、2 个字体、1 个 vendor.js(3.2MB)、还有那个 fetch。这哪是路由跳转,这是重新载入一个 SPA。
核心优化:把“过渡”真正交还给路由系统
我试了几种方案:
- 用
react-spring做动画 → 动画丝滑了,但数据加载还是卡,用户看到空页面转圈 4 秒,动画再好也没意义; - 加骨架屏 → 有缓解,但骨架屏本身也得等 CSS 加载完才渲染,且 JS 没执行完,骨架也挂;
- 升级到 React 18 +
Suspense+startTransition→ 正解,但项目还在 17,短期没法升。
最后决定不动框架大结构,只做三件事:延迟非关键副作用、拆分 CSS、用原生 transition 控制进入时机。重点不是“怎么动”,而是“什么时候开始动”。
以前的写法是这样的:
// ❌ 旧代码:一进来就全量加载
function ProductDetailPage({ id }) {
const [data, setData] = useState(null);
const [editor, setEditor] = useState(null);
useEffect(() => {
fetch(https://jztheme.com/api/product/${id})
.then(res => res.json())
.then(setData);
}, [id]);
useEffect(() => {
// 初始化巨无霸编辑器(300KB)
import('rich-editor').then(({ init }) => {
setEditor(init());
});
}, []);
return (
<div className="page-enter">
{data ? <ProductCard data={data} /> : <Skeleton />}
{editor && <Editor instance={editor} />}
</div>
);
}
问题很明显:所有逻辑都绑在 mount 上,JS 执行完才开始渲染,动画只能等它结束。
改成这样:
// ✅ 新代码:分阶段、可中断、带 loading 状态
function ProductDetailPage({ id }) {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [editorReady, setEditorReady] = useState(false);
// 数据请求:加 abort,失败兜底
useEffect(() => {
const controller = new AbortController();
setIsLoading(true);
fetch(https://jztheme.com/api/product/${id}, {
signal: controller.signal
})
.then(res => res.json())
.then(setData)
.catch(() => setData({ error: true }))
.finally(() => setIsLoading(false));
return () => controller.abort();
}, [id]);
// 富文本编辑器:仅在用户滚动到区域或点击编辑按钮后加载
const loadEditor = useCallback(() => {
if (editorReady) return;
import('rich-editor').then(({ init }) => {
setEditorReady(true);
});
}, [editorReady]);
// 用 IntersectionObserver 或 click 触发,不放 useEffect 里
return (
<div className={page-enter ${isLoading ? 'loading' : 'loaded'}}>
{isLoading ? (
<div className="skeleton-wrapper">
<div className="skeleton-title"></div>
<div className="skeleton-content"></div>
</div>
) : (
<ProductCard data={data} />
)}
{/* 编辑器区域默认不渲染 */}
{editorReady && <Editor />}
{!editorReady && (
<button onClick={loadEditor} className="editor-trigger">
编辑内容
</button>
)}
</div>
);
}
CSS 也同步改了:
.page-enter {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.page-enter.loading {
opacity: 1;
transform: translateY(0);
}
.page-enter.loaded {
opacity: 1;
transform: translateY(0);
}
.skeleton-title,
.skeleton-content {
background: #eee;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: -200px 0; }
100% { background-position: 200px 0; }
}
最关键的一环:路由切换时,用 RouterProvider 的 state 做状态驱动,而不是靠组件重渲染触发动画。我们用的是 react-router v6.15+,所以直接监听 navigation.state:
// 在根布局中统一控制
function RootLayout() {
const navigation = useNavigation();
useEffect(() => {
if (navigation.state === 'loading') {
document.body.classList.add('route-loading');
} else {
document.body.classList.remove('route-loading');
}
}, [navigation.state]);
return (
<div className={layout ${navigation.state === 'loading' ? 'is-transitioning' : ''}}>
<Outlet />
</div>
);
}
优化后:流畅多了
改完之后跑了 10 次 Lighthouse,平均 FCP 降到 820ms,LCP 940ms,TTI 1.3s。最直观的是:点菜单,0.2s 内就出骨架屏,0.6s 内内容出现,整个过渡过程没有卡顿感。用户反馈从“等得烦”变成“咦,这么快?”
Performance 面板里那座“山”没了,Scripting 时间压到 300ms 以内,Layout 几乎不占主线程。Network 请求从 7 个降到 3 个(只拉必要数据 + 1 张主图),vendor.js 也通过 code-splitting 拆成 chunks,ProductDetailPage 对应 chunk 只有 412KB。
性能数据对比
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| FCP | 4.8s | 0.82s | ↓ 83% |
| LCP | 6.1s | 0.94s | ↓ 84% |
| 主线程阻塞时间 | 1240ms | 280ms | ↓ 77% |
| 首屏 JS 执行量 | 4.2MB | 1.1MB | ↓ 74% |
| 路由跳转平均耗时(含渲染) | 5300ms | 810ms | ↓ 85% |
注意:这些数字是在低端安卓机(Redmi Note 9)上测的。高端机更快,但优化目标就是保底线。
踩坑提醒:这三点一定注意
- 别在
useEffect里初始化重型 UI 组件——哪怕你用了lazy + Suspense,如果它内部有大量副作用,照样卡主线程。宁可加个「点击加载」按钮; - CSS transition 不会等 JS 执行完才开始,但 class 切换时机必须精准。我一开始把
loadedclass 放在数据setState后立刻加,结果骨架屏闪一下就消失,因为 DOM 还没更新。后来改成useLayoutEffect+setTimeout(fn, 0)确保 class 在 paint 前生效; - AbortController 不是可选,是必加——路由跳转中途取消请求,否则旧请求回来 setState 到已卸载组件,React 会报 warning,还浪费带宽。
以上是我的优化经验,有更好的方案欢迎交流
这个方案没上 Server Components,也没动 Webpack 配置,全是运行时层面的微调。它不一定是最优解,但对当前项目来说,投入产出比最高——两天改完,上线当天 PV 提升 12%,跳出率降了 27%。
如果你也在用 react-router + CSS transition 做路由过渡,或者遇到了类似“动画卡但查不出原因”的问题,欢迎评论区聊聊你的解法。这个技巧的拓展用法还有很多,比如结合 IntersectionObserver 做渐进式加载、用 document.startViewTransition(Chrome 115+)接管原生过渡,后续我会继续分享这类实战博客。

暂无评论