Tree-Shaking
一、什么是 Tree Shaking
Tree-Shaking 是一种基于 ES Module 规范的 Dead Code Elimination 技术,它会在运行过程中静态分析模块之间的导入导出,确定 ESM 模块中哪些导出值未曾被其它模块使用,并将其删除,以此实现打包产物的优化。
通俗的讲,消除无用代码
Webpack 自 2.0 版本开始接入,webpack5自动开启
二、import导入与require导入
Tree-Shaking 基于 ES Module 规范的import静态导入
JavaScript中,模块是一种可重用的代码块,它将一些代码打包成一个单独的单元,并且可以在其他代码中进行导入和使用。在导入模块时,JavaScript中有两种常用的方式:使用import和require。
// 静态导入
import { func1, func2 } from './myModule';
// 动态导入
const myModule = require('./myModule');
import只能导入ES6模块或者使用Babel等工具转化为ES6模块的代码。而require则可以导入CommonJS模块、AMD模块、UMD模块以及Node.js内置模块等多种类型的模块。
import语句是静态执行的,因为在编译阶段就能够确定所导入的模块,从而在运行时快速加载这些模块。require函数是动态执行的,因为在运行时才能够确定所需的模块,需要动态地加载这些模块。
Webpack 中,Tree-shaking 的实现一是先标记出模块导出值中哪些没有被用过,二是使用 Terser 删掉这些没被用到的导出语句。标记过程大致可划分为三个步骤:
- Make 阶段,收集模块导出变量并记录到模块依赖关系图 ModuleGraph 变量中
- Seal 阶段,遍历 ModuleGraph 标记模块导出变量有没有被使用
- 生成产物时,若变量没有被其它模块使用则删除对应的导出语句
标记功能需要配置
optimization.usedExports = true开启
代码讲解optimization.usedExports
// index.js
import {bar} from './bar';
console.log(bar);
// bar.js
export const bar = 'bar';
export const foo = 'foo';
webpack_exports判断标记代码
optimization.usedExports = false
导出了两个变量:bar 与 foo
optimization.usedExports = true
只导出了bar
注意,这个时候 foo 变量对应的代码 const foo='foo' 都还保留完整,这是因为标记功能只会影响到模块的导出语句,真正执行“Shaking”操作的是 Terser 插件。例如在上例中 foo 变量经过标记后,已经变成一段 Dead Code —— 不可能被执行到的代码,这个时候只需要用 Terser 提供的 DCE 功能就可以删除这一段定义语句,以此实现完整的 Tree Shaking 效果。
Webpack 基本介绍
webpack 基本阶段划分
首先直接看官网对 webpack 的整体介绍:webpack 是一套静态的资源模块打包器。
打包器:即以所配置的入口为起点,将资源统一转为 AST 语法树,再分析各模块的依赖关系,最终将经过编译后得到的产物(assets)输出到指定目录中。 所以,核心内容在于打包器的实现过程上(即 webpack 核心过程),总体可划分为三个阶段:「初始化阶段」、「编译构建阶段」、「生成阶段」。
三、源码分析
模块构建流程图
img-blog.csdnimg.cn/direct/8d0a…
模块构建流程图
node_modules/_webpack@5.94.0@webpack/lib/Compiler.js中的compile方法
目的: 找到模块依赖关系图
虽然 compile 方法并没有任何实质的功能逻辑,但它搭建起了后续构建流程框架:
调用 newCompilation 方法创建 compilation 对象; 触发 make 钩子,紧接着 EntryPlugin 在这个钩子中调用 compilation 对象的 addEntry 方法创建入口模块,主流程开始进入「构建阶段」; make 执行完毕后,触发 finishMake 钩子; 执行 compilation.seal 函数,进入「生成阶段」,开始封装 Chunk,生成产物; seal 函数结束后,触发 afterCompile 钩子,开始执行收尾逻辑。 原文链接:blog.csdn.net/Tyro_java/a…
当执行到this.compile就是开始准备编译了,我们来看看compile里面做了什么
-
执行
hooks.beforeCompile -
执行
hooks.compile -
执行
hooks.make -
执行
hooks.finishMake -
执行
hooks.afterCompile其实
hooks.make是最终的编译过程,而在hooks.compile和hooks.make之间执行了const compilation = this.newCompilation(params);,并将compilation传入了hooks.make。Compiler
-
在webpack构建的之初就会创建的一个对象, 并且在webpack的整个生命周期都会存在
(before - run - beforeCompiler - compile - make - finishMake - afterCompiler - done) -
只要是做webpack的编译, 都会先创建一个
Compiler -
如果修改webpack配置需要重新
npm run buildCompilation
- 存在于
compile - make阶段 watch源代码,每次发生改变就需要重新编译模块,创建一个新的Compilation对象
3.1收集模块导出
首先,Webpack 需要弄清楚每个模块分别有什么导出值,这一过程发生在 make 阶段,大体流程:
构建阶段从 entry 开始递归解析资源与资源的依赖,在 compilation 对象内逐步构建出 module 集合以及 module 之间的依赖关系,核心流程:
解释一下,构建阶段从入口文件开始:
-
调用
handleModuleCreate,根据文件类型构建module子类 -
调用 loader-runner 仓库的
runLoaders转译module内容,通常是从各类资源类型转译为 JavaScript 文本 -
调用 acorn 将 JS 文本解析为AST
-
遍历 AST,触发各种钩子
*WebpackOptionsApply.js(new HarmonyModulesPlugin)->HarmonyModulesPlugin.js(new HarmonyExportDependencyParserPlugin)->HarmonyExportDependencyParserPlugin.js(exportSpecifier)->NormalModule.js(addDependency)* -
- 在
HarmonyExportDependencyParserPlugin插件监听exportImportSpecifier钩子,解读 JS 文本对应的资源依赖 - 调用
module对象的addDependency将依赖对象加入到module依赖列表中
- 在
-
AST 遍历完毕后,调用
module.handleParseResult处理模块依赖 -
对于
module新增的依赖,调用handleModuleCreate,控制流回到第一步 -
所有依赖都解析完毕后,构建阶段结束
这个过程中数据流
module => ast => dependences => module,先转 AST 再从 AST 找依赖。这就要求loaders处理完的最后结果必须是可以被 acorn 处理的标准 JavaScript 语法,比如说对于图片,需要从图像二进制转换成类似于export default "data:image/png;base64,xxx"这类 base64 格式或者export default "http://xxx"这类 url 格式。compilation按这个流程递归处理,逐步解析出每个模块的内容以及module依赖关系,后续就可以根据这些内容打包输出。node_modules/_webpack@5.94.0@webpack/lib/FlagDependencyExportsPlugin.js
*WebpackOptionsApply.js->options.optimization.providedExports->FlagDependencyExportsPlugin.js->moduleGraph.getExportsInfo-
将模块的所有 ESM 导出语句转换为 Dependency 对象,并记录到
module对象的dependencies集合,转换规则:
-
具名导出转换为
HarmonyExportSpecifierDependency对象 -
default导出转换为HarmonyExportExpressionDependency对象
- 所有模块都编译完毕后,触发
compilation.hooks.finishModules钩子,开始执行FlagDependencyExportsPlugin插件回调 FlagDependencyExportsPlugin插件从 entry 开始读取 ModuleGraph 中存储的模块信息,遍历所有module对象- 遍历
module对象的dependencies数组,找到所有HarmonyExportXXXDependency类型的依赖对象,将其转换为ExportInfo对象并记录到 ModuleGraph 体系中
经过
FlagDependencyExportsPlugin插件处理后,所有 ESM 风格的 export 语句都会记录在 ModuleGraph 体系内,后续操作就可以从 ModuleGraph 中直接读取出模块的导出值。node_modules/_webpack@5.94.0@webpack/lib/FlagDependencyExportsPlugin.js
3.2 标记模块导出
模块导出信息收集完毕后,Webpack 需要标记出各个模块的导出列表中,哪些导出值有被其它模块用到,哪些没有,这一过程发生在 Seal 阶段,主流程:
getDependencyReferencedExports
setUsedConditionally
exportsInfo
- 触发
compilation.hooks.optimizeDependencies钩子,开始执行FlagDependencyUsagePlugin插件逻辑 - 在
FlagDependencyUsagePlugin插件中,从 entry 开始逐步遍历 ModuleGraph 存储的所有module对象 - 遍历
module对象对应的exportInfo数组 - 为每一个
exportInfo对象执行compilation.getDependencyReferencedExports方法,确定其对应的dependency对象有否被其它模块使用 - 被任意模块使用到的导出值,调用
exportInfo.setUsedConditionally方法将其标记为已被使用。 exportInfo.setUsedConditionally内部修改exportInfo._usedInRuntime属性,记录该导出被如何使用- 结束
上面是极度简化过的版本,中间还存在非常多的分支逻辑与复杂的集合操作,我们抓住重点:标记模块导出这一操作集中在
FlagDependencyUsagePlugin插件中,执行结果最终会记录在模块导出语句对应的exportInfo._usedInRuntime字典中。3.3 生成代码
经过前面的收集与标记步骤后,Webpack 已经在 ModuleGraph 体系中清楚地记录了每个模块都导出了哪些值,每个导出值又没那块模块所使用。接下来,Webpack 会根据导出值的使用情况生成不同的代码。
3.4 删除 Dead Code
经过前面几步操作之后,模块导出列表中未被使用的值都不会定义在
__webpack_exports__对象中,形成一段不可能被执行的 Dead Code 效果,如上例中的foo变量:在此之后,将由 Terser、UglifyJS 等 DCE 工具“摇”掉这部分无效代码,构成完整的 Tree Shaking 操作。
3.5 总结
综上所述,Webpack 中 Tree Shaking 的实现分为如下步骤:
- 在
FlagDependencyExportsPlugin插件中根据模块的dependencies列表收集模块导出值,并记录到 ModuleGraph 体系的exportsInfo中 - 在
FlagDependencyUsagePlugin插件中收集模块的导出值的使用情况,并记录到exportInfo._usedInRuntime集合中 - 在
HarmonyExportXXXDependency.Template.apply方法中根据导出值的使用情况生成不同的导出语句 - 使用 DCE 工具删除 Dead Code,实现完整的树摇效果
四、最佳实践
1.避免无意义的赋值
使用者有意识地优化代码结构,或使用一些补丁技术帮助 Webpack 更精确地检测无效代码,完成 Tree Shaking 操作。
造成这一结果,浅层原因是 Webpack 的 Tree Shaking 逻辑停留在代码静态分析层面,只是浅显地判断:
- 模块导出变量是否被其它模块引用
- 引用模块的主体代码中有没有出现这个变量
没有进一步,从语义上分析模块导出值是不是真的被有效使用。
2.使用
#pure标注纯函数调用与赋值语句类似,JavaScript 中的函数调用语句也可能产生副作用,因此默认情况下 Webpack 并不会对函数调用做 Tree Shaking 操作。不过,开发者可以在调用语句前添加
/*#__PURE__*/备注,明确告诉 Webpack 该次函数调用并不会对上下文环境产生副作用3.禁止 Babel 转译模块导入导出语句
在 Webpack 中使用
babel-loader时,建议将babel-preset-env的moduels配置项设置为false,关闭模块导入导出语句的转译。4. 优化导出值的粒度
webpack_exports
export default { bar: 'bar', foo: 'foo' } 即使实际上只用到
default导出值的其中一个属性,整个default对象依然会被完整保留。所以实际开发中,应该尽量保持导出值颗粒度和原子性,上例代码的优化版本:const bar = 'bar' const foo = 'foo' export { bar, foo } - 存在于
-