一. 什么是 tree-shaking
前端中的 tree-shaking 可以理解为通过工具"摇"我们的 JS 文件,将其中用不到的代码"摇"掉,是一个性能优化的范畴。具体来说,在 webpack 项目中,有一个入口文件,相当于一棵树的主干,入口文件有很多依赖的模块,相当于树枝。实际情况中,虽然依赖了某个模块,但其实只使用其中的某些功能。通过 tree-shaking,将没有使用的模块 code 摇掉,这样来达到删除无用代码的目的。
二. tree-shaking 的原理 (webpack)
-
common.js 和 es6 中模块引入的区别?
1、
CommonJS
模块输出的是一个值的拷贝,ES6
模块输出的是值的引用。2、
CommonJS
模块是运行时加载,ES6
模块是编译时输出接口。3、
CommonJs
是单个值导出,ES6 Module
可以导出多个4、
CommonJs
是动态语法可以写在判断里,ES6 Module
静态语法只能写在顶层5、
CommonJs
的this
是当前模块,ES6 Module
的this
是undefined
-
Tree shaking 的本质 - 消除无用的JavaScript代码
因为 ES6 Model 的出现,ES6 Model 依赖关系是确定的,
和运行时的状态无关
,可以进行可靠的静态分析。ES6 Module
引入进行静态分析,故而编译的时候正确判断到底加载了那些模块- 静态分析程序流,判断那些模块和变量未被使用或者引用,进而删除对应代码
-
Tree shaking 实现原理
-
Make 阶段,收集模块导出变量并记录到模块依赖关系图 ModuleGraph 变量中。
将模块的所有 Es Moudle 导出语句转换为 Dependency 对象,并记录到
module
对象的dependencies
集合,转换规则:-
具名导出转换为
HarmonyExportSpecifierDependency
对象 -
default
导出转换为HarmonyExportExpressionDependency
对象
FlagDependencyExportsPlugin
插件的转换处理流程:- 所有模块都编译完毕后,触发
compilation.hooks.finishModules
钩子,开始执行FlagDependencyExportsPlugin
插件回调 FlagDependencyExportsPlugin
插件从 entry 开始读取 ModuleGraph 中存储的模块信息,遍历所有module
对象- 遍历
module
对象的dependencies
数组,找到所有HarmonyExportXXXDependency
类型的依赖对象,将其转换为ExportInfo
对象并记录到 ModuleGraph 体系中
经过
FlagDependencyExportsPlugin
插件处理后,所有 Es Moudle 风格的 export 语句都会记录在 ModuleGraph 体系内,后续操作就可以从 ModuleGraph 中直接读取出模块的导出值。 -
-
Seal 阶段,遍历 ModuleGraph 标记模块导出变量有没有被使用
模块导出信息收集完毕后,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
字典中。 - 触发
-
生成产物时,若变量没有被其它模块使用则删除对应的导出语句
- 打包阶段,调用
HarmonyExportXXXDependency.Template.apply
方法生成代码 - 在
apply
方法内,读取 ModuleGraph 中存储的exportsInfo
信息,判断哪些导出值被使用,哪些未被使用 - 对已经被使用及未被使用的导出值,分别创建对应的
HarmonyExportInitFragment
对象,保存到initFragments
数组 - 遍历
initFragments
数组,生成最终结果。
- 打包阶段,调用
经过前面几步操作之后,模块导出列表中未被使用的值都不会定义在
__webpack_exports__
对象中,形成一段不可能被执行的 Dead Code 效果。在此之后,将由 Terser、UglifyJS 等 DCE 工具“摇”掉这部分无效代码,构成完整的 Tree Shaking 操作。 -
三. tree-shaking 实践
- development 模式下开启 tree-shaking
由 optimization.usedExports 收集未使用的导出内容的信息,并将其标记。
由 optimization.sideEffects 告知 webpack 去辨识 package.json 中的 [副作用](<https://github.com/webpack/webpack/blob/master/examples/side-effects/README.md>)
标记或规则,以跳过那些当导出不被使用且被标记不包含副作用的模块。
presets: [["es2015", { modules: false }]] 来设置导出模块为 es6 Moudle。
使用 uglifyjs-webpack-plugin 清楚标记的无用代码。
- production 模式下开启 tree-shaking
在 development 模式下,为了开发和调试方便,我们是不会开启压缩的,而 production 下,会自动为我们开启 tree-shaking。去掉 usedExports 和 uglifyjs-webpack-plugin 相关配置,将 mode 修改为 production。
四. 无效的 tree-shaking
-
UglifyJS不能消除未引用的类,uglify不进行程序流分析,所以不能排除有可能有副作用的代码
函数的参数若是引用类型,对于它属性的操作,都是有可能会产生副作用的。因为首先它是引用类型,对它属性的任何修改其实都是改变了函数外部的数据。其次获取或修改它的属性,会触发
getter
或者setter
,而getter
、setter
是不透明的,有可能会产生副作用。uglify没有完善的程序流分析。它可以简单的判断变量后续是否被引用、修改,但是不能判断一个变量完整的修改过程,不知道它是否已经指向了外部变量,所以很多有可能会产生副作用的代码,都只能保守的不删除。rollup有程序流分析的功能,可以更好的判断代码是否真正会产生副作用。 -
立即执行函数 IIFE
五. 如何避免无效的 tree-shaking
- 尽量不写带有副作用的代码。诸如编写了立即执行函数,在函数里又使用了外部变量等。
- 如果对 ES6 语义特性要求不是特别严格,可以开启 Babel 的
loose
模式,这个要根据自身项目判断,如:是否真的要不可枚举class的属性。 - 如果是开发 JavaScript 库,请使用 rollup。并且提供 ES6 module 的版本,入口文件地址设置到package.json 的
module
字段。 - 如果 JavaScript 库开发中,难以避免的产生各种副作用代码,可以将功能函数或者组件,打包成单独的文件或目录,以便于用户可以通过目录去加载。如有条件,也可为自己的库开发单独的webpack-loader,便于用户按需加载。
- 如果是工程项目开发,对于依赖的组件,只能看组件提供者是否有对应上述3、4点的优化。对于自身的代码,除1、2两点外,对于项目有极致要求的话,可以先进行打包,最终再进行编译。
- 如果对项目非常有把握,可以通过 uglify 的一些编译配置,如:
pure_getters: true
,删除一些强制认为不会产生副作用的代码。