
一句话:代码分割(Code Splitting)用「动态
import()」把 bundle 拆成多个 chunk,按路由或按组件的交互时机加载——这是改善首屏加载最有效的手段之一。
一、为什么要代码分割
问题:单 bundle 的代价
① 首屏必须下载「整个应用」的代码(包括「设置页」「富文本编辑器」这些暂时用不到的)
② 主包越大 → 下载、解析、执行时间越长 → 首屏越慢
③ 高 TTI(可交互时间)——JS 执行完才能交互
例子:
· 一个后台系统 200 个页面 + 富文本编辑器 + 图表库
· 打包成一个 3MB 的 bundle
· 用户只是想看「登录页」,却要下载 3MB
代码分割的收益:
① 首屏只加载「当前路由/当前视图」所需代码 → 包更小 → 更快
② 其余代码「按需」加载(用户点进去才下载)
③ 配合预加载(hover 时先下载)→ 既快又不浪费
⚠️ 注意:分割不是「免费的」——
· chunk 太多会导致「请求数过多」(HTTP/2 下影响较小)
· 分割不当会导致「懒加载的 chunk 里又引入公共大依赖」
二、React.lazy + Suspense
import { lazy, Suspense } from 'react';
// ① 定义懒加载组件(动态 import)
const HeavyEditor = lazy(() => import('./HeavyEditor'));
// ⚠️ 要求:默认导出(或写成 lazy(() => import('./X').then((m) => ({ default: m.Named }))))
// ② 用 Suspense 包裹(提供加载时的 fallback)
function App() {
return (
<Suspense fallback={<Skeleton />}>
<HeavyEditor />
</Suspense>
);
}
// 命名导出的写法
const Editor = lazy(() =>
import('./Editor').then((m) => ({ default: m.Editor }))
);
// ⚠️ 常见错误:忘了 Suspense → 报错
function App() {
return <HeavyEditor />; // ❌ 报错:A component suspended while responding to synchronous input
// ✅
return (
<Suspense fallback={<Skeleton />}>
<HeavyEditor />
</Suspense>
);
}
| 关键点 | 说明 |
|---|---|
必须动态 import() |
静态 import 会被打包进主 chunk |
| 组件要求 | 默认导出,或转换成 { default: ... } |
必须有 Suspense |
否则加载期间会报错 |
| SSR | React.lazy 在 SSR 中的支持有限(用框架的 dynamic / RSC 代替) |
| chunk 命名 | 可加注释 /* webpackChunkName */(Webpack)或用打包工具的自动命名 |
三、路由级分割(最划算)
// ✅ 路由级分割是「收益最大、成本最低」的分割方式
import { lazy, Suspense } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Settings = lazy(() => import('./pages/Settings'));
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{ index: true, element: <Home /> },
{ path: 'about', element: <About /> },
{ path: 'settings', element: <Settings /> },
],
},
]);
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
{/* ⭐ Suspense 放在「布局内、路由外」,这样切换时只有内容区显示骨架 */}
<RouterProvider router={router} />
</Suspense>
);
}
⚠️ Suspense 的放置位置很关键:
❌ 放在最外层(包住整个 App)
→ 路由切换时【整个页面】变成 fallback(导航栏也没了)→ 视觉闪烁
✅ 放在「内容区」
→ 只有内容区显示骨架,导航栏保持不变
✅ 或者用「每个路由元素单独包」+ 外层一个兜底
// ✅ 更好的写法:在布局里包(只影响内容区)
function Layout() {
return (
<div className="layout">
<TopNav />
<main>
<Suspense fallback={<ContentSkeleton />}>
<Outlet /> {/* ⭐ 只有这里在切换时显示骨架 */}
</Suspense>
</main>
</div>
);
}
// 数据路由(createBrowserRouter)下可以用懒加载配置
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{ index: true, lazy: () => import('./pages/Home') }, // ⭐ 只要导出 Component
{ path: 'settings', lazy: () => import('./pages/Settings') },
],
},
]);
// Home.tsx 里导出:export function Component() { ... }
// 还可以同时导出 loader / action
四、组件级分割(按交互时机)
// ✅ 场景一:重型组件(富文本编辑器、图表、地图),用户不一定打开
const RichEditor = lazy(() => import('./RichEditor'));
function ArticlePage() {
const [editing, setEditing] = useState(false);
return (
<>
<button onClick={() => setEditing(true)}>编辑</button>
{editing && (
<Suspense fallback={<EditorSkeleton />}>
<RichEditor />
</Suspense>
)}
</>
);
}
// 好处:用户不点「编辑」就永远不会下载编辑器代码
// ✅ 场景二:弹窗/抽屉(经常含大量代码)
const UserDetailModal = lazy(() => import('./UserDetailModal'));
function UserList() {
const [openId, setOpenId] = useState<string | null>(null);
return (
<>
<List onSelect={setOpenId} />
{openId && (
<Suspense fallback={<ModalSkeleton />}>
<UserDetailModal userId={openId} onClose={() => setOpenId(null)} />
</Suspense>
)}
</>
);
}
// ✅ 场景三:条件依赖(如按平台/权限/语言加载不同实现)
const Chart = lazy(() =>
import(/* @vite-ignore */ `./charts/${platform}.tsx`)
);
// ⚠️ 这种「动态路径」会让打包工具无法静态分析 → 需要显式处理(否则会生成很多 chunk)
| 分割位置 | 收益 | 注意 |
|---|---|---|
| 路由级 | 最大(天然按页面拆分) | Suspense 放在内容区 |
| 重型组件(编辑器/图表/地图) | 大(可能几百 KB) | 按交互时机加载 |
| 弹窗/抽屉 | 中 | 打开时才加载 |
| 低频功能(导出、打印、设置) | 中 | 按钮触发时才加载 |
| 小组件(按钮、图标) | 小 | 不值得(增加请求数) |
五、预加载策略(让懒加载「无感」)
// ① 路由切换前预加载(hover / focus 时)
const SettingsLazy = lazy(() => import('./pages/Settings'));
// 单独提取「加载函数」,供预取使用
const loadSettings = () => import('./pages/Settings');
function NavLinkWithPreload() {
return (
<Link
to="/settings"
onMouseEnter={() => loadSettings()} // ⭐ 悬停时先下载
onFocus={() => loadSettings()}
>
设置
</Link>
);
}
// ② 空闲时预加载(首屏加载完成后)
useEffect(() => {
const id = requestIdleCallback(() => {
import('./pages/Settings'); // 空闲时预取
});
return () => cancelIdleCallback(id);
}, []);
// ③ 路由级预加载(进入某个路由后预取「可能的下一步」)
function HomePage() {
useEffect(() => {
// 用户大概率会点进详情页 → 空闲时预取
const id = requestIdleCallback(() => import('./pages/Detail'));
return () => cancelIdleCallback(id);
}, []);
}
// ④ 用「提前声明」的方式预加载(Webpack 的魔法注释 / Vite 的 preload)
import { lazy } from 'react';
const Home = lazy(() => import(/* webpackPrefetch: true */ './pages/Home'));
// webpackPrefetch:浏览器空闲时预取(优先级最低)
// webpackPreload:与主 chunk 并行加载(谨慎使用)
预加载的三种策略:
① hover/focus 预取(最实用)
用户「表现出意图」时开始下载 → 点击时通常已下载完 → 无感
② 空闲时预取(requestIdleCallback)
首屏完成后预取「最可能的下一步」
③ 依赖声明式预取(打包工具注释)
交给打包工具与浏览器调度
⚠️ 不要「全部预取」——那等于回到了「一个大 bundle」的问题。
六、分析产物(先看再分)
# Vite / Rollup
npm i -D rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
react(),
visualizer({ open: true, gzipSize: true, brotliSize: true }),
],
});
看产物的三个目标:
① 找出「最大的几个 chunk」——它们是否是首屏必需的?
② 找出「被多个 chunk 引用的公共依赖」——是否被重复打包?
③ 找出「不该在首屏的大依赖」——如 moment、lodash 全量、图表库、富文本编辑器
→ 按需引入 / 换更轻的库 / 懒加载
// 常见的「大依赖」优化
// ❌ 全量引入
import _ from 'lodash'; // ~70KB
import moment from 'moment'; // ~70KB + locale
// ✅ 按需引入
import debounce from 'lodash/debounce';
// ✅ 或换更轻的替代
import { format } from 'date-fns'; // 支持 tree-shaking
// ✅ 或用平台能力
new Intl.DateTimeFormat('zh-CN').format(date);
七、常见坑
// ① 忘了 Suspense → 报错
<LazyComp /> // ❌
// ② Suspense 放在最外层 → 路由切换时整页闪骨架
<Suspense fallback={<FullPageLoading />}>
<RouterProvider router={router} /> {/* ⚠️ 导航栏也没了 */}
</Suspense>
// ③ 动态 import 的组件用「命名导出」却没转换
lazy(() => import('./X')); // ⚠️ X 只有命名导出时会报错
lazy(() => import('./X').then((m) => ({ default: m.X }))); // ✅
// ④ 分割过细 → 请求数过多、每个 chunk 都带一份公共代码
// ⑤ 在同一路由内的多个组件各自 lazy → 多次瀑布式加载
// ✅ 应该「合并成一个懒加载入口」或让它们并行
// ⑥ 懒加载组件「在首屏可见区域」→ 反而拖慢(应该预加载)
// ⑦ 忘了给懒加载的 fallback 一个「合理的高度」(导致布局抖动 CLS)
<Suspense fallback={null}> // ⚠️ 内容出现时页面会跳
<Suspense fallback={<Skeleton height={400} />}> // ✅
// ⑧ 用 lazy 加载「很小的组件」(不值得)
// ⑨ 没有分析产物就盲目分割(可能分了个「本来就不大」的模块)
// ⑩ 预加载「所有路由」→ 等于没分割
// ⑪ SSR 中用 React.lazy(支持有限)→ 用框架的 dynamic/RSC
// ⑫ 懒加载 chunk 报错时没有兜底(网络失败 → 一直 loading)
// ✅ 用 ErrorBoundary + 重试按钮
// ⑦ 与 ⑫ 的完整示例
function LazySection() {
return (
<ErrorBoundary
fallback={
<div className="error-state">
内容加载失败
<button onClick={() => window.location.reload()}>重试</button>
</div>
}
>
<Suspense fallback={<Skeleton height={400} />}>
<HeavyComponent />
</Suspense>
</ErrorBoundary>
);
}
// ⭐ ErrorBoundary 在外、Suspense 在内
// → chunk 加载失败时显示错误兜底(而不是永远 loading)
八、面试延伸
- 「什么是代码分割?它解决什么问题?」
代码分割(Code Splitting)是把「一个大的 bundle」拆成多个 chunk,按需(路由切换、交互触发)加载的技术。它解决的是「首屏加载慢」的问题——用户不需要为「暂时不访问的页面」或「不打开的编辑器」付出下载、解析与执行的代价。实现方式是「动态import()」,在 React 里配合React.lazy+Suspense。 -
「
React.lazy怎么用?有什么限制?」
用法:const Comp = lazy(() => import('./Comp')),然后用<Suspense fallback={...}>包住它。四个限制:① 必须用动态import()(静态导入会被打进主 chunk);② 组件必须是默认导出(命名导出需要.then(m => ({ default: m.X }))转换);③ 必须有Suspense,否则加载期间会报错;④ 在 SSR 中支持有限(应使用框架提供的dynamic/ RSC 方案)。 -
「哪些地方适合做代码分割?」
按收益排序:① 路由级(收益最大、最自然,每个页面一个 chunk);② 重型组件(富文本编辑器、图表库、地图、PDF 预览,可能几百 KB);③ 弹窗/抽屉(含表单或详情页代码);④ 低频功能(导出、打印、设置页)。不适合:小组件(按钮、图标)——分割反而增加请求数,收益为负。 -
「
Suspense应该放在哪里?」
关键是「让 fallback 的影响范围尽量小」。不要放在最外层(包住整个App/RouterProvider)——那样路由切换时整个页面(包括导航栏)都会变成 fallback,产生明显的视觉闪烁。正确做法是放在「内容区域」(如布局组件的<main>里包住<Outlet />),这样只有内容区显示骨架,导航栏保持稳定。如果要更细的体验,可以「每个路由元素单独包 + 外层一个兜底」。 -
「怎么让懒加载『无感』?」
用预加载:① hover/focus 预取(最实用)——用户把鼠标移到链接上就开始下载,点击时通常已经下载完;② 空闲时预取——requestIdleCallback里预取「最可能的下一步」(如首页预取详情页);③ 打包工具声明的预取(webpackPrefetch会在浏览器空闲时预取)。注意不要「全部预取」——那等于把分割的收益抵消了。 -
「代码分割有什么代价?」
三个:① 请求数增加——chunk 太多会导致网络请求变多(HTTP/2 下影响较小,但仍有开销);② 瀑布式加载——如果懒加载的 chunk 里又依赖别的 lazy chunk,会形成「加载完了才知道要再加载」的串行瀑布,反而更慢;③ 加载状态与错误处理——需要 fallback UI(注意设定高度避免 CLS),还要处理「chunk 加载失败」(用 ErrorBoundary + 重试)。所以不是「分得越细越好」。 -
「怎么决定分割哪些模块?」
先分析产物(rollup-plugin-visualizer/webpack-bundle-analyzer),然后按三条标准决策:① 体积大的(超过 50KB 的优先考虑);② 首屏不需要的(如设置页、编辑器、图表);③ 被多个 chunk 共享的(提取到公共 chunk,避免重复打包)。同时要检查「大依赖的按需引入」——lodash全量、moment、图标库全量这类是常见的「隐形大头」。 -
「懒加载的 chunk 加载失败怎么办?」
三个层次:① UI 兜底——用 ErrorBoundary 包住Suspense(注意顺序:<ErrorBoundary><Suspense>),加载失败时显示「加载失败 + 重试」而不是一直 loading;② 重试机制——React.lazy的import()失败后可以「重新import()」(某些打包工具会缓存失败结果,可以加时间戳参数强制重试);③ 版本更新导致的 404——用户停留在旧版本页面、部署后旧 chunk 被删除会拿到 404,常见做法是「提示刷新」或「自动 reload」(配合vite:preloadError/ webpack 的chunkLoadError监听)。
一句话速记
代码分割 = 用动态
import()把 bundle 按需拆分,解决首屏加载;在 React 里用React.lazy+Suspense(必须默认导出、必须有 Suspense,SSR 支持有限);优先按路由分(收益最大),再按「重型组件/弹窗/低频功能」分;Suspense要放在「内容区」而不是最外层(否则整页闪骨架),fallback 要有确定高度避免 CLS;用 hover/focus 或requestIdleCallback预加载让懒加载无感(但别全量预取);先用rollup-plugin-visualizer分析产物再决定分割点;懒加载要配 ErrorBoundary 兜底(chunk 加载失败不能一直 loading)。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!