Tree Shaking 为什么必须基于 ESM?副作用(Side Effects)是什么?

Tree Shaking:为什么必须基于 ESM?副作用(Side Effects)到底是什么?

大家好,欢迎来到今天的深度技术讲座。我是你们的编程专家,今天我们要聊一个在现代前端工程中越来越重要的话题——Tree Shaking(树摇)。你可能听过这个词,尤其是在 Webpack、Vite、Rollup 等构建工具中频繁出现。但很多人对它的理解停留在“优化打包体积”的层面,却忽略了它背后的原理和限制条件。

特别是两个关键问题:

  1. 为什么 Tree Shaking 必须基于 ESM(ECMAScript Modules)?
  2. 什么是副作用(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

理想情况下,multiplylog 函数应该被从最终 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,但 setupLoggerconfig 也被打包进去了!因为构建工具不知道它们是否有副作用。

修改 package.json:

{
  "sideEffects": false
}

再次打包,你会发现:

  • setupLoggerconfig 被彻底移除!
  • 最终 bundle 变小了约 30%

✅ 成功!这就是 Tree Shaking 的价值。


六、常见误区 & 最佳实践总结

误区 正确做法
“只要用了 ES6 import 就能自动 Tree Shaking” ❌ 必须配合 sideEffects: false 或明确标注副作用
“我写的函数都只是计算逻辑,肯定没副作用” ✅ 仍需显式声明 sideEffects: false,避免误判
“把所有代码都放一个文件里,减少模块数” ❌ 这反而会让 Tree Shaking 更难工作,应保持模块粒度清晰
“我用了 babel,就可以解决这个问题” ❌ Babel 只负责转译,Tree Shaking 是构建阶段的事

✅ 最佳实践清单:

  1. 使用 ESM(import/export)而非 CommonJS;
  2. package.json 中合理配置 sideEffects
  3. 如果你是库作者,请务必测试 Tree Shaking 效果;
  4. 对于第三方库,检查其是否标明了 sideEffects
  5. 使用 /*#__PURE__*/ 注释纯函数(可选);
  6. 推荐使用 Vite 或 Webpack 5+,它们对 ESM 和 Tree Shaking 支持最好。

七、结语:Tree Shaking 不是魔法,而是设计的艺术

Tree Shaking 并不是一个黑盒功能,而是一种对代码结构的约束和优化策略。它要求开发者:

  • 理解模块系统的本质(静态 vs 动态)
  • 明确副作用的概念(不只是“打印日志”,还包括状态变更)
  • 主动配合构建工具,提供足够信息来判断哪些代码可以删

记住一句话:

Tree Shaking 是构建工具帮你省空间,但前提是你要教会它怎么判断什么该留、什么该删。

下次当你发现打包体积过大时,不妨先检查一下:

  • 是否用了 CommonJS?
  • 是否漏掉了 sideEffects 配置?
  • 是否无意中引入了副作用函数?

这些问题解决了,你就能真正享受到 Tree Shaking 带来的性能红利。

感谢收听!希望这篇讲解能让你对 Tree Shaking 有一个全面、深入的理解。
如果你觉得有用,欢迎分享给团队里的其他同学——毕竟,好的代码结构,是每个人的责任。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注