一、什么是 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.usedExports为true,启动标记功能 -
启动代码优化功能,可以通过如下方式实现:
- 配置
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 模块导出了 bar 、foo ,但只有 bar 导出值被其它模块使用,经过 Tree Shaking 处理后,foo 变量会被视作无用代码删除。
二、实现原理
Webpack 中,Tree-shaking 的实现一是先标记出模块导出值中哪些没有被用过,二是使用 Terser 删掉这些没被用到的导出语句。标记过程大致可划分为三个步骤:
- Make 阶段,收集模块导出变量并记录到模块依赖关系图 ModuleGraph 变量中
- Seal 阶段,遍历 ModuleGraph 标记模块导出变量有没有被使用
- 生成产物时,若变量没有被其它模块使用则删除对应的导出语句
标记功能需要配置
optimization.usedExports = true开启
也就是说,标记的效果就是删除没有被其它模块使用的导出语句,比如:
示例中,bar.js 模块(左二)导出了两个变量:bar 与 foo,其中 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 核心原理 。
- 将模块的所有 ESM 导出语句转换为 Dependency 对象,并记录到
module对象的dependencies集合,转换规则:
- 具名导出转换为
HarmonyExportSpecifierDependency对象 default导出转换为HarmonyExportExpressionDependency对象
例如对于下面的模块:
export const bar = 'bar';
export const foo = 'foo';
export default 'foo-bar'
对应的dependencies 值为:
- 所有模块都编译完毕后,触发
compilation.hooks.finishModules钩子,开始执行FlagDependencyExportsPlugin插件回调 FlagDependencyExportsPlugin插件从 entry 开始读取 ModuleGraph 中存储的模块信息,遍历所有module对象- 遍历
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。