REACT · Vol.VI · LESSON 39 · 性能优化与部署
代码分割与懒加载
你的 bundle 正在长成一个 fat jar
第 37、38 篇解决了「渲染慢」:memo 挡住多余重渲染,虚拟列表只画可视区。但还有个更前置的瓶颈——首屏要下载的 JS 本身太大。代码还没到浏览器,谈何执行得快。
先看一个运行了半年的中后台项目里最常见的 App.tsx:
import { Routes, Route } from "react-router-dom";
import Home from "./pages/Home";
import Dashboard from "./pages/Dashboard"; // 内部打包了 echarts,约 1 MB
import Admin from "./pages/Admin"; // 内部打包了 Monaco 编辑器,约 2 MB
export default function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/admin" element={<Admin />} />
</Routes>
);
}
三行 import 都是静态依赖:打包器只能忠实把关联代码(连同 echarts、Monaco)全部打进同一个 bundle。而一个真实用户可能一辈子只访问首页。bundle 越滚越大,无非三个来源:
- 依赖膨胀:npm install 一时爽——图表、编辑器、工具库层层叠叠。类比 Maven 传递依赖:引一个 starter,拖进来半个生态;
- 全页面打包:SPA 把所有路由的代码合进一个文件,首屏一次性全量下载;
- 静态 import 的原罪:只要顶部写了 import,打包器就必须全收——它只能假设你「随时可能用到」。
Spring Boot 的 fat jar 把全部依赖塞进一个 jar,部署省事,代价是启动全量加载。前端单 bundle 就是这个 fat jar:加载即启动。Java 世界的答案是拆分与按需:微服务按业务域拆部署单元,Eclipse 的 OSGi 插件按需激活。前端对应方案叫代码分割(Code Splitting)——把 app 拆成按需加载的 chunk。
React.lazy + Suspense:用到再加载
拆包的钥匙是动态 import():把静态 import Dashboard from ... 改成函数调用 import("./pages/Dashboard"),它就从「打包期依赖」变成「运行时返回 Promise」。打包器一看到 import() 就切出独立 chunk;lazy 和 Suspense 负责把「异步到达的组件」接进渲染树:
import { useState, lazy, Suspense } from "react";
import PageLoading from "./components/PageLoading";
// 静态写法(打包期依赖,进主包):
// import Dashboard from "./pages/Dashboard";
// 懒加载写法:Dashboard 被切成独立 chunk,首次渲染才下载执行
const Dashboard = lazy(() => import("./pages/Dashboard"));
export default function App() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>打开数据看板</button>
{open && (
<Suspense fallback={<PageLoading />}>
<Dashboard />
</Suspense>
)}
</>
);
}
三个角色各司其职:
import("./pages/Dashboard"):告诉打包器「单独切一个 chunk」,运行时按需下载;lazy(...):把「返回组件 Promise 的函数」包装成能写进 JSX 的组件,首次渲染时触发下载;它约定取模块的 default 导出,被懒加载的页面记得export default;<Suspense fallback>:声明「children 还没就绪时渲染什么」,转圈、骨架屏都行。
Spring 容器默认启动时实例化所有单例 Bean;加上 @Lazy 后,第一次 getBean 才初始化。React.lazy 就是显式的 @Lazy:延迟到第一次渲染才加载。再往底层想,JVM 的类加载本来就是懒的——.class 要等第一次 new 才加载、初始化。React.lazy 相当于把这套默认行为搬到了组件粒度。
按路由拆包:最自然的切割线
lazy 可以包任何组件,但粒度选在哪最划算?答案是路由。第 30 篇说过,路由是用户访问的自然边界:切页本来就有「过渡」的心理预期——这里多 200ms 用户毫无怨言;弹窗里多 200ms 就是「卡」。
import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import PageLoading from "./components/PageLoading";
// 每个页面一个 chunk;首屏页想极致一点也可以不拆
const Home = lazy(() => import("./pages/Home"));
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Admin = lazy(() => import("./pages/Admin"));
export default function App() {
return (
<Suspense fallback={<PageLoading />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
{/* 嵌套路由整棵子树,一起进一个 chunk */}
<Route path="/admin/*" element={<Admin />} />
</Routes>
</Suspense>
);
}
效果立竿见影,一张图看明白:
从图里读出三个要点:主包从 1.8 MB 降到 240 KB;重组件首次访问才下载;下载一次就进缓存,第二次访问零成本。拆包粒度一共三级:
| 粒度 | 切什么 | 典型场景 | 后端类比 |
|---|---|---|---|
| 路由级 | 每个页面一个 chunk,默认首选 | 多页面中后台:首页、看板、后台互不拖累 | 按业务域拆微服务 |
| 组件级 | 「首次交互才出现」的重组件 | 点「预览」才弹的全屏编辑器、抽屉里的大表格 | 重对象延迟到首次使用才初始化 |
| 重库级 | 大第三方库单独收口 | echarts、Monaco、xlsx:包一层 lazy 组件全局复用 | 重依赖收进独立模块,谁用谁引 |
路由级是默认粒度还有两个工程理由:边界稳定——页面天然是低耦合区,切出来不纠结循环依赖;缓存友好——改 Dashboard 不会让 Admin chunk 的 hash 变化(第 40 篇细讲),没访问过的页面缓存永不失效。
动态 import() 不是 tree-shaking
这两个词经常一起出现,但干的完全是两码事:
- tree-shaking(打包期剪枝):静态分析 ESM 的 import/export 图谱,把没人引用的导出从产物里删掉。减少的是打进包里的代码量,剩下的仍首屏全量到达;
- 动态 import()(运行期按需):一行不删,只是把活代码推迟到需要的那一刻才下载。优化的是到达的时机。
type Row = { id: number; title: string };
// tree-shaking:ESM 静态结构可分析,产物里只留下用到的 debounce
import { debounce } from "lodash-es";
const debouncedSave = debounce(save, 500);
// 动态 import:xlsx 整个库不进主包,点「导出」那一刻才下载
export async function exportRows(rows: Row[]) {
const { toXlsx } = await import("./lib/export-xlsx");
toXlsx(rows);
}
| 维度 | tree-shaking | 动态 import() |
|---|---|---|
| 发生时机 | 构建期(静态分析) | 运行时(用户触发) |
| 处理对象 | 没人引用的死代码 | 真实会用的活代码 |
| 产物形态 | 变小,但还在同一个 chunk 里 | 独立 chunk,按需请求 |
| 前提条件 | ESM 的静态 import/export | 打包器识别 import()(Vite/Rollup 原生支持) |
| 后端类比 | ProGuard 删未用类、依赖裁剪 | 插件按需激活、ClassLoader 延迟加载 |
两者经常配合:先剪掉死代码,再把活代码分批送达。顺带一个实用小技巧——预加载:悬停到点击之间有 100~300ms,给导航入口加一句 onMouseEnter={() => import("./pages/Admin")},chunk 提前进缓存,真点击时 lazy 直接命中,几乎零等待。一行代码,抹掉大半延迟感。
拆到什么程度算合适
最后泼一盆冷水:拆包不是越细越好。拆得太碎,一次页面切换拉起几十个几 KB 的小文件——HTTP/2 解决了队头阻塞,但每个请求仍有头部与调度开销,碎片化请求的总耗时可能反超一个大文件;缓存也跟着碎片化,一团乱麻。
经验法则:以「用户的访问单元」为切割粒度:路由级是默认,组件级留给重组件,其余不拆;小于 20~30KB 的模块单独拆不划算,省下的流量抵不上一次请求的开销。自查表:
| Network 面板的症状 | 诊断 | 处理 |
|---|---|---|
| 首屏一个 1.5 MB+ 的 app.js | 从没拆过包 | 路由级 lazy 起步 |
| 点击菜单后白屏转圈 1~2 秒 | 目标页面 chunk 太大,或没预载 | 检查页面内的重依赖;入口 hover 预取 |
| 一次页面切换拉起 30+ 个小文件 | 拆得太碎 | 收敛到路由级 + 重组件两层,其余合并 |
| 每次发版全部 chunk 缓存失效 | 分包与 hash 策略不合理 | 第 40 篇:manualChunks 固定依赖 + 内容 hash |
- bundle 膨胀的根因:静态 import 全量打包 + 首屏全量下载——你部署了一个前端 fat jar。
React.lazy(() => import())把组件切成独立 chunk、首次渲染才加载;Suspense负责 fallback 占位,失败要 ErrorBoundary 兜底。- 路由级拆包是默认粒度(边界稳、缓存友好),组件级与重库级(echarts / Monaco / xlsx)按需补充。
- tree-shaking 是构建期剪枝(删死代码),动态 import() 是运行期按需下载——常配合使用,不是一回事。
- hover 时一句
import()即可预取;警惕过度拆包的请求碎片化,20~30KB 以下不值得单独拆。
本章回顾
本篇把「应用启动」按需化了:lazy + Suspense 让代码第一次使用才到达,路由级拆包给出最自然的切割线,动态 import 与 tree-shaking 一个管时机、一个管体积。但这些 chunk 是谁切的、怎么切的?为什么 dev 模式下根本看不到带 hash 的文件名?下一篇第 40 篇走进 Vite 构建原理,把这台「前端的 Maven」拆开给你看。
Comments · 评论