😲Tree Shaking的实现原理是什么?

Tree-Shaking,就是 Dead code elimination(消除无用代码) 的一种实现,它借助于 ECMAScript 6 的模块机制原理,更多关注的是对无用模块的消除,消除那些引用了但并没有被使用的模块。

ECMAScript 6 module

为了更好地理解 Tree-Shaking 的原理,我们需要先了解 ES6 的模块机制。

我们通过对比 ES Module 与 CommonJS 的区别来理解 ES Module 的模块机制,它们的区别主要体现在模块的输出和执行上,

  • ES Module 输出的是值的引用,而 CommonJS 输出的是值的拷贝
  • ES Module 是编译时执行,而 CommonJS 模块是在运行时加载

所以 ES Module 最大的特点就是静态化,在编译时就能确定模块的依赖关系,以及输入和输出的值,这意味着什么?意味着模块的依赖关系是确定的,和运行时的状态无关,可以进行可靠的静态分析,正是基于这个基础,才使得 Tree-Shaking 成为可能,这也是为什么 rollup 和 Webpack 2 都要用 ES6 Module 语法才能支持 Tree-Shaking。

这种模块机制可以根据一个入口静态地构建出依赖图的数据结构,而不用实际运行代码。也就是可以在代码不运行的状态下,分析出不需要的代码。

Tree Shaking

Tree-Shaking 实现的大体思路:借助 ES6 模块语法的静态结构, 通过编译阶段的静态分析,找到没有引入的模块并打上标记,然后在压缩阶段利用像 uglify-js 这样的压缩工具删除这些没有用到的代码。

  • webpack 可以通过 Tree-Shaking 找到没有引入的模块,并不会删除 Dead code。

  • uglify-js 在压缩的同时去除了 Dead code

在 webpack 中使用 Tree-shaking 的三步

在 Webpack 中,启动 Tree Shaking 功能必须同时满足三个条件:

  • 使用 ESM 规范编写模块代码
  • 配置 optimization.usedExportstrue,启动标记功能
  • 启动代码优化功能,可以通过如下方式实现:
    • 配置 mode = production
    • 配置 optimization.minimize = true
    • 提供 optimization.minimizer 数组
1
2
3
4
5
6
7
8
9
// webpack.config.js
module.exports = {
entry: "./src/index",
mode: "production",
devtool: false,
optimization: {
usedExports: true,
},
};

ESM 下模块之间的依赖关系是高度确定的,与运行状态无关,编译工具只需要对 ESM 模块做静态分析,就可以从代码字面量中推断出哪些模块值未曾被其它模块使用,这是实现 Tree Shaking 技术的必要条件。

示例

对于下述代码:

1
2
3
4
5
6
7
// index.js
import {bar} from './bar';
console.log(bar);

// bar.js
export const bar = 'bar';
export const foo = 'foo';

示例中,bar.js 模块导出了 barfoo ,但只有 bar 导出值被其它模块使用,经过 Tree Shaking 处理后,foo 变量会被视作无用代码删除。

实现原理

Webpack 中,Tree-shaking 的实现一是先标记出模块导出值中哪些没有被用过,二是使用 Terser 删掉这些没被用到的导出语句。标记过程大致可划分为三个步骤:

  • Make 阶段,收集模块导出变量并记录到模块依赖关系图 ModuleGraph 变量中
  • Seal 阶段,遍历 ModuleGraph 标记模块导出变量有没有被使用
  • 生成产物时,若变量没有被其它模块使用则删除对应的导出语句

标记功能需要配置 optimization.usedExports = true 开启

Webpack 中的 usedExports 属性用于指定是否要导出未被使用的模块。当设置为 true 时,Webpack 将会分析每个模块的使用情况,并仅导出被使用的部分。这样做可以减小最终打包文件的大小,提高性能。

也就是说,标记的效果就是删除没有被其它模块使用的导出语句,比如:

image-20240407143826017

示例中,bar.js 模块(左二)导出了两个变量:barfoo,其中 foo 没有被其它模块用到,所以经过标记后,构建产物(右一)中 foo 变量对应的导出语句就被删除了。作为对比,如果没有启动标记功能(optimization.usedExports = false 时),则变量无论有没有被用到都会保留导出语句,如上图右二的产物代码所示。

注意,这个时候 foo 变量对应的代码 const foo='foo' 都还保留完整,这是因为标记功能只会影响到模块的导出语句,真正执行“Shaking”操作的是 Terser 插件。例如在上例中 foo 变量经过标记后,已经变成一段 Dead Code —— 不可能被执行到的代码,这个时候只需要用 Terser 提供的 DCE 功能就可以删除这一段定义语句,以此实现完整的 Tree Shaking 效果。

同样是导出值,barindex.js 模块使用因此对应生成了 __webpack_require__.d 调用 "bar": ()=>(/* binding */ bar),作为对比 foo 则仅仅保留了定义语句,没有在 chunk 中生成对应的 export。

经过前面几步操作之后,模块导出列表中未被使用的值都不会定义在 __webpack_exports__ 对象中,形成一段不可能被执行的 Dead Code 效果,如上例中的 foo 变量, 在此之后,将由 Terser、UglifyJS 等 DCE 工具“摇”掉这部分无效代码,构成完整的 Tree Shaking 操作。

最佳实践

  1. 避免无意义的赋值
  2. 优化导出值的粒度
  3. 使用支持 Tree Shaking 的包

使用 lodash-es 替代 lodash ,或者使用 babel-plugin-lodash 实现类似效果

不过,并不是所有 npm 包都存在 Tree Shaking 的空间,诸如 React、Vue2 一类的框架原本已经对生产版本做了足够极致的优化

参考文章

  1. https://juejin.cn/post/6955383260759195678
  2. https://juejin.cn/post/7002410645316436004#heading-8

😲Tree Shaking的实现原理是什么?
http://example.com/2024/04/07/Tree Shaking实现原理/
作者
weirdo
发布于
2024年4月7日
更新于
2024年4月7日
许可协议