Webpack 原理:Tree-Shaking 实现原理

453 阅读4分钟

一、什么是 Tree Shaking

Tree-Shaking 是一种基于 ES Module 规范的 Dead Code Elimination 技术,它会在运行过程中静态分析模块之间的导入导出,确定 ESM 模块中哪些导出值未曾其它模块使用,并将其删除,以此实现打包产物的优化。

Tree Shaking 较早前由 Rich Harris 在 Rollup 中率先实现,Webpack 自 2.0 版本开始接入,至今已经成为一种应用广泛的性能优化手段。

1.1 在 Webpack 中启动 Tree Shaking

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

  • 使用 ESM 规范编写模块代码

  • 配置 optimization.usedExportstrue,启动标记功能

  • 启动代码优化功能,可以通过如下方式实现:

    • 配置 mode = production
    • 配置 optimization.minimize = true
    • 提供 optimization.minimizer 数组

例如:

// webpack.config.js
module.exports = {
  entry: "./src/index",
  mode: "production",
  devtool: false,
  optimization: {
    usedExports: true,
  },
};

1.2 基础了解

在 CommonJs、AMD、CMD 等旧版本的 JavaScript 模块化方案中,导入导出行为是高度动态,难以预测的,例如:

if(process.env.NODE_ENV === 'development'){
  require('./bar');
  exports.foo = 'foo';
}

而 ESM 方案则从规范层面规避这一行为,它要求所有的导入导出语句只能出现在模块顶层,且导入导出的模块名必须为字符串常量,这意味着下述代码在 ESM 方案下是非法的:

if(process.env.NODE_ENV === 'development'){
  import bar from 'bar';
  export const foo = 'foo';
}

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

1.3 示例

对于下述代码:

// 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 开启

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

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

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

接下来我会展开标记过程的源码,详细讲解 Webpack 5 中 Tree Shaking 的实现过程,对源码不感兴趣的同学可以直接跳到下一章。

2.1 收集模块导出

首先,Webpack 需要弄清楚每个模块分别有什么导出值,这一过程发生在 make 阶段,大体流程:

关于 Make 阶段的更多说明,请参考前文 [万字总结] 一文吃透 Webpack 核心原理

  1. 将模块的所有 ESM 导出语句转换为 Dependency 对象,并记录到 module 对象的 dependencies 集合,转换规则:
  • 具名导出转换为 HarmonyExportSpecifierDependency 对象
  • default 导出转换为 HarmonyExportExpressionDependency 对象

例如对于下面的模块:

export const bar = 'bar';
export const foo = 'foo';

export default 'foo-bar'

对应的dependencies 值为:

  1. 所有模块都编译完毕后,触发 compilation.hooks.finishModules 钩子,开始执行 FlagDependencyExportsPlugin 插件回调
  2. FlagDependencyExportsPlugin 插件从 entry 开始读取 ModuleGraph 中存储的模块信息,遍历所有 module 对象
  3. 遍历 module 对象的 dependencies 数组,找到所有 HarmonyExportXXXDependency 类型的依赖对象,将其转换为 ExportInfo 对象并记录到 ModuleGraph 体系中

经过 FlagDependencyExportsPlugin 插件处理后,所有 ESM 风格的 export 语句都会记录在 ModuleGraph 体系内,后续操作就可以从 ModuleGraph 中直接读取出模块的导出值。

使用支持 Tree Shaking 的包

如果可以的话,应尽量使用支持 Tree Shaking 的 npm 包,例如:

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

不过,并不是所有 npm 包都存在 Tree Shaking 的空间,诸如 React、Vue2 一类的框架原本已经对生产版本做了足够极致的优化,此时业务代码需要整个代码包提供的完整功能,基本上不太需要进行 Tree Shaking。