Tree-Shaking深入了解

134 阅读9分钟

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中有两种常用的方式:使用importrequire

// 静态导入
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

导出了两个变量:barfoo

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里面做了什么

  1. 执行hooks.beforeCompile

  2. 执行hooks.compile

  3. 执行hooks.make

  4. 执行hooks.finishMake

  5. 执行hooks.afterCompile

    其实hooks.make是最终的编译过程,而在hooks.compilehooks.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 build

      Compilation

      • 存在于compile - make阶段
      • watch源代码,每次发生改变就需要重新编译模块,创建一个新的Compilation对象

      3.1收集模块导出

      首先,Webpack 需要弄清楚每个模块分别有什么导出值,这一过程发生在 make 阶段,大体流程:

      构建阶段从 entry 开始递归解析资源与资源的依赖,在 compilation 对象内逐步构建出 module 集合以及 module 之间的依赖关系,核心流程:

      解释一下,构建阶段从入口文件开始:

      1. 调用 handleModuleCreate ,根据文件类型构建 module 子类

      2. 调用 loader-runner 仓库的 runLoaders 转译 module 内容,通常是从各类资源类型转译为 JavaScript 文本

      3. 调用 acorn 将 JS 文本解析为AST

      4. 遍历 AST,触发各种钩子

        *WebpackOptionsApply.js(new HarmonyModulesPlugin)->HarmonyModulesPlugin.js(new HarmonyExportDependencyParserPlugin)->HarmonyExportDependencyParserPlugin.js(exportSpecifier)->NormalModule.js(addDependency)*
        
        1. HarmonyExportDependencyParserPlugin 插件监听 exportImportSpecifier 钩子,解读 JS 文本对应的资源依赖
        2. 调用 module 对象的 addDependency 将依赖对象加入到 module 依赖列表中
      5. AST 遍历完毕后,调用 module.handleParseResult 处理模块依赖

      6. 对于 module 新增的依赖,调用 handleModuleCreate ,控制流回到第一步

      7. 所有依赖都解析完毕后,构建阶段结束

      这个过程中数据流 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
      
      1. 将模块的所有 ESM 导出语句转换为 Dependency 对象,并记录到 module 对象的 dependencies 集合,转换规则:

      • 具名导出转换为 HarmonyExportSpecifierDependency 对象

      • default 导出转换为 HarmonyExportExpressionDependency 对象

      1. 所有模块都编译完毕后,触发 compilation.hooks.finishModules 钩子,开始执行 FlagDependencyExportsPlugin 插件回调
      2. FlagDependencyExportsPlugin 插件从 entry 开始读取 ModuleGraph 中存储的模块信息,遍历所有 module 对象
      3. 遍历 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

      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 属性,记录该导出被如何使用
      7. 结束

      上面是极度简化过的版本,中间还存在非常多的分支逻辑与复杂的集合操作,我们抓住重点:标记模块导出这一操作集中在 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-envmoduels 配置项设置为 false,关闭模块导入导出语句的转译。

      4. 优化导出值的粒度

      webpack_exports

      export default {
          bar: 'bar',
          foo: 'foo'
      }
      ​
      

      即使实际上只用到 default 导出值的其中一个属性,整个 default 对象依然会被完整保留。所以实际开发中,应该尽量保持导出值颗粒度和原子性,上例代码的优化版本:

      const bar = 'bar'
      const foo = 'foo'
      ​
      export {
          bar,
          foo
      }