Tree Shaking 为什么会失效
Tree Shaking 依赖「静态可分析」。只要代码里出现运行期才能确定的结构,它就会保守地全部保留。
一、静态可分析是前提
Tree shaking 依赖的是静态的模块结构:打包器要在不执行代码的前提下,看出哪个导出被引用、哪个没被引用。任何让这层分析失效的东西都会打断它——模块被转成了 CommonJS、引用路径是运行时拼出来的、动态属性访问取成员、或者模块顶层存在无法判定是否有副作用的代码。所以「为什么没摇掉」的答案,几乎总在这四条里。
更具体地说,它有两个硬前提。第一,必须是 ESM(import / export)。这是静态声明,Rollup、esbuild、Rolldown、以及 webpack 的生产模式下都能在不执行的情况下构建出依赖图。CommonJS 的 require / module.exports 是运行期赋值,无法静态分析,一旦混进来,整包往往就保不住。第二,sideEffects 字段要标对。它告诉打包器「这个包有没有副作用」,是按需裁剪的依据——标错了,要么摇不掉,要么把真正需要的副作用删了。
二、六类让分析失败的写法
最常见的两种。第一种是副作用无法判定:模块顶层执行了语句(注册、打点、修改原型),打包器不敢删,宁可整段保留;解法是用 sideEffects 字段或 /*#__PURE__*/ 标注。第二种是整包 re-export:写了个 index.ts 把所有模块导出,业务只 import 其中一个函数,但因为入口文件的存在,整个包的关系网被拉进分析范围,裁剪效率大幅下降。
其余四种同样常见:
- 用了 CommonJS:
require之后动态取值,分析直接失效,整包被打进产物。 - 动态取值:
import * as utils之后用utils[key]这种运行时才确定的键去取属性,打包器无法在构建期判断你用了哪个,只能全留。(静态写法utils.foo()现在多数打包器已经能分析出实际用到的成员——真正失效的是动态键。)
import * as utils from 'some-lib'
// 动态取成员,打包器无法判定用到了哪些导出
utils.formatDate(new Date('2026-01-01'))
- Babel 把 ESM 转成 CJS:
preset-env的modules没设成false,编译产物变成 CommonJS,前面的静态分析前提就没了。
// babel.config 里如果这样写,会降级模块语法,tree shaking 失效
presets: [['@babel/preset-env', { modules: 'auto' }]]
// 正确写法:交给打包器处理,不要提前降级
presets: [['@babel/preset-env', { modules: false }]]
- class 属性 / 装饰器触发副作用:装饰器在编译期可能插入顶层副作用代码,导致整个类被保留。
- 动态
import路径拼接:import('./modules/' + name)无法在编译期确定目标,打包器只能把整个目录都打进包。
sideEffects 与 /*#__PURE__*/ 是两面刃:标对了能摇掉大量死代码,标错了会删掉真正需要的副作用(比如样式导入、polyfill 注册)。sideEffects: false 的含义是「本包没有任何副作用」,打包器会放心删掉未被引用的模块;一旦某个模块其实有副作用(例如全局注册),你却标了 false,它就被静默删除了。
{ "name": "my-lib", "sideEffects": false }
const result = /*#__PURE__*/ computeSomething() // 无副作用,未被使用可删除
三、用产物反查未消除的模块
排查要从「定位」开始,而不是上来就改配置。三种有效手段:
- 产物分析:用 rollup-plugin-visualizer、webpack-bundle-analyzer 一类工具看 chunk 内容,定位哪块体积异常、是哪个模块被整包拉进来的。
- 构建工具的理由输出:很多打包器能打印「为什么这个模块被保留 / 被打包进来」,顺着理由往上找,通常能定位到某个 re-export 或副作用标记。
- 临时删依赖对比:把可疑依赖临时去掉,对比产物体积变化,能快速确认它是不是罪魁。
先定位再动手,比盲猜配置高效得多。
确认某个导出到底有没有被摇掉,直接在产物里搜它的名字最快:
# 能搜到就说明没摇掉
grep -c "legacyHelper" dist/assets/*.js
# 想定位它属于哪个模块,配合 sourcemap 看体积构成
npx source-map-explorer dist/assets/index-*.js
# 注意:压缩后函数名会变,搜之前先确认没有开启 mangle 混淆这块
四、按成因逐类修
修复有优先级,不要一上来就改库:
- 先改用法:按需引入,避免
import *;动态属性访问改成具名引用;import路径不要运行时拼接。 - 再改配置:确认 Babel 不降级模块语法(
modules: false);把sideEffects标对(只把「确实有副作用」的入口列进去,而不是一股脑false)。 - 最后才是改库:如果只有库本身没提供 ESM 产物或没标
sideEffects,再考虑提 issue / PR,或者换一个对 tree shaking 友好的替代品。
减少总量(不引入没用的东西)永远优于重新分配总量(把同样的代码拆得更碎)。
五、剩下的那部分谁来擦
写了 ESM 也不代表一定能摇掉——构建工具若把代码转成 CJS(比如 Babel 配置不当),静态分析直接失效。
sideEffects: false 更不能随便加。误标会删掉真正需要的副作用,样式导入和 polyfill 注册是典型的受害者。
CSS 也不参与 tree shaking,清除未使用规则是另一套机制(PurgeCSS 一类),别指望打包器顺手解决。
摇不摇得掉是可以验证的——看产物,别看配置。
副作用判定的规则写在 Rollup 文档,理解 treeshake 那几个选项后,很多「为什么没摇掉」的问题会自己解开。