Tree-shaking 详解

8,956 阅读6分钟

下载 (1).png

一. 什么是 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 对象

      企业微信截图_16369664146252.png

      FlagDependencyExportsPlugin 插件的转换处理流程:

      1. 所有模块都编译完毕后,触发 compilation.hooks.finishModules 钩子,开始执行 FlagDependencyExportsPlugin 插件回调
      2. FlagDependencyExportsPlugin 插件从 entry 开始读取 ModuleGraph 中存储的模块信息,遍历所有 module 对象
      3. 遍历 module 对象的 dependencies 数组,找到所有 HarmonyExportXXXDependency 类型的依赖对象,将其转换为 ExportInfo 对象并记录到 ModuleGraph 体系中

      经过 FlagDependencyExportsPlugin 插件处理后,所有 Es Moudle 风格的 export 语句都会记录在 ModuleGraph 体系内,后续操作就可以从 ModuleGraph 中直接读取出模块的导出值。

    • Seal 阶段,遍历 ModuleGraph 标记模块导出变量有没有被使用

      模块导出信息收集完毕后,Webpack 需要标记出各个模块的导出列表中,哪些导出值有被其它模块用到,哪些没有,这一过程发生在 Seal 阶段,主流程:

      1. 触发 compilation.hooks.optimizeDependencies 钩子,开始执行 FlagDependencyUsagePlugin 插件逻辑
      2. 在 FlagDependencyUsagePlugin 插件中,从 entry 开始逐步遍历 ModuleGraph 存储的所有 module 对象
      3. 遍历 module 对象对应的 exportInfo 数组
      4. 为每一个 exportInfo 对象执行 compilation.getDependencyReferencedExports 方法,确定其对应的 dependency 对象有否被其它模块使用
      5. 被任意模块使用到的导出值,调用 exportInfo.setUsedConditionally 方法将其标记为已被使用。
      6. exportInfo.setUsedConditionally 内部修改 exportInfo._usedInRuntime 属性,记录该导出被如何使用

      上面是极度简化过的版本,中间还存在非常多的分支逻辑与复杂的集合操作,我们抓住重点:标记模块导出这一操作集中在 FlagDependencyUsagePlugin 插件中,执行结果最终会记录在模块导出语句对应的 exportInfo._usedInRuntime 字典中。

    • 生成产物时,若变量没有被其它模块使用则删除对应的导出语句

      1. 打包阶段,调用 HarmonyExportXXXDependency.Template.apply 方法生成代码
      2. 在 apply 方法内,读取 ModuleGraph 中存储的 exportsInfo 信息,判断哪些导出值被使用,哪些未被使用
      3. 对已经被使用及未被使用的导出值,分别创建对应的 HarmonyExportInitFragment 对象,保存到 initFragments 数组
      4. 遍历 initFragments 数组,生成最终结果。

    经过前面几步操作之后,模块导出列表中未被使用的值都不会定义在 __webpack_exports__ 对象中,形成一段不可能被执行的 Dead Code 效果。在此之后,将由 Terser、UglifyJS 等 DCE 工具“摇”掉这部分无效代码,构成完整的 Tree Shaking 操作。

三. tree-shaking 实践

  • development 模式下开启 tree-shaking

企业微信截图_16369636453064.png

由 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 清楚标记的无用代码。

企业微信截图_16369630187057.png

  • production 模式下开启 tree-shaking

企业微信截图_1636963213687.png

在 development 模式下,为了开发和调试方便,我们是不会开启压缩的,而 production 下,会自动为我们开启 tree-shaking。去掉 usedExports 和 uglifyjs-webpack-plugin 相关配置,将 mode 修改为 production。

四. 无效的 tree-shaking

  • UglifyJS不能消除未引用的类,uglify不进行程序流分析,所以不能排除有可能有副作用的代码

    函数的参数若是引用类型,对于它属性的操作,都是有可能会产生副作用的。因为首先它是引用类型,对它属性的任何修改其实都是改变了函数外部的数据。其次获取或修改它的属性,会触发getter或者setter,而gettersetter是不透明的,有可能会产生副作用。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,删除一些强制认为不会产生副作用的代码。