花了三天时间,我终于摸清了 tree-shaking

19 阅读22分钟

背景:

在工程化项目的性能优化上面,尤其是打包体积优化上面,我们一定避不开一个词,那就是 tree-shaking,但是这个词对我来说永远是似懂非懂,项目实际是否生效,也不得而知,这次就好好实践+总结一下并且破解一下tree-shaking的迷思, 体会工程化带来的性能优化之美。

迷思一:sideEffects 到底在哪配?

熟悉webpack/rollup 的朋友可能知道 sideEffects 既可以在 打包脚本里配置,也可以在 package.json里面配置,但是请记住这条 规矩:

永远不在 Webpack/Rollup 配置文件里碰 sideEffects 相关配置,只在 package.json 里配

最终极简结论(直接记这个就够了)

永远不在 Webpack/Rollup 配置文件里碰 sideEffects 相关配置,只在 package.json 里配:

  1. 开发自用项目(不发布):只在根目录 package.jsonsideEffects: ["*.css"];让自己的打包工具(Webpack/Rollup)对本地源码做极致 Tree Shaking
  2. 开发发布库(供他人用):
    • 自己打包时:根目录 package.json 配 sideEffects: ["*.css"](保证自己打包不删样式);同 1
    • 给消费方:在 dist/package.jsonsideEffects: false(或指定有副作用的文件)。让消费方的打包工具(Webpack/Rollup)对你的库产物做极致 Tree Shaking

为什么这个方案最“简单”?(避坑+少配置)

  1. 减少配置维度:不用记 Webpack 的 optimization.sideEffects、Rollup 的 output.sideEffects 这些易混淆的配置,只盯 package.json 一个文件;
  2. 逻辑不割裂sideEffects 本质是「模块副作用声明」,放在 package.json(模块配置文件)里最符合语义,而非打包工具配置文件;
  3. 兼容性拉满:不管是 Webpack/Rollup 打包,还是消费方用 Webpack/Rollup 引入,package.jsonsideEffects 都是通用规则,不会出兼容问题。

补充:为什么不用碰打包工具的 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
}

总结(终极极简版)

  1. 核心规则sideEffects 只配在 package.json,打包工具配置文件零配置;
  2. 场景区分
    • 自用项目 → 根目录 package.json 配 ["*.css"]
    • 发布库 → 根目录配 ["*.css"] + dist/package.json 配 false
  3. 最终效果:既保证样式/全局逻辑不被误删,又能最大化 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: falsemain: "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 的判断逻辑是:

  1. 先标记这个导出为“未使用导出”(如果你的项目没显式导入它);
  2. 但在删除阶段,会因为它是“复杂对象 + sideEffects: true”,判定:

    “这个对象的创建(包括 Object.freeze 调用)可能有隐式副作用,删除风险太高,保留!”

最终结果:即使这个导出完全没被使用,sideEffects: true 时也会被完整保留在打包产物中

3. 补充:如果换成 sideEffects: false 会怎样?

如果配置 sideEffects: false(且无额外保留注释):

  • 若这个导出未被显式导入 → Webpack 会判定“它是纯对象(无副作用)”,直接删除;
  • 若被显式导入 → 正常保留。

这也印证了我们之前的结论:sideEffects: false 是“精准删除”,sideEffects: true 是“盲目保留”。

总结
  1. Object.freeze() 包裹的数组对象对 Webpack 来说属于复杂对象sideEffects: true 时会被保留;
  2. 只有“无函数调用、无嵌套结构”的极简常量(如 export const a = 1),sideEffects: true 时才可能被 Tree Shaking 删除;

sideEffects: true vs sideEffects: false 的 Tree Shaking 能力对比

场景sideEffects: truesideEffects: 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 彻底删除:

  1. 属于“纯导出代码”:仅做变量/对象/函数的导出,无任何“修改外部状态”的行为(即无副作用);
  2. 未被静态分析识别为“实际使用”
    • 既没有被显式导入后使用(如 import { DICT } from 'xxx' 但没读 DICT 的属性/传参);
    • 也没有被标记为“强制保留”(如 /* webpack-export-name: DICT */);
  3. 不是 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 */ 强制保留)。

总结(精准无歧义版)
  1. 删除范围(唯一):仅删「未被使用的纯导出代码」(纯常量/纯函数/纯对象,无副作用、未被静态使用、无保留标记);
  2. 保留范围(全部)
    • 所有有副作用的代码(addEventListener/useEffect/console.log/DOM 操作等);
    • 所有被静态分析识别为“实际使用”的代码(导入后读属性/传参/赋值);
    • 所有 React 组件/钩子内的运行时逻辑;
  3. 你的 Drawer 组件中:useEffect 内的 addEventListenerhandleKeyDown 函数、组件渲染逻辑全保留;只有组件外未被使用的纯导出(如 DICT)会被删。

sideEffects: false 的核心逻辑是:

  1. 当前文件内的代码:先判断是否有副作用 → 有则保留;无则再看是否显式使用 → 未使用则删;
  2. 通过 import 导入的其他文件:先判断是否“显式使用”(如赋值、调用、作为参数)→ 未使用则直接删除整个文件(不会分析文件内部是否有副作用);
  3. 因此,对于「仅导入但未显式使用、且内部有副作用的文件」(如样式文件、全局初始化文件),必须手动在 sideEffects 列表中声明,才能保护它们不被整体删除。

补充关键细节(让你的理解更完整)

你说的“看当前文件的代码是否有副作用”是对的,但要注意:

  • 这里的“当前文件”指的是「被 Webpack 判定为“需要保留”的文件」(比如入口文件、被显式使用的文件);
  • 对于“未被显式使用的导入文件”,Webpack 根本不会进入“分析内部代码副作用”的环节,直接删文件——这是你理解中已经抓住的核心,也是最容易踩坑的点。

一句话落地(你的场景直接用)

对你的 kaci webpos cashier widgets 组件库:

{
  "sideEffects": [
    "**/*.css",    // 保护样式文件(仅导入未使用)
    "**/*.less",   // 保护 Less 文件
    "./src/initGlobal.js" // 保护全局初始化文件(仅导入未使用)
  ]
}

这个配置的效果:

  • 列表内的文件:无论是否显式使用,都保留(内部副作用代码正常执行);
  • 列表外的文件:按“无副作用 + 未显式使用则删”的逻辑精准 Tree Shaking。

总结(关键点回顾)

  1. sideEffects: false 分两层判断:当前文件内的代码“先看副作用,再看使用”;导入的文件“先看使用,不看内部副作用”;
  2. sideEffects: false 的删除逻辑是 “无副作用 + 未显式使用” 双条件,不是 “只看是否显式使用”;
  3. 对于主文件内容:“是否有副作用” > “是否显式使用”—— 有副作用的代码,哪怕未使用也会保留;
  4. 仅导入未显式使用的文件会被整体删除,哪怕内部有副作用;(不会分析文件内部是否有副作用)
  5. 保护这类文件的唯一方式:手动在 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 才是标准方案

总结(精准无歧义)

  1. 核心原因:sideEffects: false 下,Webpack 先判断「文件是否被显式使用」,未使用则直接删文件,不会分析内部代码;
  2. 关键区别:主文件内的副作用代码能被识别保留,独立文件的副作用代码会因文件被删而丢失;
  3. 解决方案:必须在 sideEffects 里声明 "./src/initGlobal.js",让 Webpack 跳过“文件是否使用”的判断,直接保留文件,内部的副作用代码自然就能执行。
  4. 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(最常见)

.babelrcbabel.config.json 中明确设置 modules: false

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "modules": false, // 核心:禁止转译 ES 模块
        "targets": "> 0.25%, not dead" // 你的目标浏览器
      }
    ]
  ]
}
  • 关键:modules: false → 保留 import/export,仅转译 ES6+ 语法(如箭头函数、解构),不改变模块系统;

总结

  1. 消费方使用 Babel 时,必须配置 modules: false(或 7.14+ 的 auto,否则会转译 ES 模块为 CommonJS,导致 Tree Shaking 失效;
  2. 核心目的:保留原生 import/export,让打包工具能静态分析并完成 Tree Shaking;
  3. webpack 5+ 有兜底能力,但显式配置仍是最佳实践。
  4. 关键逻辑:Babel 默认忽略 node_modules,转译范围仅覆盖消费方业务代码;

完整生效的 Tree Shaking的条件

一、第三方库侧(必备)

  1. 输出纯 ES 模块代码
    • 库打包产物必须保留 import/export 静态语法(不能转译为 CommonJS);
    • package.json 中配置 module 字段,指向 ES 模块入口(main 字段可保留用于兼容老项目,优先级低于 module)。
  2. 明确声明副作用规则
    • 最优配置:"sideEffects": false(全库无副作用);
    • 精准配置:若有副作用文件(如全局样式),则配置 ["*.css", "./src/global.js"](仅标记指定文件有副作用);
    • ❌ 不配置 sideEffects 会导致「文件级 Tree Shaking 保守」(仅删模块内未用导出,不删未引用文件)。

二、消费方侧(必备)

  1. 保留 ES 模块导入语法
    • 使用 Babel 时,需配置 @babel/preset-envmodules: false(或 7.14+ 的 auto),避免业务代码的 import 被转译为 CommonJS 的 require
    • 核心:让打包工具能静态分析「消费方导入 + 库导出」的关联关系。
  2. 打包工具开启生产模式优化
    • webpack 需开启 mode: production(默认开启 usedExports: true 标记未使用导出、minimize: true 压缩删除无用代码);
    • 无需额外配置,生产模式的默认优化已足够触发 Tree Shaking。

三、额外避坑(非核心但关键)

  • 库侧:避免动态导出/导入(如 export default { [key]: fn }),静态分析无法识别;
  • 消费方:不手动转译 node_modules 中的第三方库(Babel 默认忽略 node_modules,无需修改);
  • 工具版本:优先使用 webpack 5+/Rollup(对 ES 模块的静态分析更完善)。

简化记忆(你的核心总结)

你说的“第三方库输出 ES 模块入口 + sideEffects: false,消费方以 ES 模块导入,webpack production 模式自动 Tree Shaking”,是99% 场景下的正确结论——仅需补充“消费方 Babel 保留 ES 导入语法”这一个关键细节,就是完整的最佳实践。

最终核心要点回顾

  1. 基础前提:库侧输出 ES 模块 + 消费方保留 ES 导入(静态分析的基础);
  2. 安全前提:库侧声明 sideEffects(让工具敢做激进 Tree Shaking);
  3. 触发条件:消费方 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 模块的静态语法。

总结

  1. Tree Shaking 的核心是静态分析:打包前通过 ES 模块的静态语法,精准识别“导出的内容”和“实际使用的内容”;所以必须保留 ES 语法(import/export),这是 Tree Shaking 的关键;
  2. 静态分析的前提是:全链路保留 import/export 语法(库侧导出、消费方导入都不能转成 CommonJS);
  3. 最终目的:基于静态分析的结果,剔除未被使用的代码,缩小打包体积。

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 模块输出需额外配置

总结

  1. 核心逻辑高度相似:Rollup 和 Webpack 实现“保留 ES 模块”的核心思路完全一致——都是“指定 ES 模块输出格式 + 禁止 Babel 转译 import/export”;
  2. 写法差异仅在工具特有配置:Rollup 用 format: 'es' 更直接,Webpack 需用 libraryTarget: 'module' + 实验特性;
  3. Babel 配置完全通用:两者的 Babel 配置(modules: false)是一模一样的,因为这是 Babel 层面的规则,和打包工具无关。