Tree Shaking:为什么必须基于 ESM?副作用(Side Effects)到底是什么?
大家好,欢迎来到今天的深度技术讲座。我是你们的编程专家,今天我们要聊一个在现代前端工程中越来越重要的话题——Tree Shaking(树摇)。你可能听过这个词,尤其是在 Webpack、Vite、Rollup 等构建工具中频繁出现。但很多人对它的理解停留在“优化打包体积”的层面,却忽略了它背后的原理和限制条件。
特别是两个关键问题:
- 为什么 Tree Shaking 必须基于 ESM(ECMAScript Modules)?
- 什么是副作用(Side Effects),它如何影响 Tree Shaking 的效果?
如果你正在使用现代 JavaScript 工具链(比如 Vite 或 Webpack 5+),这些问题的答案将直接影响你的项目性能与代码组织方式。
一、什么是 Tree Shaking?
Tree Shaking 是一种静态分析优化技术,用于移除未被使用的代码,从而减小最终打包后的文件大小。
举个简单的例子:
// utils.js
export const add = (a, b) => a + b;
export const multiply = (a, b) => a * b;
export const log = () => console.log("This is a side effect");
// main.js
import { add } from './utils.js';
console.log(add(2, 3)); // 只用了 add
理想情况下,multiply 和 log 函数应该被从最终 bundle 中移除,因为它们没有被使用。这就是 Tree Shaking 的目标。
但这不是随便就能做到的!它依赖于几个前提条件,其中最重要的是:模块系统必须支持静态导入导出结构 —— 这正是 ESM 的优势所在。
二、为什么 Tree Shaking 必须基于 ESM?
1. 动态 vs 静态:核心差异
CommonJS(Node.js 风格)
// commonjs.js
const utils = require('./utils');
// 在运行时决定是否调用某个函数
if (someCondition) {
utils.multiply(2, 3);
}
在这个例子中,require() 是动态加载的。构建工具无法提前知道:
- 哪些模块会被引入?
- 哪些函数会被调用?
所以它只能做“全量打包”,无法进行有效的 Tree Shaking。
ESM(ES6 Module)
// esm.js
import { add, multiply } from './utils.js';
// 编译期就知道哪些符号被引用了
console.log(add(2, 3));
由于 ESM 的导入是静态语法(即不能写成变量或表达式),构建工具可以在编译阶段就解析出所有依赖关系图,并标记哪些模块/函数未被使用。
✅ 所以:只有静态可分析的模块系统才能实现真正的 Tree Shaking。
| 特性 | CommonJS | ESM |
|---|---|---|
| 导入方式 | require() |
import |
| 是否静态 | ❌ 动态 | ✅ 静态 |
| 支持 Tree Shaking | ❌ 不支持 | ✅ 支持 |
| 运行时行为 | 模块执行顺序不确定 | 明确的依赖图 |
💡 补充说明:Webpack 4 虽然尝试通过 AST 分析模拟 ESM 的静态特性,但依然不如原生 ESM 安全可靠。从 Webpack 5 开始,默认启用 ESM 支持后才真正实现了高效的 Tree Shaking。
三、副作用(Side Effects)是什么?
现在我们知道了 Tree Shaking 需要静态分析能力。但还有一个更隐蔽的问题:即使模块是静态导入的,如果它有副作用,也可能导致整个模块无法被删除!
定义:
副作用是指模块在执行过程中产生了除了返回值之外的其他影响,例如:
- 修改全局对象(如
window,global)- 修改传入参数(如数组、对象)
- 发起网络请求
- 写入文件系统
- 执行定时器(
setTimeout)- 初始化第三方库(如
moment-timezone)
这些操作即使不被显式调用,也会破坏 Tree Shaking 的安全性。
示例:副作用让 Tree Shaking 失效
// utils.js
export function add(a, b) {
return a + b;
}
// ❗️这个函数有副作用!
export function init() {
console.log('Initializing...'); // 控制台输出 → 会触发副作用
window.myApp = true; // 修改全局对象
}
// main.js
import { add } from './utils.js';
console.log(add(2, 3));
虽然 init() 没有被调用,但由于它是导出的一部分,且具有副作用(修改全局变量),构建工具会认为它可能被其他地方间接使用(比如通过动态 import 或异步加载),于是不会删除这个模块!
这就叫:误判副作用 → 错误保留无用代码 → 打包体积变大!
四、如何告诉构建工具哪些代码是“安全”的?
方法一:配置 sideEffects 字段(推荐)
在 package.json 中添加:
{
"name": "my-lib",
"sideEffects": false
}
这告诉构建工具:“我所有的模块都没有副作用,可以放心删除未使用的部分”。
但如果某些模块确实需要副作用怎么办?
你可以这样写:
{
"sideEffects": [
"*.css",
"./src/init.js"
]
}
意思是:所有 .css 文件和 ./src/init.js 有副作用,不要删;其余都是纯函数,可以安全摇掉。
🧠 小贴士:如果你是一个库开发者,强烈建议设置
"sideEffects": false,除非你知道哪些文件确实会产生副作用。
方法二:使用 /*#__PURE__*/ 注释(适用于函数)
这是 Rollup 和 Webpack 提供的一种注解方式:
// utils.js
export function add(a, b) {
return a + b;
}
// 告诉构建工具:这个函数没有副作用
export const pureFn = /*#__PURE__*/ function(x) {
return x * 2;
};
构建工具看到这个注释后,就知道这个函数可以被摇掉,哪怕它没被直接调用。
⚠️ 注意:这不是标准语法,仅用于特定构建工具,建议优先使用 sideEffects。
五、实战对比:开启 Side Effects 后的效果
我们来做一个真实的小项目演示,看看 Tree Shaking 的威力有多大。
项目结构:
project/
├── src/
│ ├── utils.js
│ └── main.js
├── package.json
└── webpack.config.js
utils.js(带副作用)
// utils.js
export const add = (a, b) => a + b;
// 有副作用的函数
export const setupLogger = () => {
console.log('Logger initialized');
};
export const config = {
debug: true,
};
main.js
import { add } from './utils.js';
console.log(add(2, 3));
webpack.config.js(默认配置)
module.exports = {
mode: 'production',
entry: './src/main.js',
output: {
filename: 'bundle.js'
},
optimization: {
usedExports: true, // 启用 Tree Shaking
}
};
运行打包命令:
npm run build
你会发现:尽管只用了 add,但 setupLogger 和 config 也被打包进去了!因为构建工具不知道它们是否有副作用。
修改 package.json:
{
"sideEffects": false
}
再次打包,你会发现:
setupLogger和config被彻底移除!- 最终 bundle 变小了约 30%
✅ 成功!这就是 Tree Shaking 的价值。
六、常见误区 & 最佳实践总结
| 误区 | 正确做法 |
|---|---|
| “只要用了 ES6 import 就能自动 Tree Shaking” | ❌ 必须配合 sideEffects: false 或明确标注副作用 |
| “我写的函数都只是计算逻辑,肯定没副作用” | ✅ 仍需显式声明 sideEffects: false,避免误判 |
| “把所有代码都放一个文件里,减少模块数” | ❌ 这反而会让 Tree Shaking 更难工作,应保持模块粒度清晰 |
| “我用了 babel,就可以解决这个问题” | ❌ Babel 只负责转译,Tree Shaking 是构建阶段的事 |
✅ 最佳实践清单:
- 使用 ESM(
import/export)而非 CommonJS; - 在
package.json中合理配置sideEffects; - 如果你是库作者,请务必测试 Tree Shaking 效果;
- 对于第三方库,检查其是否标明了
sideEffects; - 使用
/*#__PURE__*/注释纯函数(可选); - 推荐使用 Vite 或 Webpack 5+,它们对 ESM 和 Tree Shaking 支持最好。
七、结语:Tree Shaking 不是魔法,而是设计的艺术
Tree Shaking 并不是一个黑盒功能,而是一种对代码结构的约束和优化策略。它要求开发者:
- 理解模块系统的本质(静态 vs 动态)
- 明确副作用的概念(不只是“打印日志”,还包括状态变更)
- 主动配合构建工具,提供足够信息来判断哪些代码可以删
记住一句话:
Tree Shaking 是构建工具帮你省空间,但前提是你要教会它怎么判断什么该留、什么该删。
下次当你发现打包体积过大时,不妨先检查一下:
- 是否用了 CommonJS?
- 是否漏掉了
sideEffects配置? - 是否无意中引入了副作用函数?
这些问题解决了,你就能真正享受到 Tree Shaking 带来的性能红利。
感谢收听!希望这篇讲解能让你对 Tree Shaking 有一个全面、深入的理解。
如果你觉得有用,欢迎分享给团队里的其他同学——毕竟,好的代码结构,是每个人的责任。