Tree Shaking 到底在”摇”什么?

JavaScript1周前发布 admin
157 0

如果你写过几年前端代码,大概率在打包工具的输出日志里见过这个词——Tree Shaking。webpack、Rollup、esbuild、Vite,几乎所有现代构建工具都把它当作标配功能来宣传。但如果认真问一句:**它到底在”摇”什么?**很多人的回答可能只停留在”删掉没用的代码”这个模糊印象上。

这篇文章想把这件事讲透:Tree Shaking 的”树”是什么,”摇”的动作具体是什么,它依赖什么样的前提条件,又为什么经常”摇不干净”。

一、先搞清楚”树”是什么

Tree Shaking 里的”树”,指的不是 DOM 树,而是模块依赖关系构成的树状结构

一个前端项目由很多模块(文件)组成,模块之间通过 import/export 相互引用。如果把入口文件当作树根,它引入的模块是树枝,这些模块又引入其他模块,层层展开,就形成了一棵完整的依赖树。

这棵树上的每一个节点,不是整个文件,而是文件里具体的导出项——某个函数、某个变量、某个类。这一点很关键,也是很多人理解偏差的起点:Tree Shaking 摇的粒度是”导出的绑定”,而不是”文件”。

二、”摇”的动作:找出无人引用的枝叶

理解了”树”之后,”摇”这个动作就很好理解了——想象你抓住树干用力摇晃,那些没有牢固连接、已经枯死的枝叶会掉落,留下的都是真正被依附着、有用的部分。

对应到代码层面,这个过程分为两步:

第一步:静态分析,标记可达性。 构建工具会从入口文件出发,沿着 import 语句遍历整棵依赖树,标记出哪些导出确实被某处代码引用到了。这个过程叫作 Mark,被标记到的代码就是”可达的”(reachable)。

第二步:删除不可达代码。 遍历完成后,所有没有被标记到的导出——也就是定义了但从未被任何地方 import 使用的函数、变量——会在打包阶段被物理删除。这一步叫 Sweep

这套”标记-清除”的思路,其实和很多语言运行时里垃圾回收(GC)的标记清除算法在直觉上是相通的:都是先确定”活着的”是什么,再清理”死掉的”部分。不同的是,GC 是在运行时动态进行的,而 Tree Shaking 是在构建阶段做静态分析,这个区别非常关键,也是下一节要说的重点。

三、为什么必须依赖 ES Module

这是 Tree Shaking 最重要、也最容易被忽略的前提:它高度依赖 ES Module(ESM)的静态结构,而不是 CommonJS(CJS)。

ESM 的 import/export 语句有一个关键特性——它们必须写在模块顶层,不能出现在 iffunction 内部,也不能被条件语句动态改变。这意味着,构建工具在不运行代码的情况下,仅通过静态解析源码,就能百分之百确定模块之间的依赖关系。

// ESM:静态可分析
import { add, subtract } from './math.js';
console.log(add(1, 2));
// subtract 没有被使用,可以被安全删除

而 CommonJS 的 require() 是一个普通的函数调用,理论上可以出现在任何位置,甚至可以传入动态拼接的字符串路径:

// CommonJS:动态、不可静态分析
const moduleName = condition ? './a' : './b';
const mod = require(moduleName);

面对这种写法,构建工具无法在不执行代码的前提下确定最终加载的是哪个模块,更谈不上分析其中哪些导出被用到了。所以业界的共识是:CommonJS 模块基本无法被有效 Tree Shaking,这也是这些年 npm 包生态持续推动”ESM 优先”、许多库同时发布 ESM 和 CJS 两种产物(通过 package.json 里的 exports 字段区分)的重要原因之一。

四、”摇不干净”的常见原因:副作用

即便代码是标准的 ESM 写法,Tree Shaking 依然可能失效,最常见的原因是副作用(side effect)

副作用指的是:一个模块被导入时,即便你没有使用它导出的任何东西,它本身的执行也会对外部环境产生影响。比如下面这个模块:

// polyfill.js
Array.prototype.myMethod = function () { /* ... */ };
export function helper() { /* ... */ }

即使某处代码只是 import { helper } from './polyfill.js' 却从未调用 helper,构建工具也不敢贸然删除整个模块的执行——因为给 Array.prototype 打补丁这个动作本身就是”有用的”,删掉它会改变程序行为。

正因为存在这种不确定性,打包工具在默认情况下会偏保守:只要无法证明一段代码没有副作用,就不会删除它。这也是为什么很多第三方库即使写成了 ESM,实际打包体积依然没有明显缩小的原因。

为了解决这个问题,社区约定了一个标记方式:在 package.json 中声明。

{
  "sideEffects": false
}

这相当于开发者向构建工具做出承诺:”这个包里的所有模块都没有副作用,你可以放心地按需删除。”如果包里确实有个别文件有副作用(比如引入了全局 CSS),也可以用数组形式排除:

{
  "sideEffects": ["./src/polyfill.js", "*.css"]
}

五、Tree Shaking 不是压缩,二者分工不同

还有一个常见的混淆:很多人以为代码体积变小主要靠 Tree Shaking,实际上打包流程里有两个独立的阶段在配合:

  • Tree Shaking:发生在打包/编译阶段,基于模块依赖关系,删除整个”未被引用的导出”
  • 压缩(Minification/DCE):通常由 Terser、esbuild 等工具在最后一步完成,在单个文件内部做更细粒度的死代码消除,比如删除永远不会执行的 if (false) {...} 分支、未使用的局部变量等

两者的处理粒度和依赖的分析方式不同,是打包流程中前后衔接、但相对独立的两个环节,共同作用才能拿到最终体积最优的产物。

六、写在最后

回到最初的问题——Tree Shaking 到底在摇什么?

它摇的是模块依赖树上,那些没有被静态分析证明”可达”的导出代码。这个过程能够成立,建立在三个前提之上:代码使用 ESM 的静态导入导出语法、构建工具能够完整地做可达性分析、以及模块本身不存在无法被证明的副作用。

理解这套机制之后,再回头看日常开发中的一些建议,会更清楚背后的逻辑:优先使用具名导出而非一个大的默认导出对象、按需引入第三方库的子模块、关注所依赖的包是否提供 ESM 产物并正确声明 sideEffects——这些都不是玄学层面的”最佳实践”,而是直接决定 Tree Shaking 能否生效的具体条件。

© 版权声明

相关文章

暂无评论

none
暂无评论...