一、什么是 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 阶段,大体流程:
- 将模块的所有 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 中直接读取出模块的导出值。
2.2 标记模块导出
模块导出信息收集完毕后,Webpack 需要标记出各个模块的导出列表中,哪些导出值有被其它模块用到,哪些没有,这一过程发生在 Seal 阶段,主流程:
- 触发
compilation.hooks.optimizeDependencies
钩子,开始执行FlagDependencyUsagePlugin
插件逻辑 - 在
FlagDependencyUsagePlugin
插件中,从 entry 开始逐步遍历 ModuleGraph 存储的所有module
对象 - 遍历
module
对象对应的exportInfo
数组 - 为每一个
exportInfo
对象执行compilation.getDependencyReferencedExports
方法,确定其对应的dependency
对象有否被其它模块使用 - 被任意模块使用到的导出值,调用
exportInfo.setUsedConditionally
方法将其标记为已被使用。 exportInfo.setUsedConditionally
内部修改exportInfo._usedInRuntime
属性,记录该导出被如何使用- 结束
上面是极度简化过的版本,中间还存在非常多的分支逻辑与复杂的集合操作,我们抓住重点:标记模块导出这一操作集中在 FlagDependencyUsagePlugin
插件中,执行结果最终会记录在模块导出语句对应的 exportInfo._usedInRuntime
字典中。
2.3 生成代码
经过前面的收集与标记步骤后,Webpack 已经在 ModuleGraph 体系中清楚地记录了每个模块都导出了哪些值,每个导出值又没那块模块所使用。接下来,Webpack 会根据导出值的使用情况生成不同的代码,例如:
重点关注 bar.js
文件,同样是导出值,bar
被 index.js
模块使用因此对应生成了 __webpack_require__.d
调用 "bar": ()=>(/* binding */ bar)
,作为对比 foo
则仅仅保留了定义语句,没有在 chunk 中生成对应的 export。
这一段生成逻辑均由导出语句对应的 HarmonyExportXXXDependency
类实现,大体的流程:
- 打包阶段,调用
HarmonyExportXXXDependency.Template.apply
方法生成代码 - 在
apply
方法内,读取 ModuleGraph 中存储的exportsInfo
信息,判断哪些导出值被使用,哪些未被使用 - 对已经被使用及未被使用的导出值,分别创建对应的
HarmonyExportInitFragment
对象,保存到initFragments
数组 - 遍历
initFragments
数组,生成最终结果
基本上,这一步的逻辑就是用前面收集好的 exportsInfo
对象未模块的导出值分别生成导出语句。
2.4 删除 Dead Code
经过前面几步操作之后,模块导出列表中未被使用的值都不会定义在 __webpack_exports__
对象中,形成一段不可能被执行的 Dead Code 效果,如上例中的 foo
变量:
在此之后,将由 Terser、UglifyJS 等 DCE 工具“摇”掉这部分无效代码,构成完整的 Tree Shaking 操作。
2.5 总结
综上所述,Webpack 中 Tree Shaking 的实现分为如下步骤:
- 在
FlagDependencyExportsPlugin
插件中根据模块的dependencies
列表收集模块导出值,并记录到 ModuleGraph 体系的exportsInfo
中 - 在
FlagDependencyUsagePlugin
插件中收集模块的导出值的使用情况,并记录到exportInfo._usedInRuntime
集合中 - 在
HarmonyExportXXXDependency.Template.apply
方法中根据导出值的使用情况生成不同的导出语句 - 使用 DCE 工具删除 Dead Code,实现完整的树摇效果