本节目标:掌握把「必须加载的代码」与「可以晚点加载的代码」分开的方法。你会知道动态
import()的类型如何推导、tree-shaking 在什么条件下才真正删掉代码、sideEffects字段为什么关键、分包策略该怎么定,以及哪些写法会让优化静默失效。
15.2 代码分割与 tree-shaking
上一节讲了 Vite 怎么把 TS 转成 JS。这一节换个方向:转出来的 JS 有多少是用户当下必须下载的。
体积优化的第一原则是:首屏的敌人不是代码总量,而是「用不到的代码被提前加载」。一个 2 MB 的后台管理页面如果只在点开报表时才需要图表库,那 400 KB 的图表库就不该出现在首屏 chunk 里。代码分割解决「什么时候加载」,tree-shaking 解决「加载时带不带用不到的部分」,两者配合才能把首屏压下来。
15.2.1 静态导入与动态导入
普通的 import 是静态的:Rollup 在构建期就能看到完整的模块图,因此可以把它和入口合进同一个 chunk。
// 静态导入:进入入口 chunk,随首屏一起下载
import { formatDate } from './date';
import() 是表达式,返回 Promise,Rollup 会以它为边界切出一个新 chunk,直到运行到这行才发起网络请求:
const { renderChart } = await import('./chart');
renderChart(canvas, data);
这两行的差别在产物里非常直观:
dist/assets/index-Cq3x8K.js 182.44 kB │ gzip: 58.21 kB
dist/assets/chart-Dk92mP.js 412.08 kB │ gzip: 132.77 kB
chart 被独立出来,首屏只付 182 KB。
15.2.2 动态 import 的类型推导
import() 返回的是模块命名空间对象,TS 会完整推导它的类型,不需要手写断言:
// chart.ts 导出 renderChart(canvas: HTMLCanvasElement, data: Point[]): void
const mod = await import('./chart');
// mod: typeof import("./chart")
mod.renderChart(canvas, data); // 参数类型与返回值都被检查
写错方法名会立刻报错:
src/report.ts:8:9 - error TS2339: Property 'rendarChart' does not exist
on type 'typeof import("./chart")'. Did you mean 'renderChart'?
解构写法同样保留类型,并且更常用:
const { renderChart } = await import('./chart');
如果只想拿类型、不想触发运行时加载,仍然用 import type——它不产生 chunk:
import type { Point } from './chart';
function prepare(data: unknown[]): Point[] {
return data.map((d) => d as Point);
}
一个容易忽略的点:类型本身不会跨 chunk 传递运行时开销。把 Point 拆到独立文件再两边 import type,不会多出任何字节。
15.2.3 tree-shaking 生效的三个前提
tree-shaking 不是「自动去掉没用的代码」,它依赖三个前提同时成立:
| 前提 | 含义 | 破坏方式 |
|---|---|---|
| ESM 静态语法 | import/export 可静态分析 | 用 CJS 的 require/module.exports |
| 无副作用声明 | 模块可安全删除 | 模块顶层有副作用代码 |
| 压缩器死代码消除 | 删除被标记的死代码 | 关闭 minify 或只在 dev 构建 |
第一条最关键。Rollup 只能分析 ESM 的静态结构,遇到 require() 就只能整包保留。判断一个依赖是否可摇,先看它的 package.json:
{
"name": "some-lib",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs" } }
}
如果只有 main 指向 CJS,import { pick } from 'some-lib' 会把整个库打进来——因为 CJS 的对象是动态构造的,Rollup 无法判断哪个属性被用到。
15.2.4 sideEffects:让删除成为可能
即使全是 ESM,Rollup 仍需知道「删掉这个模块是否安全」。默认它是保守的:只要模块顶层有代码,就假定有副作用。
// utils/legacy.ts —— 顶层执行了注册,Rollup 不敢删
import { registry } from './registry';
registry.register('legacy', () => {});
export function unusedHelper() {}
如果这个模块只被 import './legacy' 的形式引用,或者被一个 barrel 文件整体 re-export,那么即使 unusedHelper 从没被调用,整块代码也会留在产物里。解决办法是在库自己的 package.json 里声明:
{
"name": "my-ui-lib",
"sideEffects": ["*.css", "./src/polyfill.ts"]
}
"sideEffects": false 表示「所有模块都可安全删除」,只保留数组里列出的例外。注意 CSS 一定要列进去,否则 import './button.css' 会被误删,样式直接消失。
这条声明对你自己的应用同样有效。很多应用把 src 拆成 index.ts barrel:
// src/components/index.ts
export * from './Button';
export * from './Modal';
export * from './Chart';
只要每个组件文件都无副作用、且应用侧声明了 sideEffects: false,import { Button } from '@/components' 就只会带进 Button。反过来,barrel 文件里只要有一行 console.log 或全局注册,整包就会被拖进来。
15.2.5 让代码不可摇的四种写法
// 1) 顶层副作用:模块一旦被引用就整体保留
window.__APP_VERSION__ = '1.0.0';
// 2) enum 会生成运行时对象,无法逐成员摇掉
export enum Status { Idle, Loading, Done }
// 3) 动态属性访问让静态分析失效
export const handlers = { onClick: fn1, onBlur: fn2 };
const key = getUserInput();
handlers[key]();
// 4) 桶文件里用 export default 聚合
export default { Button, Modal, Chart };
第 2 条在工程里最常见。enum 编译后会生成一个 IIFE 对象,即使用到其中一个成员,整个对象也得留下。多数场景可以用 as const 联合类型替代,既有同样的类型约束,又完全没有运行时开销:
export const STATUS = {
Idle: 'idle',
Loading: 'loading',
Done: 'done',
} as const;
export type Status = (typeof STATUS)[keyof typeof STATUS];
// type Status = "idle" | "loading" | "done"
第 4 条也值得强调:export default 一个对象字面量,Rollup 无法确定你只用了 Button,只能整体保留。用命名导出才能逐项摇。
15.2.6 manualChunks:按策略分包
默认策略下,chunk 边界等于动态 import() 边界。但「所有第三方依赖都进一个 vendor chunk」这类需求需要显式配置:
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (/react|react-dom|scheduler/.test(id)) return 'react';
if (id.includes('echarts')) return 'charts';
return 'vendor';
}
},
},
},
},
});
manualChunks 返回 string 表示归入该 chunk,返回 undefined 表示交给默认策略。三个实用原则:
- 按更新频率分组:框架与图表库更新频率天差地别,混在一个 chunk 里会让用户每次发版都重下 400 KB。
- 别切太碎:HTTP/2 下多路复用缓解了请求数问题,但每个 chunk 都有独立开销,切到几十个反而更慢。
- 公共依赖单独提:被多个懒加载路由共享的模块应放进共享 chunk,否则会被复制进每个路由 chunk。
分包策略与浏览器缓存的关系,在下一节会与 chunk 命名一起展开。
15.2.7 框架层懒加载
React 用 lazy + Suspense,lazy 要求模块提供 default 导出:
import { lazy, Suspense } from 'react';
const Chart = lazy(() => import('./Chart'));
export function Dashboard() {
return (
<Suspense fallback={<Spinner />}>
<Chart data={data} />
</Suspense>
);
}
如果 ./Chart 只有命名导出,会直接报类型错误:
src/Dashboard.tsx:5:33 - error TS2322: Type 'Promise<typeof import("./Chart")>'
is not assignable to type 'Promise<{ default: ComponentType<any>; }>'.
Property 'default' is missing in type 'typeof import("./Chart")'.
两种修法:给组件文件加 export default,或在 import() 后包一层:
const Chart = lazy(() =>
import('./Chart').then((m) => ({ default: m.Chart })),
);
路由级懒加载是收益最大的一处。React Router 的 lazy 字段、Vue 的 defineAsyncComponent、Next.js App Router 的按路由自动分割,本质都是把动态 import() 下沉到路由边界。Next.js 的做法见 《TypeScript编程实战》12.2 Next.js App Router 类型(Server Actions / Route Handlers)
,Vue 侧的异步组件见 《TypeScript编程实战》12.1 Vue 3 组合式 API 类型
。
懒加载组件一旦加载失败(网络抖动、发版后旧 chunk 404),需要错误边界兜底,见 《TypeScript编程实战》3.2 全局错误边界与未捕获异常 。
15.2.8 预加载与 modulePreload
切分之后还有一个问题:下载开始得太晚。用户点了「报表」,浏览器才发现需要 chart chunk,于是等一个 RTT 才发请求。预加载就是把这段时间省掉。
Vite 在构建时会自动为动态 chunk 的静态依赖注入 <link rel="modulepreload">,浏览器解析 HTML 时就开始下载。相关配置在 build.modulePreload:
export default defineConfig({
build: {
modulePreload: { polyfill: true }, // 为不支持该特性的浏览器注入 polyfill
},
});
polyfill 大约 1 KB,只在目标浏览器缺失该特性时才注入。如果你自己已经在入口用 import 静态引用了框架代码,它同样会被 preload——这也是「首屏只该有一个入口 chunk」的原因。
更高阶的做法是在用户悬停时预取。关键是复用同一个 loader 函数,让模块缓存命中:
import { lazy } from 'react';
const chartLoader = () => import('./Chart');
const Chart = lazy(chartLoader);
export function ReportButton() {
return (
<button
onMouseEnter={() => void chartLoader()}
onClick={() => setOpen(true)}
>
打开报表
</button>
);
}
chartLoader 的类型是 () => Promise<typeof import("./Chart")>。在 onMouseEnter 里直接调用它,等于提前把 chunk 下载并执行完;等真正渲染 <Chart /> 时,lazy 拿到的 Promise 已经 resolved。不要写成另一个 () => import('./Chart')——虽然类型相同,但 Rollup 会把它识别成同一个 chunk,运行时也共享模块缓存,看似无害;真正的问题在于容易漏掉某一处而让预取失效,统一引用一个 loader 更不容易写错。
路由级别的预取可以结合 requestIdleCallback 在空闲时预热下一个可能访问的路由,但这属于「锦上添花」——首屏该加载的东西没有正确切分时,预取只会让带宽竞争更严重。
15.2.9 五类静默失效场景
一、依赖是 CJS。 摇不掉,只能换包或改用按需引入。
二、忘了声明 sideEffects。 尤其是自研组件库,缺这一行会让 barrel 导入退化成全量引入。
三、用了 enum。 改成 as const 对象加联合类型。
四、export default 聚合对象。 改成命名导出。
五、只在 dev 下看产物。 dev 阶段 Vite 不做 tree-shaking,必须 vite build 后再分析。
15.2.10 与本书其它章节的衔接
动态 import() 切出的 chunk 里如果包含数据请求,预取与缓存策略见 《TypeScript编程实战》14.3 分页、无限滚动与预取
;组件 props 的类型设计见 《TypeScript编程实战》11.1 组件 props 与泛型组件
。
站内延伸阅读:Vite tree-shaking 深度剖析 、Vite 模块图内部原理 、esbuild 原理 、前端构建工具选型指南 。
小结
代码分割与 tree-shaking 是两个独立的问题。分割靠动态 import() 划边界,类型系统会完整推导模块命名空间,写错导出名当场报错;lazy 与路由级懒加载只是把这条边界下沉到组件与路由层。摇树则依赖三个前提同时成立:ESM 静态语法、模块无副作用、压缩器执行死代码消除。其中最容易失控的是第二项——一个顶层 console.log、一个 enum、一个 export default 聚合对象,都足以让整包代码留在产物里。
判断是否生效只有一个可靠办法:跑 vite build,看产物大小与 chunk 划分,而不是凭直觉。下一节就把这套「看产物」的方法做成可重复的诊断流程,并给出把体积写进 CI 门禁的做法。
阅读导航:上一节:15.1 Vite 与 TS 集成 · 下一节:15.3 构建性能诊断与包体积治理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。