共计 6323 个字符,预计需要花费 16 分钟才能阅读完成。
一、什么是 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 中间接读取出模块的导出值。
参考资料:
- [万字总结] 一文吃透 Webpack 外围原理
- 有点难的 webpack 知识点:Dependency Graph 深度解析
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。
对于 Webpack 产物的内容及
__webpack_require__.d
办法的含意,可参考 Webpack 原理系列六:彻底了解 Webpack 运行时 一文。
这一段生成逻辑均由导出语句对应的 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,实现残缺的树摇成果
上述实现原理对背景常识要求较高,倡议读者同步配合以下文档食用:
- [万字总结] 一文吃透 Webpack 外围原理
- 有点难的 webpack 知识点:Dependency Graph 深度解析
- Webpack 原理系列六:彻底了解 Webpack 运行时
三、最佳实际
尽管 Webpack 自 2.x 开始就原生反对 Tree Shaking 性能,但受限于 JS 的动静个性与模块的复杂性,直至最新的 5.0 版本仍然没有解决许多代码副作用带来的问题,使得优化成果并不如 Tree Shaking 本来构想的那么完满,所以须要使用者无意识地优化代码构造,或应用一些补丁技术帮忙 Webpack 更准确地检测有效代码,实现 Tree Shaking 操作。
3.1 防止无意义的赋值
应用 Webpack 时,须要无意识躲避一些不必要的赋值操作,察看上面这段示例代码:
示例中,index.js
模块援用了 bar.js
模块的 foo
并赋值给 f
变量,但后续并没有持续用到 foo
或 f
变量,这种场景下 bar.js
模块导出的 foo
值实际上并没有被应用,理当被删除,但 Webpack 的 Tree Shaking 操作并没有失效,产物中仍然保留 foo
导出:
造成这一后果,浅层起因是 Webpack 的 Tree Shaking 逻辑停留在代码动态剖析层面,只是通俗地判断:
- 模块导出变量是否被其它模块援用
- 援用模块的主体代码中有没有呈现这个变量
没有进一步,从语义上剖析模块导出值是不是真的被无效应用。
更深层次的起因则是 JavaScript 的赋值语句并不 纯,视具体场景有可能产生意料之外的副作用,例如:
import {bar, foo} from "./bar";
let count = 0;
const mock = {}
Object.defineProperty(mock, 'f', {set(v) {
mock._f = v;
count += 1;
}
})
mock.f = foo;
console.log(count);
示例中,对 mock
对象施加的 Object.defineProperty
调用,导致 mock.f = foo
赋值语句对 count
变量产生了副作用,这种场景下即应用简单的动静语义剖析也很难在确保正确副作用的前提下,完满地 Shaking 掉所有无用的代码枝叶。
因而,在应用 Webpack 时开发者须要无意识地躲避这些无意义的反复赋值操作。
3.3 应用 #pure
标注纯函数调用
与赋值语句相似,JavaScript 中的函数调用语句也可能产生副作用,因而默认状况下 Webpack 并不会对函数调用做 Tree Shaking 操作。不过,开发者能够在调用语句前增加 /*#__PURE__*/
备注,明确通知 Webpack 该次函数调用并不会对上下文环境产生副作用,例如:
示例中,foo('be retained')
调用没有带上 /*#__PURE__*/
备注,代码被保留;作为比照,foo('be removed')
带上 Pure 申明后则被 Tree Shaking 删除。
3.3 禁止 Babel 转译模块导入导出语句
Babel 是一个十分风行的 JavaScript 代码转换器,它可能将高版本的 JS 代码等价转译为兼容性更佳的低版本代码,使得前端开发者可能应用最新的语言个性开发出兼容旧版本浏览器的代码。
但 Babel 提供的局部性能个性会以致 Tree Shaking 性能生效,例如 Babel 能够将 import/export
格调的 ESM 语句等价转译为 CommonJS 格调的模块化语句,但该性能却导致 Webpack 无奈对转译后的模块导入导出内容做动态剖析,示例:
示例应用 babel-loader
解决 *.js
文件,并设置 Babel 配置项 modules = 'commonjs'
,将模块化计划从 ESM 转译到 CommonJS,导致转译代码 (右图上一) 没有正确标记出未被应用的导出值 foo
。作为比照,右图 2 为 modules = false
时打包的后果,此时 foo
变量被正确标记为 Dead Code。
所以,在 Webpack 中应用 babel-loader
时,倡议将 babel-preset-env
的 moduels
配置项设置为 false
,敞开模块导入导出语句的转译。
3.4 优化导出值的粒度
Tree Shaking 逻辑作用在 ESM 的 export
语句上,因而对于上面这种导出场景:
export default {
bar: 'bar',
foo: 'foo'
}
即便实际上只用到 default
导出值的其中一个属性,整个 default
对象仍然会被残缺保留。所以理论开发中,应该尽量放弃导出值颗粒度和原子性,上例代码的优化版本:
const bar = 'bar'
const foo = 'foo'
export {
bar,
foo
}
3.5 应用反对 Tree Shaking 的包
如果能够的话,应尽量应用反对 Tree Shaking 的 npm 包,例如:
- 应用
lodash-es
代替lodash
,或者应用babel-plugin-lodash
实现相似成果
不过,并不是所有 npm 包都存在 Tree Shaking 的空间,诸如 React、Vue2 一类的框架本来曾经对生产版本做了足够极致的优化,此时业务代码须要整个代码包提供的残缺性能,基本上不太须要进行 Tree Shaking。