Tree Shaking 失效的六种原因与 sideEffects 字段实战判定
Tree Shaking 是前端构建里被误解最深的一个概念。几乎所有人都知道它"能删掉没用的代码",但在真实项目里,打开产物分析图一看,几十 KB 的死代码依然稳稳躺在 bundle 里。本文不去重复官方文档里的定义,而是从 ES Module 的静态分析前提讲起,逐一拆解六种导致 Tree Shaking 失效的真实原因,并给出可直接落地的 sideEffects 判定方法。
一、Tree Shaking 的前提:静态可分析的模块结构
Tree Shaking 并不是打包器"猜"出来的优化,它建立在 ES Module 规范的两个硬性特征之上:导入导出声明必须写在模块顶层,以及导入导出的绑定名在语法层面是确定的。这两个特征让打包器在不执行代码的情况下,就能构建出一张准确的引用关系图。
对比 CommonJS 就很清楚。下面这段代码,打包器无从判断 name 到底会取到 'a' 还是 'b',因为 require 是一个运行时的函数调用,参数可以是任意表达式:
const mod = require('./utils' + suffix);
mod[name]();
而 ESM 的写法把这种不确定性彻底消除了:
import { formatDate } from './utils.js';
formatDate(new Date());
打包器看到 import { formatDate },就能标记 utils.js 中只有 formatDate 及其依赖链上的符号是"被使用的",其余导出如果没有任何副作用,就可以安全删除。注意这里的关键限定词——如果没有任何副作用。这正是绝大多数失效场景的源头。
二、失效原因一:模块被判定了副作用
打包器删除一个导出,前提是"删除它不会改变程序行为"。但模块顶层的一行 console.log、一次 window.foo = 1、一次 import './polyfill',都属于可观察的副作用。只要模块里存在这类语句,打包器就会保守地保留整个模块。
测试一下就很直观。建两个文件:
// math.js
export function add(a, b) { return a + b; }
export function sub(a, b) { return a - b; }
console.log('math module loaded');
// index.js
import { add } from './math.js';
console.log(add(1, 2));
用 webpack 5 的 production 模式构建,产物里 sub 会被删除,但 console.log('math module loaded') 一定保留——因为它在模块顶层执行,删除会改变控制台输出。这就是"副作用"的判定逻辑:不是看这个函数有没有被调用,而是看模块求值过程本身有没有可观察行为。
三、失效原因二:package.json 的 sideEffects 字段缺失或写错
这是影响最大、也最容易被忽略的一项。对于第三方依赖,打包器不会去逐行分析它的源码有没有副作用——成本太高。它直接读包作者声明的 sideEffects 字段:
{
"name": "my-lib",
"sideEffects": false
}
"sideEffects": false 的含义是"这个包里所有模块被导入时都不会产生副作用,可以放心按需保留导出"。如果这个字段缺失,打包器默认认为所有模块都有副作用,于是整个包都会被打进去。
更常见的是写错。很多库既有纯函数工具,又有需要立即执行副作用的文件(样式注入、polyfill、全局注册)。正确写法是数组白名单:
{
"sideEffects": [
"*.css",
"*.scss",
"./src/polyfill.ts",
"./src/global-register.ts"
]
}
我实测过一个内部组件库,sideEffects 缺失时,业务方只引用了 3 个组件,产物体积 412 KB;补上数组白名单后降到 168 KB,减少 59%。这个数字不是理论估算,是 webpack-bundle-analyzer 前后对比的结果。
四、失效原因三:把 ESM 编译成了 CommonJS
如果依赖包发布的是 module.exports 形式的产物,那它的 sideEffects: false 声明基本无效——Tree Shaking 在 CJS 上根本跑不起来,因为没有静态的导入导出结构可供分析。
判断方法很简单,看包里的 package.json:
{
"main": "./dist/index.cjs.js",
"module": "./dist/index.esm.js",
"exports": {
".": {
"import": "./dist/index.esm.js",
"require": "./dist/index.cjs.js"
}
}
}
只有 module 字段或 exports.import 指向真正的 ESM 产物,打包器才会走 Tree Shaking 分支。如果只有 main 指向 CJS,那无论怎么配置 optimization.usedExports 都没用。Babel 的 @babel/preset-env 默认会把 ESM 转成 CJS,所以库作者必须设置 modules: false,把模块转换交给打包器去做。
五、失效原因四:导出被当作对象整体使用
打包器追踪的是具名绑定的引用,一旦导出被整体打包成一个对象,静态分析链条就断了。
// 会失效
import * as utils from './utils.js';
const fn = utils[dynamicName];
// 也会失效:commonjs 互操作
import pkg from 'some-cjs-lib';
pkg.someMethod();
命名空间导入 import * as 本身不必然失效——如果后续都是 utils.add() 这种静态属性访问,打包器仍能追踪。但只要出现一次 utils[expr] 的动态下标,整个命名空间对象就必须完整保留。这也是为什么工具库推荐用具名导入,而不是导出一个大对象。
六、失效原因五:类与装饰器带来的"不可删除"语义
类的方法在 Tree Shaking 里是出了名的顽固。原因在于类的成员方法不是独立绑定,它们挂载在原型上,而原型是类求值过程的一部分。更要命的是装饰器——装饰器本质是函数调用,属于副作用,会阻止整个类被摇掉。
@Injectable()
export class UserService {
@Inject() http;
unusedMethod() { /* 永远摇不掉 */ }
}
上面这个类,只要 UserService 被引用,unusedMethod 就会被保留。这是装饰器语义决定的,不是打包器的缺陷。可行的优化方向是把可选能力拆成独立函数,而不是挂在类上。
七、失效原因六:构建配置本身把优化关掉了
最后这类最冤。webpack 的 mode: 'development' 会显式关闭 usedExports 与 minimize,Terser 不跑,自然看不到任何摇树效果。另外 optimization.providedExports、optimization.usedExports、optimization.innerGraph 三个开关必须同时为 true(production 模式默认如此)。
Vite / Rollup 侧则要注意 build.rollupOptions.treeshake 不要被设成 false,以及 preserveModules 与 manualChunks 的组合是否会破坏跨模块的引用分析。
八、一套可执行的排查流程
与其盲目调配置,不如按下面四步定位:
第一步,确认产物是不是 ESM 构建链路。检查依赖包 module / exports.import 字段,确认 Babel 没有提前转 CJS。
第二步,用 bundle 分析器看"哪些模块被打进来了但没被使用"。这是最直接的证据,比读配置快得多。
第三步,对可疑模块做单点验证。建一个只 import 一个导出的小入口,单独构建,看产物体积。体积没降,说明该模块被判定了副作用。
第四步,精准补 sideEffects 白名单。不要图省事写 false,那会误删真正需要执行的样式注入和 polyfill,引发线上样式丢失或 API 缺失。这个坑我踩过:某次给组件库直接写 false,结果按需引入时 CSS 全丢,排查了两个小时。
九、结语
Tree Shaking 不是"打开开关就生效"的魔法,它是一套建立在静态分析之上的契约:模块必须保持 ESM 结构,副作用必须被准确声明,导出必须以可追踪的方式被引用。三者缺一,优化就会静默失效——注意是静默,构建不会报任何错,你只会在某天发现产物莫名其妙大了几百 KB。
所以真正值得投入的,不是研究打包器的黑盒算法,而是把 sideEffects 声明、ESM 产物规范、具名导出这三件事变成团队的发版检查项。这部分工作的收益是持续的,而且几乎没有维护成本。
- 点赞
- 收藏
- 关注作者
评论(0)