Skip to main content

Tree Shaking 为什么会失效

Tree Shaking 依赖「静态可分析」。只要代码里出现运行期才能确定的结构,它就会保守地全部保留。

一、静态可分析是前提​

Tree shaking 依赖的是静态的模块结构:打包器要在不执行代码的前提下,看出哪个导出被引用、哪个没被引用。任何让这层分析失效的东西都会打断它——模块被转成了 CommonJS、引用路径是运行时拼出来的、动态属性访问取成员、或者模块顶层存在无法判定是否有副作用的代码。所以「为什么没摇掉」的答案,几乎总在这四条里。

更具体地说,它有两个硬前提。第一,必须是 ESM(import / export)。这是静态声明,Rollup、esbuild、Rolldown、以及 webpack 的生产模式下都能在不执行的情况下构建出依赖图。CommonJS 的 require / module.exports 是运行期赋值,无法静态分析,一旦混进来,整包往往就保不住。第二,sideEffects 字段要标对。它告诉打包器「这个包有没有副作用」,是按需裁剪的依据——标错了,要么摇不掉,要么把真正需要的副作用删了。

摇掉的前提是「静态可分析」:import / export 必须是编译期能确定下来的入口 index.jsutils.jsexport a / b / clodash-es按需导出被用到:a未用:b、c进产物被摇掉会失效的几种情况被 Babel 转成 CommonJS动态 import / 运行时取值sideEffects 标注错误
图:摇掉的是「静态分析后确认没被引用的导出」,任何运行时行为都会让分析失效

二、六类让分析失败的写法​

前提:模块必须是静态可分析的有副作用顶层有赋值/调用动态取值obj[key] 访问CJS 转换整个模块被保留无法摇树导出全部保留验证比猜测快:构建后直接搜产物里还有没有那个模块。grep -rl "模块名" dist/还在 → 按上面四类成因逐条排查注意 sideEffects 字段声明错比没声明更危险:它会让本该保留的样式被摇掉,表现为「线上少了一段样式」,比摇不掉更难查。
图:Tree Shaking 失效的四类成因,以及最快的验证方式

最常见的两种。第一种是副作用无法判定:模块顶层执行了语句(注册、打点、修改原型),打包器不敢删,宁可整段保留;解法是用 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() // 无副作用,未被使用可删除

三、用产物反查未消除的模块​

排查要从「定位」开始,而不是上来就改配置。三种有效手段:

  1. 产物分析:用 rollup-plugin-visualizer、webpack-bundle-analyzer 一类工具看 chunk 内容,定位哪块体积异常、是哪个模块被整包拉进来的。
  2. 构建工具的理由输出:很多打包器能打印「为什么这个模块被保留 / 被打包进来」,顺着理由往上找,通常能定位到某个 re-export 或副作用标记。
  3. 临时删依赖对比:把可疑依赖临时去掉,对比产物体积变化,能快速确认它是不是罪魁。

先定位再动手,比盲猜配置高效得多。

确认某个导出到底有没有被摇掉,直接在产物里搜它的名字最快:

# 能搜到就说明没摇掉
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 那几个选项后,很多「为什么没摇掉」的问题会自己解开。