首屏包 1.8 MB,分析报告里最大的一块是某个 UI 组件库。团队确认只用到了其中的 Button 和 Modal,于是把 import { Button, Modal } from 'lib' 改成按需引入——体积只降了 40 KB。再查下去发现:包里进了整个库,因为它有一个顶层 registerTheme() 调用。
这件事的机制值得说明白:tree-shaking 不是"智能判断你有没有用到",而是"在静态可达性 + 副作用假设下的删除"。只要打包器认为某个模块"可能有副作用",它就不敢删——哪怕你一行都没用。
1. 删除的前提:它认为这段代码"删了也不影响"
| 判据 | 打包器怎么看 | 结果 |
|---|---|---|
| 静态可达(有没有被 import 引用) | 分析 import/export 图 | 不可达 → 可删 |
| 是否无副作用 | 看 sideEffects 声明 / 顶层语句 |
有疑问 → 保留 |
| 是否可求值(能否在编译期算出) | 常量折叠 | 算不出 → 保留 |
| 是否被动态引用 | require(变量) / 字符串拼接 |
有动态 → 整包保留 |
// 同一个按钮组件,两种包的体积贡献(实测形状)
lib 未声明 sideEffects: Button 2KB + 整个库 460KB 一起进包
lib 声明 sideEffects:false: Button 2KB + 它依赖的 6KB = 8KB
// 差的 452KB 全是"这段代码可能做事"的保险费
第二行是主战场:默认假设"有副作用"。所以一个库如果没有声明 sideEffects: false,它的每个模块都会被视为"可能做事",于是全部保留。
// 同一个 import,两种结果
import { Button } from 'lib' // lib 未声明 sideEffects → 整个 lib 进包
import { Button } from 'lib' // lib 声明 sideEffects:false → 只留 Button 及其依赖
// 差别不是语法,是包里的元数据
2. 三种"删不掉"的写法
| 写法 | 为什么留 | 怎么改 |
|---|---|---|
import 'polyfill'(无绑定) |
导入本身可能就是副作用 | 确认后改为按需调用 |
顶层 registerTheme() / window.x = ... |
顶层语句必定执行 | 移进函数或懒加载 |
装饰器 / prototype 扩展 |
元数据在运行期,静态分析看不见 | 换成显式注册 |
// 三种写法的静态分析结果对比
export const add = (a,b) => a+b → 可摇(纯函数,无顶层语句)
registerPlugin({ name:'x' }) → 保留(顶层调用,被判有副作用)
import './theme.css' → 保留(且声明 sideEffects:false 后会消失)
// 第二条最常见:库的 index.js 里做了自动注册
第三行在前端框架里很常见:装饰器(或 Object.defineProperty 扩展原型)让"类"与"元数据"的关系只在运行时成立,静态分析无法判断"这个类有没有被用",只能整块保留。
3. sideEffects: false 是承诺,不是开关
| 库的真实行为 | 声明 sideEffects: false 后 |
|---|---|
| 纯函数 / 纯组件 | 正确,能安全摇掉 |
| 自动注册(插件、主题、polyfill) | 会坏:注册代码被删了 |
注入 CSS(import './style.css') |
会坏:样式消失 |
| 全局补丁(修改 Array.prototype) | 会坏:运行时报错 |
第三行是最常见的翻车点:很多库在 JS 模块里 import 了 CSS。声明"无副作用"后,打包器把这条 import 删了,页面样式就丢了——而错误只在视觉上体现,测试通常发现不了。
// 正确的声明方式:只把纯模块标成无副作用
{ "sideEffects": ["*.css", "*.scss", "./src/register.js"] }
// 语义:除了这些,其余模块都无副作用 —— 而不是整个包都无副作用
4. 体积归因:先看报告,再改代码
| 手段 | 能看到什么 |
|---|---|
--analyze(webpack)/ rollup-plugin-visualizer |
每个模块占多少 |
| source map + 逐行归属 | 某个大文件里哪几行在包里 |
构建产物的 import 图 |
谁引入了谁 |
| 对比两次构建的 diff | 这次改动新增了多少 |
第四行是最实用的日常动作:每次 PR 比一次产物体积,涨了就看 diff。体积不是一次涨上去的,而是每次"顺手加个依赖"累积出来的——而等到有人想起来优化时,已经很难判断哪 200 KB 是必要的、哪 200 KB 是历史遗留。,涨了就看 diff。它比"季度性优化"有效得多,因为体积是慢慢涨上去的。
// 一个可用的体积门禁(CI 里跑,超阈值就失败)
$ npm run build && du -k dist/*.js | sort -rn | head -5
$ node -e "const s=require('./dist/stats.json');console.log(s.total)"
// 与基线比:+5% 警告,+10% 失败;并输出"新增最大的三个模块"
5. 什么时候"包大"不是问题
| 场景 | 为什么 | 说明 |
|---|---|---|
| 内网后台系统 | 首次加载慢一次,之后走缓存 | 不值得优化 |
| 首屏不是它 | 路由懒加载已隔离 | 看首屏包 |
| 用户量极小 | 省下的流量 < 优化成本 | 先看总量 |
| 有强缓存策略 | 只有首次付传输成本 | 关键在缓存头 |
第四行常被忽略:在强缓存 + 内容哈希的前提下,"体积大"只影响首次访问,而首次访问的转化损失远小于"为优化体积引入的复杂度与 bug"。这也是为什么后台系统与工具类产品几乎不该为体积立项——它们的用户每天来,缓存永远命中。,"体积大"只影响首次访问**,而首次访问的转化损失远小于"为优化体积引入的复杂度与 bug"。
6. 排错:五种"改了 imports 没变小"
| 现象 | 八成原因 | 处理方向 |
|---|---|---|
| 改按需引入只降一点 | 库未声明 sideEffects |
查包的 package.json |
| 删了组件体积没变 | 有动态 require 引用整包 |
查动态 import 写法 |
| 声明无副作用后样式丢了 | CSS 被当作副作用删掉 | 白名单里加 *.css |
| 某个类始终在包里 | 装饰器元数据不可静态分析 | 换显式注册 |
| 本地小了、线上没变 | CDN 缓存未失效 | 换 hash 文件名 |
第五行是发布侧的问题,但它会让"优化生效"这件事被误判成失败——先确认线上真的拉到了新包。
7. 边界:什么情况不该为体积重构
| 场景 | 为什么 | 做法 |
|---|---|---|
| 已经满足性能预算 | 无收益 | 只监控 |
| 优化需要改库的导出结构 | 影响所有使用方 | 换库更划算 |
| 体积来自业务代码本身 | 摇不掉 | 拆分路由 / 懒加载 |
| 首屏指标达标但总包大 | 用户没感知 | 不必动 |
判断口径一句话:体积优化的收益要看"首屏包里有多少是这次能摇掉的"——如果大头是业务代码或已懒加载的 chunk,那么调 sideEffects 只会带来风险而没有收益。判据:先看分析报告里"未使用但被保留"的占比,低于 10 % 就别动。
8. 自测
- tree-shaking 的两个前提是什么?为什么"没声明
sideEffects"会导致整包保留? import 'x'(无绑定)为什么几乎必然被保留?sideEffects: false在什么情况下会让线上坏掉?CSS 为什么是重灾区?- 装饰器为什么让类无法被摇掉?静态分析缺的是什么信息?
- 为什么"每次 PR 比一次体积"比"季度性优化"更有效?
- 什么情况下"包大"根本不是问题?判据是什么?
9. 一句话总结
tree-shaking 是"静态可达 + 副作用假设",不是"智能判断有没有用到":默认假设有副作用,所以未声明 sideEffects 的库会整包保留;而 import 'x'(无绑定)几乎必然留下,因为导入本身可能就是副作用。sideEffects: false 是承诺不是开关——库里有 CSS 注入或自动注册时,声明它会让线上静默坏掉(样式消失、注册缺失)。判据:先看报告里"未使用但被保留"的占比,低于 10 % 就别动。