打包产物里最让人心疼的,往往是那些写了却从没被用到的代码:工具库里导出几十个函数,你只用了两个;组件库提供上百个组件,你只引了一个。它们被一起打进 bundle,白白增加体积。**Tree Shaking(摇树优化)**就是把这些「死代码」摇掉。
一、为什么需要 Tree Shaking
先看一个例子:
// src/utils.js
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export function multiply(a, b) {
return a * b;
}
// src/index.js
import { add } from './utils.js';
console.log(add(1, 2));
这里只用了 add,subtract 和 multiply 从头到尾没被引用。但在没有 Tree Shaking 的情况下,打包产物会把三个函数全部带上——因为打包器默认「导入即引入整个模块」。
问题在于:
- 工具库:
lodash有几百个方法,全量引入动辄几十上百 KB。 - 组件库:只用一两个组件,却把整包代码都带进来。
- 项目自身:历史遗留的未使用函数、常量也会一直留在产物里。
在 CommonJS 时代,这个问题尤其难解。因为 require 是运行时行为,module.exports 可以动态赋值,打包器在构建时无法确定「哪些导出真的被用了」,也就无法安全删除。
二、Tree Shaking 是什么
Tree Shaking 指的是:
基于 ES Module 的静态结构,在构建时分析出哪些导出被真正使用,把未被使用的代码从产物中移除。
它属于**死代码消除(Dead Code Elimination)**的一种,但因为作用在「模块导出的粒度」上,常被单独拿出来讲。
一个常见的误解是:Tree Shaking 是 webpack 一个开关。实际上它是多个环节配合的结果:
- 打包器(webpack / Rollup):基于 ESM 的静态分析,标记哪些导出未被使用(
usedExports),哪些模块可以跳过(sideEffects)。 - 压缩器(Terser):根据标记,真正把无用的代码删除。
- 模块规范(ESM):提供可静态分析的前提。
也就是说,webpack 负责「标记」,Terser 负责「删除」。只开启其中一个,效果都不完整。
三、Tree Shaking 的前提与原理
1. 必须使用 ESM
Tree Shaking 的基础是 ES Module 的静态结构:import / export 只能写在顶层,导入导出关系在代码运行前就能确定。
// 可以被 Tree Shaking(静态)
import { add } from './utils.js';
export const foo = 1;
而 CommonJS 是动态的,无法可靠分析:
// 难以 Tree Shaking(动态)
const name = 'add';
const fn = require('./utils')[name];
module.exports = computeExports();
所以第一条铁律:用 ESM 写代码。
2. 打包器标记 usedExports
webpack 会构建模块依赖图,分析每个模块导出了什么(providedExports)、哪些被别处引用(usedExports),并给未使用的导出打上标记。
3. sideEffects 决定能否整块跳过
usedExports 只能删除「模块里未使用的导出」。但有些模块本身没有导出,或导入它只是为了触发副作用(比如引入 polyfill、引入 CSS)。webpack 需要知道:这个模块被 import 时,是否只是「顺便引入」,能不能在没用到时直接跳过?
这就是 package.json 里 sideEffects 字段的作用:
{
"sideEffects": false
}
表示「本包所有文件都没有副作用,未使用即可安全删除」。如果只有部分文件有副作用,用数组列出:
{
"sideEffects": ["*.css", "./src/polyfill.js"]
}
4. 顶层副作用会阻止摇树
只要模块在顶层有副作用(修改全局变量、调用函数等),打包器就不敢轻易删除:
// 难以摇掉
export function add(a, b) { return a + b; }
window.foo = 'bar'; // 顶层副作用
而纯函数、纯常量则很容易被标记和删除。
四、怎么用
1. 生产模式已自动开启
webpack 在 mode: 'production' 下会默认启用 Tree Shaking 相关选项:
module.exports = {
mode: 'production',
};
它自动打开了 usedExports、sideEffects、minimize 等。开发模式下为了构建速度和调试方便,这些默认是关闭的。
2. 手动配置(开发环境调试时)
module.exports = {
mode: 'development',
optimization: {
usedExports: true,
sideEffects: true,
},
};
注意:开发模式下 minimize 通常为 false,所以「标记」有了,但代码不会真正被删——这是正常的,要看最终效果请用生产构建。
3. 在 package.json 声明 sideEffects
对业务项目:
{
"name": "my-app",
"sideEffects": ["*.css", "*.less", "./src/polyfill.js"]
}
对纯函数工具库:
{
"name": "my-utils",
"sideEffects": false
}
4. 关键坑:别让 Babel 把 ESM 转成 CommonJS
这是 Tree Shaking 失效最常见的原因。@babel/preset-env 的 modules 选项如果被设成 'commonjs',ESM 会先被 Babel 转成 require,webpack 再拿到就已经是 CommonJS 了,Tree Shaking 直接失效。
// 错误:会破坏 Tree Shaking
{
presets: [['@babel/preset-env', { modules: 'commonjs' }]]
}
// 正确:交给 webpack 处理模块
{
presets: [['@babel/preset-env', { modules: false }]]
}
modules: false 让 Babel 保留 ESM 语法,把模块处理交给 webpack。默认值 'auto' 在 webpack 环境下通常也会保持 ESM,但显式写 false 最稳妥。
5. 注意 CSS 等副作用资源
如果 sideEffects: false,而项目里又通过 import './style.css' 引入样式,webpack 可能认为「这个模块没有导出,也没有副作用」,从而把样式整个删掉。
所以有样式的项目一定要把 CSS 列进 sideEffects:
{
"sideEffects": ["*.css", "*.less", "*.scss"]
}
五、如何验证 Tree Shaking 生效
- 看产物:搜索未使用的函数名,确认是否还在 bundle 里。
- webpack 统计信息:
npx webpack --mode production --json > stats.json
从中查看 usedExports、各模块的体积变化。
- webpack-bundle-analyzer:可视化产物体积,直观看到哪些包占用大:
npm install webpack-bundle-analyzer --save-dev
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
plugins: [new BundleAnalyzerPlugin()],
};
- Chrome Coverage:在 DevTools 的 Coverage 面板中查看代码实际执行覆盖率,辅助判断。
六、完整配置示例
const path = require('path');
module.exports = {
mode: 'production',
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'js/[name].[contenthash:8].js',
clean: true,
},
module: {
rules: [
{
test: /\.m?js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
// 保留 ESM,交给 webpack 做 Tree Shaking
presets: [['@babel/preset-env', { modules: false }]],
},
},
},
{
test: /\.css$/i,
use: ['style-loader', 'css-loader'],
},
],
},
optimization: {
// production 下默认已开启,这里显式写出便于理解
usedExports: true,
sideEffects: true,
minimize: true,
// 作用域提升,把模块合并进同一作用域,摇树更彻底
concatenateModules: true,
},
};
对应的 package.json:
{
"sideEffects": ["*.css", "*.less"]
}
七、常见失效原因
| 现象 | 原因 | 解决 |
|---|---|---|
| 未使用导出仍在产物里 | 用了 CommonJS | 改用 ESM |
| 同上 | Babel 转成了 require | 设 modules: false |
| 同上 | 开发模式未压缩 | 用 mode: 'production' |
| 整个模块删不掉 | 顶层有副作用 | 抽离副作用、声明 sideEffects |
| 样式被删掉 | sideEffects: false 误伤 | 把 CSS 列入 sideEffects 数组 |
| 库的代码摇不掉 | 该库未声明 sideEffects | 反馈给库作者,或改用按需引入 |
小结
Tree Shaking 的本质,是「基于 ESM 静态分析的死代码消除」,它的落地依赖三件事:
- 写 ESM:用
import/export,不用require。 - 让 webpack 标记、让 Terser 删除:生产模式下自动完成。
- 如实声明副作用:用
package.json的sideEffects帮打包器判断哪些模块可以安全跳过。
最容易踩的两个坑是:Babel 把 ESM 转成了 CommonJS,以及 sideEffects: false 误删了 CSS。避开它们,Tree Shaking 就能稳定发挥作用,帮你显著瘦身产物体积。