首页 / React 学习笔记 / 39

REACT · Vol.VI · LESSON 39 · 性能优化与部署

代码分割与懒加载

进阶#性能#打包

你的 bundle 正在长成一个 fat jar

第 37、38 篇解决了「渲染慢」:memo 挡住多余重渲染,虚拟列表只画可视区。但还有个更前置的瓶颈——首屏要下载的 JS 本身太大。代码还没到浏览器,谈何执行得快。

先看一个运行了半年的中后台项目里最常见的 App.tsx:

App.tsx · 所有页面静态 import,全进一个包
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,打包器就必须全收——它只能假设你「随时可能用到」。
后端类比:fat jar、微服务与 OSGi 插件化

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 负责把「异步到达的组件」接进渲染树:

App.tsx · 组件级懒加载三件套
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>
      )}
    </>
  );
}

三个角色各司其职:

  1. import("./pages/Dashboard"):告诉打包器「单独切一个 chunk」,运行时按需下载;
  2. lazy(...):把「返回组件 Promise 的函数」包装成能写进 JSX 的组件,首次渲染时触发下载;它约定取模块的 default 导出,被懒加载的页面记得 export default
  3. <Suspense fallback>:声明「children 还没就绪时渲染什么」,转圈、骨架屏都行。
后端类比:@Lazy 的 Bean 与 JVM 懒加载

Spring 容器默认启动时实例化所有单例 Bean;加上 @Lazy 后,第一次 getBean 才初始化。React.lazy 就是显式的 @Lazy:延迟到第一次渲染才加载。再往底层想,JVM 的类加载本来就是懒的——.class 要等第一次 new 才加载、初始化。React.lazy 相当于把这套默认行为搬到了组件粒度。

两个细节:chunk 小、网快时 fallback 会闪一下,骨架屏反而不如保持原界面;lazy 的 Promise 一旦 reject,组件树渲染失败,需要 ErrorBoundary 兜底给重试入口——加载态和错误态要分开设计。

按路由拆包:最自然的切割线

lazy 可以包任何组件,但粒度选在哪最划算?答案是路由。第 30 篇说过,路由是用户访问的自然边界:切页本来就有「过渡」的心理预期——这里多 200ms 用户毫无怨言;弹窗里多 200ms 就是「卡」。

App.tsx · 第 30 篇的路由表,升级成按需加载版
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>
  );
}

效果立竿见影,一张图看明白:

拆分前:一个 fat bundle 浏览器 首屏请求 app.js · 1.8 MB 全部页面 + 全部依赖 一次性下载 1.8 MB 其中首页用不到的占大头 拆分后:路由级拆包 + 按需加载 浏览器 main.js · 240 KB 首屏立即加载 Dashboard chunk · 620 KB Admin chunk · 890 KB 首次访问时按需拉取 拉取一次即缓存
图 1拆包前后对比:拆分前首屏全量下载单个 bundle;拆分后只加载 main.js,各路由 chunk 首次访问时按需拉取,之后走缓存。

从图里读出三个要点:主包从 1.8 MB 降到 240 KB;重组件首次访问才下载;下载一次就进缓存,第二次访问零成本。拆包粒度一共三级:

粒度切什么典型场景后端类比
路由级每个页面一个 chunk,默认首选多页面中后台:首页、看板、后台互不拖累按业务域拆微服务
组件级「首次交互才出现」的重组件点「预览」才弹的全屏编辑器、抽屉里的大表格重对象延迟到首次使用才初始化
重库级大第三方库单独收口echarts、Monaco、xlsx:包一层 lazy 组件全局复用重依赖收进独立模块,谁用谁引

路由级是默认粒度还有两个工程理由:边界稳定——页面天然是低耦合区,切出来不纠结循环依赖;缓存友好——改 Dashboard 不会让 Admin chunk 的 hash 变化(第 40 篇细讲),没访问过的页面缓存永不失效。

动态 import() 不是 tree-shaking

这两个词经常一起出现,但干的完全是两码事:

  • tree-shaking(打包期剪枝):静态分析 ESM 的 import/export 图谱,把没人引用的导出从产物里删掉。减少的是打进包里的代码量,剩下的仍首屏全量到达;
  • 动态 import()(运行期按需):一行不删,只是把活代码推迟到需要的那一刻才下载。优化的是到达的时机
export.ts · 两种机制各管一段
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 直接命中,几乎零等待。一行代码,抹掉大半延迟感。

为什么 tree-shaking 只对 ESM 好使?CommonJS 的 require 路径运行时才算得出,打包器不敢删;ESM 的 import 编译期就完全定型。这个「静态可分析」特性,也是下一篇 Vite 敢在 dev 用原生 ESM 的底气。

拆到什么程度算合适

最后泼一盆冷水:拆包不是越细越好。拆得太碎,一次页面切换拉起几十个几 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 · 评论