学完这篇你能把一个几 MB 的 React 单包拆成按需加载的小块,首屏 JS 体积明显下降,同时不把页面搞出一堆加载闪烁和报错白屏。
第一步:先确认值得拆什么
不要一上来就到处包 React.lazy。先看打包结果,找出体积大头:
npm run build
npx source-map-explorer build/static/js/*.js
Vite 用 rollup-plugin-visualizer,Webpack 用 webpack-bundle-analyzer。重点看三类东西:路由页面、图表/编辑器这类重型第三方库、只在少数页面用的业务组件。首页首屏不需要的,才是分割目标。
注意:分割不是越多越好。每个 chunk 都是一次额外 HTTP 请求(HTTP/1.1 下更明显),把一个 30KB 的组件拆成 5 份反而更慢。按「路由」和「重型依赖」两个粒度拆就够了。
第二步:最小可用的 React.lazy + Suspense
React.lazy 接收一个返回 import() 的函数,必须返回 Promise,且模块要有 default 导出:
import React, { Suspense } from 'react';
const HeavyChart = React.lazy(() => import('./HeavyChart'));
export default function Dashboard() {
return (
<Suspense fallback={<div>图表加载中…</div>}>
<HeavyChart data={data} />
</Suspense>
);
}
关键点:Suspense 是包在使用方的,不是包在 lazy 组件里。fallback 会在 chunk 下载期间显示。
注意:React.lazy 只认 default 导出。如果你的组件是 export const Foo = ...,要加一层转换:
> const Foo = React.lazy(() =>
> import('./Foo').then(m => ({ default: m.Foo }))
> );
>
第三步:把 Suspense 放在合适的位置
新手最容易把每个 lazy 组件都套一个 Suspense,结果页面上三个地方同时闪加载。更好的做法是:在同一层级共用外层一个 Suspense,让它们一起等待、一起出现。
<Suspense fallback={<PageSkeleton />}>
<Header />
<Chart />
<Table />
</Suspense>
如果某个区块确实要独立加载,再单独套。骨架屏(Skeleton)比「加载中…」文字体验好得多,而且能减少布局跳动(CLS)。
第四步:路由级分割,收益最大
绝大多数项目 80% 的收益来自路由分割。React Router v6.4+ 直接支持:
// router.jsx
import { createBrowserRouter } from 'react-router-dom';
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{ index: true, element: <Home /> },
{
path: 'admin',
lazy: () => import('./pages/Admin'), // 导出 Component
},
],
},
]);
老版本或不用 data router 的话,用 React.lazy + 一个包住 <Outlet /> 的 Suspense 也行:
const Admin = React.lazy(() => import('./pages/Admin'));
<Route path="/admin" element={
<Suspense fallback={<PageSkeleton />}><Admin /></Suspense>
} />
注意:React.lazy 在服务端渲染(SSR)里默认不支持。做 SSR 的项目请改用框架自带的方案(Next.js 的 next/dynamic、Remix 的路由级加载),否则会在服务端报错。
第五步:加错误边界,别让网络抖动变白屏
用户网络差时 chunk 可能加载失败(尤其是发版后旧 chunk 被删),Suspense 不会捕获这个错误,会直接冒泡崩掉整棵树。必须配 ErrorBoundary:
class LazyErrorBoundary extends React.Component {
state = { error: null };
static getDerivedStateFromError(error) { return { error }; }
render() {
if (this.state.error) {
return (
<div>
加载失败,请刷新页面
<button onClick={() => location.reload()}>刷新</button>
</div>
);
}
return this.props.children;
}
}
配合「发版后旧 chunk 404」的经典问题,可以在 window 上监听 vite:preloadError(Vite)或 unhandledrejection,命中就自动刷新一次。
注意:ErrorBoundary 必须是 class 组件或使用第三方库(如 react-error-boundary),函数组件目前没有对应的 Hook。
第六步:用 startTransition 消除闪烁
React 18 起,非 transition 的更新一旦 suspend,会直接显示 fallback。切 Tab、切筛选条件时就会闪一下。用 startTransition 包住:
import { startTransition } from 'react';
function onTabChange(key) {
startTransition(() => setActiveTab(key));
}
这样 React 会保留旧内容直到新 chunk 就绪,不再闪 fallback。
第七步:提前预加载,把等待藏起来
用户 hover 到链接上时就开始下载 chunk:
const importAdmin = () => import('./pages/Admin');
const Admin = React.lazy(importAdmin);
<Link to="/admin" onMouseEnter={importAdmin}>后台</Link>
Webpack 里还可以用魔法注释命名 chunk,方便排查:
React.lazy(() => import(/* webpackChunkName: "page-admin" */ './pages/Admin'));
Vite 对应的是 /* @vite-ignore */ 之外的 manualChunks 配置,注释方式不同,别搞混。
小结
- 先测体积再拆,只拆路由和重型依赖,别过度分割
React.lazy 要求 import() 返回模块有 default 导出,命名导出要手动转一层
- Suspense 包在使用方,同层级共用一个比每个都套更好
- 必配 ErrorBoundary,并处理发版后旧 chunk 404 的情况
- React 18 用
startTransition 避免切换时 fallback 闪烁
- 用 hover 预加载和 chunk 命名把体验和排查成本都降下来