背景:
在工程化项目的性能优化上面,尤其是打包体积优化上面,我们一定避不开一个词,那就是 tree-shaking,但是这个词对我来说永远是似懂非懂,项目实际是否生效,也不得而知,这次就好好实践+总结一下并且破解一下tree-shaking的迷思, 体会工程化带来的性能优化之美。
迷思一:sideEffects 到底在哪配?
熟悉webpack/rollup 的朋友可能知道 sideEffects 既可以在 打包脚本里配置,也可以在 package.json里面配置,但是请记住这条 规矩:
永远不在 Webpack/Rollup 配置文件里碰 sideEffects 相关配置,只在 package.json 里配
最终极简结论(直接记这个就够了)
永远不在 Webpack/Rollup 配置文件里碰
sideEffects相关配置,只在package.json里配:
- 开发自用项目(不发布):只在根目录 package.json 配
sideEffects: ["*.css"];让自己的打包工具(Webpack/Rollup)对本地源码做极致 Tree Shaking- 开发发布库(供他人用):
- 自己打包时:根目录 package.json 配
sideEffects: ["*.css"](保证自己打包不删样式);同 1- 给消费方:在
dist/package.json配sideEffects: false(或指定有副作用的文件)。让消费方的打包工具(Webpack/Rollup)对你的库产物做极致 Tree Shaking
为什么这个方案最“简单”?(避坑+少配置)
- 减少配置维度:不用记 Webpack 的
optimization.sideEffects、Rollup 的output.sideEffects这些易混淆的配置,只盯package.json一个文件; - 逻辑不割裂:
sideEffects本质是「模块副作用声明」,放在package.json(模块配置文件)里最符合语义,而非打包工具配置文件; - 兼容性拉满:不管是 Webpack/Rollup 打包,还是消费方用 Webpack/Rollup 引入,
package.json的sideEffects都是通用规则,不会出兼容问题。
补充:为什么不用碰打包工具的 sideEffects 配置?
| 工具配置 | 问题点 | 替代方案 |
|---|---|---|
Webpack optimization.sideEffects | 生产模式默认 true,改了反而容易出错 | 不配置,靠根目录 package.json 即可 |
Rollup output.sideEffects | 仅作用于库的产物 package.json,且需插件生成文件 | 直接手动写 dist/package.json 更简单 |
简单说:这些配置要么“默认就对”,要么“手动配更可控”,完全没必要额外加配置,徒增理解成本。
实战清单(直接套用)
场景1:开发 React/Vue 自用项目(不发布)
// 根目录 package.json(仅这一行配置,搞定所有 Tree Shaking)
{
"sideEffects": ["*.css", "*.less", "*.scss"], // 仅样式文件有副作用,其余可删
"scripts": {
"build": "webpack --mode production" // 或 rollup -c
}
}
✅ 效果:Webpack/Rollup 会自动删掉未引用的组件/函数,保留样式,无需额外配置。
场景2:开发 UI 库(发布到 npm)
步骤1:自己打包时(根目录 package.json)
{
"sideEffects": ["*.css"], // 保证自己打包时样式不被删
"scripts": {
"build": "rollup -c" // 用 Rollup 打包更适合库
}
}
步骤2:给消费方看(dist/package.json,手动/插件生成)
{
"name": "your-ui-lib",
"version": "1.0.0",
"main": "bundle.js",
"sideEffects": false // 告诉消费方:我的库产物无副作用,可放心 Tree Shaking
}
总结(终极极简版)
- 核心规则:
sideEffects只配在package.json,打包工具配置文件零配置; - 场景区分:
- 自用项目 → 根目录 package.json 配
["*.css"]; - 发布库 → 根目录配
["*.css"]+ dist/package.json 配false;
- 自用项目 → 根目录 package.json 配
- 最终效果:既保证样式/全局逻辑不被误删,又能最大化 Tree Shaking,打包体积最优。
小tips:package.json 傻傻分不清楚
先给个通俗比喻(瞬间理解)
把你的库比作「一家工厂」:
- 根目录
package.json:工厂内部的「生产规则手册」→ 给工厂工人(你的 Rollup)看的,指导“怎么生产(打包)产品(库)”; - dist/package.json:产品包装盒上的「使用说明书」→ 给消费者(使用你库的开发者)看的,指导“怎么使用产品(引入库)”;
1. 使用者安装你的库时,下载的是「dist/ 目录的产物」,不是你的源码!
当你发布库到 npm 时,会在根目录 package.json 中配置 "main": "dist/bundle.js" 或 "files": ["dist"]:
- npm 发布时,只会把
dist/目录的产物推送到 npm 仓库; - 消费方执行
npm install my-ui-lib时,下载到本地的只有dist/里的文件(bundle.js、bundle.css、package.json),根本看不到你根目录的 package.json; - 所以根目录的
package.json对消费方来说,“不存在”,自然无法被读取。
2. 根目录 package.json 的核心作用(仅对你自己有效)
- 指导你自己的 Rollup/Webpack 打包源码(比如
sideEffects: ["*.css"]是给你的打包工具看的); - 声明库的元信息(名称、版本、依赖),供 npm 发布时使用;
- 定义 npm 脚本(比如
npm run build),方便你自己开发/打包。
3. dist/package.json 的核心作用(仅对消费方有效)
- 消费方的 Webpack/Rollup 只会读取「库的产物目录(node_modules/my-ui-lib/)下的 package.json」;
- 里面的
sideEffects: false、main: "bundle.js"等配置,是消费方打包工具的唯一参考; - 这也是为什么 Rollup 要通过
output.sideEffects+ 插件,把规则写入dist/package.json—— 因为这是消费方唯一能看到的配置文件。
迷思二:sideEffects 需不需要配置
先说结论:
避坑关键:永远不要让 sideEffects 处于「隐式默认值(true)」,显式配置才能最大化 Tree Shaking。
一、先理解 sideEffects 为true的真实作用
sideEffects为true,会禁用tree-shaking吗?
不会!
sideEffects: true 并不会完全禁用 Tree Shaking,只是会极大限制它的能力,且行为非常不稳定。
举个例子:
// 你的库代码
export const B = 2 // 未被显式导入
export const A = 1; // 被显式导入
export const DICT = { key: 'value' }; // 未被显式导入
export const F = () => { console.log('F'); }; // 未被显式导入,含 console(副作用)
// sideEffects: true 时的 Tree Shaking 结果:
// - A 保留(被使用)
// - DICT 可能保留(Terser 不确定是否有隐式副作用)
// - F 一定保留(含 console 副作用)
你看到的 “基础 Tree Shaking”,其实只是 Webpack 删除了像 export const B = 2 这种 “纯到极致” 的未使用常量,而非真正的深度优化。
再举个例子:
// 未被显式导入
export default Object.freeze([ { label: '已提交', value: 1 }, { label: '未提交', value: 0 }, ])
sideEffects: true时,会被摇掉吗?
答案是不会!!-- 本人亲自验证
对 Webpack 而言,这属于“复杂对象”,sideEffects: true 时几乎一定会被保留,不会被 Tree Shaking 删除。
1. 先拆解这个代码的“复杂度”
export default Object.freeze([
{ label: '已提交', value: 1 },
{ label: '未提交', value: 0 },
])
这个导出包含三层“非极简”特征,Webpack/Terser 会把它归为“复杂对象”:
- 第一层:
Array数组(不是简单的字符串/数字常量); - 第二层:数组内的
Object对象({ label: ... }); - 第三层:
Object.freeze()方法调用(属于函数执行,而非纯字面量)。
对比“极简纯常量”(sideEffects: true 时可能被删):
export const STATUS = 1; // 极简纯常量,sideEffects: true 时可能被删
export default 2; // 同理,极简值,可能被删
2. sideEffects: true 下这个复杂对象的处理逻辑
当配置 sideEffects: true 时,Webpack/Terser 的判断逻辑是:
- 先标记这个导出为“未使用导出”(如果你的项目没显式导入它);
- 但在删除阶段,会因为它是“复杂对象 +
sideEffects: true”,判定:“这个对象的创建(包括
Object.freeze调用)可能有隐式副作用,删除风险太高,保留!”
最终结果:即使这个导出完全没被使用,sideEffects: true 时也会被完整保留在打包产物中。
3. 补充:如果换成 sideEffects: false 会怎样?
如果配置 sideEffects: false(且无额外保留注释):
- 若这个导出未被显式导入 → Webpack 会判定“它是纯对象(无副作用)”,直接删除;
- 若被显式导入 → 正常保留。
这也印证了我们之前的结论:sideEffects: false 是“精准删除”,sideEffects: true 是“盲目保留”。
总结
Object.freeze()包裹的数组对象对 Webpack 来说属于复杂对象,sideEffects: true时会被保留;- 只有“无函数调用、无嵌套结构”的极简常量(如
export const a = 1),sideEffects: true时才可能被 Tree Shaking 删除;
sideEffects: true vs sideEffects: false 的 Tree Shaking 能力对比
| 场景 | sideEffects: true | sideEffects: false |
|---|---|---|
| 标记未使用导出 | ✅ 正常执行 | ✅ 正常执行 |
删除纯常量(如 B=2) | ✅ 可能删除(极简代码) | ✅ 必然删除 |
| 删除复杂对象(如 DICT) | ❌ 几乎保留(担心副作用) | ✅ 必然删除(未被使用时) |
| 删除带简单副作用的函数 | ❌ 完全保留(如含 console 的 F 函数) | ❌ 完全保留(本身有副作用) |
| 优化稳定性 | ❌ 不稳定(不同版本/代码写法结果不同) | ✅ 稳定(按“是否使用+是否有副作用”精准删除) |
| 打包体积 | ❌ 体积大(仅删极少代码) | ✅ 体积小(精准删除未使用的纯代码) |
为什么不建议依赖 sideEffects: true 的“基础 Tree Shaking”?
- 行为不可控:同样的代码,Webpack 5.70 和 5.80 版本可能给出不同的 Tree Shaking 结果(比如有时删 DICT,有时保留);
- 优化效果差:仅删除极简代码,无法实现“删除未使用组件/复杂对象”的核心优化目标;
- 违背初衷:
sideEffects: true的设计本意是“告诉 Webpack 不要随便删代码”,而非“保留基础 Tree Shaking”,依赖这个行为会导致后续维护风险(比如升级 Webpack 后代码被误删)。
二、再理解 sideEffects 为 false 的真实作用
sideEffects: false:精准的减法,不会误删主文件内容
一、一定会被删除的代码(唯一范畴)
只有满足所有以下条件的代码,才会被 Webpack 彻底删除:
- 属于“纯导出代码”:仅做变量/对象/函数的导出,无任何“修改外部状态”的行为(即无副作用);
- 未被静态分析识别为“实际使用”:
- 既没有被显式导入后使用(如
import { DICT } from 'xxx'但没读DICT的属性/传参); - 也没有被标记为“强制保留”(如
/* webpack-export-name: DICT */);
- 既没有被显式导入后使用(如
- 不是 React 钩子/运行时执行的逻辑:仅为纯声明式导出,而非
useEffect/addEventListener等运行时逻辑。
典型示例(必删):
// 示例1:纯常量导出,未被使用
export const DICT = Object.freeze([{ label: '已提交', value: 1 }]);
// 示例2:纯函数导出,未被调用
export const formatMoney = (num) => num.toFixed(2);
// 示例3:导入但未使用的纯导出
// 业务代码中
import { DICT } from './widgets'; // 仅导入,无任何使用
console.log('其他逻辑'); // DICT 会被删
// 示例4:重新导出但最终未使用
export { DICT } from './constants'; // 重新导出后无其他文件使用
二、绝对不会被删除的代码(覆盖所有安全场景)
只要代码满足以下任一条件,无论是否配置 sideEffects: false,都不会被删:
1. 有“显式副作用”的代码(运行时修改外部状态)
- DOM 操作:
document.addEventListener/document.body.append/document.title = 'xxx'; - 全局变量修改:
window.XXX = 123/globalThis.version = '0.0.1'; - 控制台/定时器/网络请求:
console.log/setTimeout/fetch('/api'); - 样式注入:
import './style.css'(即使配置sideEffects: false,只要列在sideEffects列表里就保留); - 函数执行:直接调用的函数(如
init()),哪怕函数内是纯逻辑。
2. 被静态分析识别为“实际使用”的代码
- 导入后读取属性:
import { DICT } from 'xxx'; console.log(DICT[0].label); - 导入后作为参数传递:
import { formatMoney } from 'xxx'; render(formatMoney(100)); - 导入后赋值并使用:
import { STATUS } from 'xxx'; const a = STATUS; if (a === 1) {}; - 重新导出后被其他文件使用:
export { DICT } from './constants'(其他文件导入并使用 DICT)。
3. React 组件/钩子内的逻辑
- 组件定义:
const Drawer = (props) => { ... }(即使未被导入,也仅删组件本身,内部钩子逻辑不删); - 钩子内代码:
useEffect/useCallback/useState内的所有逻辑(如useEffect中的addEventListener); - 组件内的运行时逻辑:
const handleKeyDown = () => { ... }(只要被useEffect引用,就保留)。
4. 模块加载时直接执行的代码
// 模块加载时立即执行,必保留
(() => {
console.log('初始化组件库'); // 副作用,保留
})();
// 即使是空执行函数,只要直接运行,也保留
initLib(); // 保留 initLib 函数及调用逻辑
三、边界场景(不会被删,但易误解)
1. “看似纯代码,但在 React 钩子内执行”
const Drawer = (props) => {
// 纯对象声明,但在组件内(运行时渲染执行)
const statusList = Object.freeze([{ label: '已提交', value: 1 }]);
useEffect(() => {
// 纯函数,但在副作用钩子内执行
const format = (num) => num.toFixed(2);
console.log(format(100)); // format 保留
}, []);
return <div>{statusList[0].label}</div>; // statusList 保留
};
export default Drawer;
👉 结论:statusList/format 虽为纯代码,但属于组件运行时逻辑,不会被删。
2. “动态使用的纯导出(未标记保留)”
// 导出代码
export const DICT = { a: 1 };
// 业务代码
import * as mod from './widgets';
const key = 'DICT';
console.log(mod[key]); // 动态取值
👉 结论:Webpack 静态分析无法识别 mod[key] 用到了 DICT,DICT 会被删(需加注释 /* webpack-export-name: DICT */ 强制保留)。
总结(精准无歧义版)
- 删除范围(唯一):仅删「未被使用的纯导出代码」(纯常量/纯函数/纯对象,无副作用、未被静态使用、无保留标记);
- 保留范围(全部):
- 所有有副作用的代码(
addEventListener/useEffect/console.log/DOM 操作等); - 所有被静态分析识别为“实际使用”的代码(导入后读属性/传参/赋值);
- 所有 React 组件/钩子内的运行时逻辑;
- 所有有副作用的代码(
- 你的
Drawer组件中:useEffect内的addEventListener、handleKeyDown函数、组件渲染逻辑全保留;只有组件外未被使用的纯导出(如 DICT)会被删。
sideEffects: false 的核心逻辑是:
- 对当前文件内的代码:先判断是否有副作用 → 有则保留;无则再看是否显式使用 → 未使用则删;
- 对通过
import导入的其他文件:先判断是否“显式使用”(如赋值、调用、作为参数)→ 未使用则直接删除整个文件(不会分析文件内部是否有副作用); - 因此,对于「仅导入但未显式使用、且内部有副作用的文件」(如样式文件、全局初始化文件),必须手动在
sideEffects列表中声明,才能保护它们不被整体删除。
补充关键细节(让你的理解更完整)
你说的“看当前文件的代码是否有副作用”是对的,但要注意:
- 这里的“当前文件”指的是「被 Webpack 判定为“需要保留”的文件」(比如入口文件、被显式使用的文件);
- 对于“未被显式使用的导入文件”,Webpack 根本不会进入“分析内部代码副作用”的环节,直接删文件——这是你理解中已经抓住的核心,也是最容易踩坑的点。
一句话落地(你的场景直接用)
对你的 kaci webpos cashier widgets 组件库:
{
"sideEffects": [
"**/*.css", // 保护样式文件(仅导入未使用)
"**/*.less", // 保护 Less 文件
"./src/initGlobal.js" // 保护全局初始化文件(仅导入未使用)
]
}
这个配置的效果:
- 列表内的文件:无论是否显式使用,都保留(内部副作用代码正常执行);
- 列表外的文件:按“无副作用 + 未显式使用则删”的逻辑精准 Tree Shaking。
总结(关键点回顾)
sideEffects: false分两层判断:当前文件内的代码“先看副作用,再看使用”;导入的文件“先看使用,不看内部副作用”;sideEffects: false的删除逻辑是 “无副作用 + 未显式使用” 双条件,不是 “只看是否显式使用”;- 对于主文件内容:“是否有副作用” > “是否显式使用”—— 有副作用的代码,哪怕未使用也会保留;
- 仅导入未显式使用的文件会被整体删除,哪怕内部有副作用;(不会分析文件内部是否有副作用)
- 保护这类文件的唯一方式:手动在
sideEffects列表中声明。
三、再理解 sideEffects 为 [] 的真实作用
一、最核心的执行顺序(直接解释为什么删)
当你写 import './initGlobal.js' 且配置 sideEffects: false 时,Webpack 的判断流程是「自上而下」的,完全不可逆:
步骤1:检查这个导入的文件是否被“显式使用”
→ 你只是 import 了文件,没有做任何“使用”操作(比如 `const init = require('./initGlobal.js')` 或 `initGlobal()`)
→ 判定:该文件“未被显式使用”
步骤2:基于 `sideEffects: false`,判定“未被显式使用的文件是纯文件,无副作用”
→ 直接标记“删除整个文件”,**跳过对文件内部代码的任何分析**
步骤3:打包阶段删除该文件
→ 哪怕文件里写了 100 行 `window.XXX = 123`,也会被一起删掉——因为文件已经被整体移除,内部代码根本没机会被分析
二、为什么「代码级副作用」和「文件级副作用」不一样?
你混淆了两种不同场景的副作用判断,这是关键认知偏差:
| 场景 | 代码示例 | Webpack 分析时机 | 结果 |
|---|---|---|---|
| 代码级副作用(主文件内) | 在 src/index.jsx 里直接写 window.XXX = 123 | 先分析代码 → 识别副作用 → 保留 | 一定会保留 |
| 文件级副作用(独立文件) | import './initGlobal.js'(文件内写 window.XXX = 123) | 先判断文件是否使用 → 未使用则删文件 → 不分析内部代码 | 会被整体删除 |
简单说:
- 主文件里的
window.XXX = 123:Webpack 能直接看到这行代码,识别副作用后保留; - 独立文件里的
window.XXX = 123:Webpack 先删了整个文件,根本看不到这行代码,自然无法识别副作用。
三、sideEffects: false 为什么“无法预判文件内容”?
Webpack 是「静态分析工具」,但它的分析范围有明确边界:
- 它不会“提前打开
initGlobal.js看里面写了什么”——因为这会极大增加构建耗时(要遍历所有导入文件的内容); - 它只能基于「导入语句的使用方式」做快速判断:只要是“仅导入无使用”的文件,且
sideEffects: false,就默认是“纯文件”,直接删除。
这就是文档里说“Webpack 无法预判文件内容”的本质——它不是“不能识别 window.XXX = 123 的副作用”,而是“没机会看,就已经把文件删了”。
四、只有一种情况能自动保留(但几乎没人这么写)
如果你的导入方式是“显式使用”,哪怕配置 sideEffects: false,文件也会被保留:
// 显式使用导入的文件(强行让 Webpack 认为“文件被使用”)
import initGlobal from './initGlobal.js';
// 哪怕只是空调用,也会触发“文件被使用”的判定
initGlobal;
// initGlobal.js 里需要导出一个空对象/函数(否则导入会报错)
export default {};
window.WIDGETS_VERSION = '0.0.1';
此时 Webpack 会判定“文件被显式使用”,保留文件,内部的 window.XXX = 123 也会保留。但这种写法极其反直觉,没有任何工程化意义——显式声明 sideEffects 才是标准方案。
总结(精准无歧义)
- 核心原因:
sideEffects: false下,Webpack 先判断「文件是否被显式使用」,未使用则直接删文件,不会分析内部代码; - 关键区别:主文件内的副作用代码能被识别保留,独立文件的副作用代码会因文件被删而丢失;
- 解决方案:必须在
sideEffects里声明"./src/initGlobal.js",让 Webpack 跳过“文件是否使用”的判断,直接保留文件,内部的副作用代码自然就能执行。 sideEffects声明文件的核心作用是:告诉 Webpack “这些文件即使看起来‘未被使用’,也因为有文件级副作用而必须保留” 。
四、为什么「显式配置」是关键?(Rollup/Webpack 逻辑一致)
不管是消费方的 Webpack 还是 Rollup,读取 sideEffects 时都遵循「保守原则」:
- 未显式配置:隐式认为
sideEffects: true→ 怕删错有副作用的代码,只会做「浅度 Tree Shaking」(比如只删未引用的导出,不会删整个文件); - 显式配置
sideEffects: false:明确告诉打包工具「这个库/文件无任何副作用」→ 工具会做「深度 Tree Shaking」(删掉所有未引用的代码/文件); - 显式配置
sideEffects: ["*.css"]:精准标记有副作用的文件 → 既保留必要代码,又删掉无副作用的未引用代码(最佳实践)。
会毁掉 tree-shaking的行为
👉 消费方如果使用 Babel(尤其是 @babel/preset-env),必须配置 modules: false(或 modules: auto),否则会破坏库的 ES 模块结构,导致 Tree Shaking 失效。
一、核心原因:Babel 会“毁掉”ES 模块的 Tree Shaking 能力
Tree Shaking 的底层依赖 ES 模块的静态分析特性(import/export 语法是静态的,工具能提前知道哪些导出被使用),而 CommonJS 模块(require/module.exports)是动态的,无法做高效 Tree Shaking。
@babel/preset-env 的默认行为是:将 ES 模块的 import/export 转译为 CommonJS 的 require/module.exports——这会直接让库的 ES 模块产物“退化”成 CommonJS,消费方的 webpack/Rollup 即使识别了 module 字段,也无法做 Tree Shaking。
而配置 modules: false 的作用就是:禁止 Babel 转译 ES 模块语法,保留原生的 import/export,让 Tree Shaking 能正常生效。
二、消费方的 Babel 正确配置(分场景)
场景 1:使用 @babel/preset-env(最常见)
在 .babelrc 或 babel.config.json 中明确设置 modules: false:
{
"presets": [
[
"@babel/preset-env",
{
"modules": false, // 核心:禁止转译 ES 模块
"targets": "> 0.25%, not dead" // 你的目标浏览器
}
]
]
}
- 关键:
modules: false→ 保留import/export,仅转译 ES6+ 语法(如箭头函数、解构),不改变模块系统;
总结
- 消费方使用 Babel 时,必须配置
modules: false(或 7.14+ 的auto),否则会转译 ES 模块为 CommonJS,导致 Tree Shaking 失效; - 核心目的:保留原生
import/export,让打包工具能静态分析并完成 Tree Shaking; - webpack 5+ 有兜底能力,但显式配置仍是最佳实践。
- 关键逻辑:Babel 默认忽略
node_modules,转译范围仅覆盖消费方业务代码;
完整生效的 Tree Shaking的条件
一、第三方库侧(必备)
- 输出纯 ES 模块代码:
- 库打包产物必须保留
import/export静态语法(不能转译为 CommonJS); package.json中配置module字段,指向 ES 模块入口(main字段可保留用于兼容老项目,优先级低于module)。
- 库打包产物必须保留
- 明确声明副作用规则:
- 最优配置:
"sideEffects": false(全库无副作用); - 精准配置:若有副作用文件(如全局样式),则配置
["*.css", "./src/global.js"](仅标记指定文件有副作用); - ❌ 不配置
sideEffects会导致「文件级 Tree Shaking 保守」(仅删模块内未用导出,不删未引用文件)。
- 最优配置:
二、消费方侧(必备)
- 保留 ES 模块导入语法:
- 使用 Babel 时,需配置
@babel/preset-env的modules: false(或 7.14+ 的auto),避免业务代码的import被转译为 CommonJS 的require; - 核心:让打包工具能静态分析「消费方导入 + 库导出」的关联关系。
- 使用 Babel 时,需配置
- 打包工具开启生产模式优化:
- webpack 需开启
mode: production(默认开启usedExports: true标记未使用导出、minimize: true压缩删除无用代码); - 无需额外配置,生产模式的默认优化已足够触发 Tree Shaking。
- webpack 需开启
三、额外避坑(非核心但关键)
- 库侧:避免动态导出/导入(如
export default { [key]: fn }),静态分析无法识别; - 消费方:不手动转译
node_modules中的第三方库(Babel 默认忽略node_modules,无需修改); - 工具版本:优先使用 webpack 5+/Rollup(对 ES 模块的静态分析更完善)。
简化记忆(你的核心总结)
你说的“第三方库输出 ES 模块入口 + sideEffects: false,消费方以 ES 模块导入,webpack production 模式自动 Tree Shaking”,是99% 场景下的正确结论——仅需补充“消费方 Babel 保留 ES 导入语法”这一个关键细节,就是完整的最佳实践。
最终核心要点回顾
- 基础前提:库侧输出 ES 模块 + 消费方保留 ES 导入(静态分析的基础);
- 安全前提:库侧声明
sideEffects(让工具敢做激进 Tree Shaking); - 触发条件:消费方 webpack 生产模式(自动开启标记+删除无用代码)。
Tree Shaking 的本质
第一步:静态扫描(“摸清家底”)
打包工具(webpack/Rollup)在打包前,会静态扫描所有 ES 模块代码(不执行代码,只分析语法结构):
- 对每个模块,标记出「导出列表」:比如
export { a, b, c }→ 记录该模块导出了 a、b、c; - 对整个项目,标记出「导入关系」:比如
import { a } from './lib'→ 记录“当前模块仅使用了 lib 模块的 a”。 ✅ 关键:ES 模块的import/export是静态语法(必须写在顶层、不能动态拼接变量),工具能 100% 精准扫描,不会有歧义。
第二步:依赖关联(“匹配使用关系”)
工具会把「导出列表」和「导入关系」做关联,生成一张“使用图谱”:
- 比如 lib 模块导出 a、b、c,只有 a 被其他模块导入使用 → 标记 b、c 为“未使用导出”;
- 比如 helper.js 模块未被任何地方导入 → 标记该模块为“未引用模块”。 ✅ 关键:静态分析让工具能提前确定“谁被用了、谁没被用”,无需运行代码。
第三步:代码剔除(“摇掉无用代码”)
最后在打包阶段,工具会根据“使用图谱”删除无用代码:
- 模块内未使用的导出(如 b、c)→ 直接删除;
- 未引用的整个模块(如 helper.js)→ 直接忽略,不打包进产物;
- 仅保留被使用的代码(如 a),完成“摇树”。
反例:为什么 CommonJS 无法做高效 Tree Shaking?
CommonJS 的 require/module.exports 是动态语法,静态分析根本无法精准识别依赖和导出:
// CommonJS 代码(动态,工具无法静态分析)
const name = 'a';
// 动态拼接导入路径 → 工具不知道实际导入了哪个文件
const lib = require(`./lib/${name}`);
// 动态导出 → 工具不知道实际导出了什么
module.exports[name] = () => {};
工具既无法提前知道“导入了哪个模块”,也无法确定“导出了哪些变量”,自然没法判断“哪些代码没用”,只能保留全部代码——这就是为什么 Tree Shaking 必须依赖 ES 模块的静态语法。
总结
- Tree Shaking 的核心是静态分析:打包前通过 ES 模块的静态语法,精准识别“导出的内容”和“实际使用的内容”;所以必须保留 ES 语法(
import/export),这是 Tree Shaking 的关键; - 静态分析的前提是:全链路保留
import/export语法(库侧导出、消费方导入都不能转成 CommonJS); - 最终目的:基于静态分析的结果,剔除未被使用的代码,缩小打包体积。
webpack/rollup 配置差异
一、关键配置代码对比(直观看到相似性)
1. 输出 ES 模块的核心配置(库打包配置)
// Rollup 配置(核心:format: 'es')
export default {
output: {
file: 'dist/index.esm.js',
format: 'es' // 直接指定 ES 模块
}
};
// Webpack 配置(核心:libraryTarget + experiments)
module.exports = {
output: {
filename: 'dist/index.esm.js',
libraryTarget: 'module', // 声明输出 ES 模块
module: true
},
experiments: {
outputModule: true // Webpack 5+ 需开启该实验特性
}
};
2. Babel 禁止转译 ES 模块的配置(完全一致)
无论是 Rollup 还是 Webpack,只要用 Babel 做语法降级,@babel/preset-env 的配置完全相同:
// Rollup 中 @rollup/plugin-babel 的配置
babel({
presets: [
['@babel/preset-env', {
modules: false, // 禁止转译 import/export
targets: '> 0.25%, not dead'
}]
]
});
// Webpack 中 babel-loader 的配置
module: {
rules: [{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', {
modules: false, // 同样禁止转译 import/export
targets: '> 0.25%, not dead'
}]
]
}
}
}]
}
二、唯一的核心差异:设计理念(导致配置复杂度不同)
| 特性 | Rollup(库打包) | Webpack(应用打包) |
|---|---|---|
| ES 模块支持 | 原生支持,默认输出 ES 模块,配置极简 | 需开启实验特性(outputModule),配置稍复杂 |
| 配置复杂度 | 轻量,仅需关注“模块格式 + 语法降级” | 重量级,需处理 chunk、splitChunks 等应用相关配置 |
| 兼容性 | 专注库打包,ES 模块输出更纯粹 | 兼顾应用/库打包,ES 模块输出需额外配置 |
总结
- 核心逻辑高度相似:Rollup 和 Webpack 实现“保留 ES 模块”的核心思路完全一致——都是“指定 ES 模块输出格式 + 禁止 Babel 转译 import/export”;
- 写法差异仅在工具特有配置:Rollup 用
format: 'es'更直接,Webpack 需用libraryTarget: 'module'+ 实验特性; - Babel 配置完全通用:两者的 Babel 配置(
modules: false)是一模一样的,因为这是 Babel 层面的规则,和打包工具无关。