webpack 学习系列(二):你可能不知道 tree shaking

简介

相信使用过 webpack 的小伙伴,对 tree shaking 功能都不会陌生。tree shaking, 通常用于移除 javascript 上下文中未使用的代码(dead-code)。tree shaking,可以有效减小最后打包文件的体积。因此在 webpack 打包过程中开启 tree shaking 功能,是一种常用的优化手段。

配置项

要使用 webpack 的 tree shaking 功能,我们需要先做一些配置。

webpack 提供了两种级别的 tree shaking 功能:modules-levelstatements-level。不同级别的 tree shaking,对应的配置项也不相同。

  • modules-level

    modules-level 级别,即 tree shaking 功能作用于整个模块。如果模块被引用但未被使用,那么该模块不会出现在最后的打包代码中。

    示例代码如下:

      // 源文件代码
      // example.1.js
      export default function funA() { console.log('funcA') }
    
      // index.js
      import funcA from './example.1.js';
    
      console.log('index');
    <span class="copy-code-btn">复制代码</span>
    

    打包以后的代码如下,其中 example.1 模块被移除:

    
      // bundle.js
          (self["webpackChunkwebpack_treeshaking"] = self["webpackChunkwebpack_treeshaking"] || []).push([[179],{
          /***/ "./index.js":
          /***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
                    "use strict";
                    __webpack_require__.r(__webpack_exports__);
                    console.log('index');
          /***/ })
              // example.1.js 已经被移除
          },
          0,[["./index.js",303]]]);
    <span class="copy-code-btn">复制代码</span>
    

    使用 modules-leveltree shaking 功能时,我们需要按如下步骤进行配置:

    1. 首先,我们需要设置 optimization.sideEffects 的值为 true

      optimization.sideEffects 的值为 true,意味着 webpack 的 tree shaking 功能被开启。该属性在 production 模式下默认为 true,不需要配置。

    2. 其次,我们需要将 package.json 文件中的 sideEffects 属性设置为 false,或者不做任何处理;

      将 sideEffects 属性设置为 false,意味着 webpack 在使用 tree shaking 功能时会认为是没有副作用的,可以安全的将未使用的模块移除。如果没有 sideEffects 属性或者 sideEffects 的属性值为 true,webpack 会自己分析 tree shaking 有没有副作用,如果没有副作用,将移除未使用的模块。

  • statements-level

    statements-level 级别,即 tree shaking 功能作用于模块内部的语句。如果模块内部定义的 export 没有被引用,或者引用但未被使用,那么该 export 将不会出现在最后的打包代码中。

    示例代码如下:

    // example.2.js
    export const funcB = () => { console.log('funcB') }
    export const funcC = () => { console.log('funcC') }
    
    // index.js
    import { funcB, funcC } from './example.2.js';
    funcC();    
    <span class="copy-code-btn">复制代码</span>
    

    打包以后的代码如下,其中 example.2 模块的 func 被移除:

     // bundle.js
     (self.webpackChunkwebpack_treeshaking=self.webpackChunkwebpack_treeshaking||[]).push([[179],
             {
                 "./index.js":(e,s,c)=>{"use strict";(0,c("./example.2.js").I)()},
                 "./example.2.js":(e,s,c)=>{
                     "use strict";
                     c.d(s,{I:()=>n});
                     const n=()=>{console.log("funcC")}
                     // funcB 已经被移除
                  }
              },0,[["./index.js",303]]]);
    <span class="copy-code-btn">复制代码</span>
    

    使用 statements-leveltree shaking 功能时,我们需要按照如下步骤进行配置:

    1. 首先,我们需要将 optimization.usedExports 的属性值设置为 true(production 模式下,默认为 true);

    2. 其次,我们需要将 optimization.minimize 的属性值设置为 true(production 模式下,默认为 true);

在实际的项目中,我们会将 modules-levelstatements-leveltree shaking 功能同时开启,将未使用的模块及模块内部未使用的 export 全部移除,缩小打包文件的体积。

理论依据

了解完 tree shaking 功能的配置项以后,我们再来了解一下 tree shaking 功能的理论依据。

使用过 webpack 的同学都知道,如果要想 tree shaking 功能有效,我们必须使用 ES6 - import 的方式引用模块。如果使用 common.js - require 的方式引用模块,tree shaking 功能则无效。

那是什么原因导致 ES6 - import 的方式引用模块可以使用 tree shaking 功能,而 common.js - require 的方式引用模块却无法 tree shaking 功能呢?

要解答这个疑问,我们需要先了解两个知识点:js 代码的执行过程 以及 ES6-module 和 commonjs-module的区别

  • js 代码执行过程

    关于 V8 引擎是如何执行一段 js 代码的,网上有已经有大量的讲解,大家可以自行去搜索。在这里,我推荐一篇极客时间李兵老师的文章 - 编译器和解释器:V8是如何执行一段JavaScript代码的

    总的来说,一段 js 代码执行时,要经历如下步骤:

    1. 源代码通过语法分析和词法分析,生成抽象语法树(AST)和执行上下文;

    2. 根据抽象语法树(AST)生成字节码;

    3. 执行生成的字节码;

  • ES6-module 和 commonjs-module 对比

    ES6-modulecommonjs-module 是目前两种通用的 js 模块解决方案。

    ES6-module 的设计思想是尽量的静态化,使得 js 代码在编译阶段,就可以确定模块之间的依赖关系、以及模块的输出。而 commonjs-module 不同,只有在 js 代码真正执行的时候,我们才能知道模块的输出。

    对比上面 js 代码的执行过程,ES6-module 在第一步结束的时候,就可以知道依赖模块的 export,而 commonjs-module 需要在第三步的时候,才能知道依赖模块的 export。

正是基于 js 代码执行之前需要先编译 以及 ES6-module 在 js 代码编译时就可确定模块之间依赖关系和依赖模块输出 的特性,使得 webpack 可以在打包过程中,静态解析源文件的内容,找到模块之间的依赖关系以及模块被使用的 export,然后移除未使用的模块以及模块中未使用的 export,达到 tree shaking 的目的。

实现

知道了 webpack - treeshaking 的工作原理以后,接下来我们要了解的就是 webpack 是如何实现 tree shaking 功能的。

webpack 将我们项目的源文件处理成最后的打包文件,大致需要经历构建模块依赖图将模块依赖图封装为 chunks构建 chunks 对应的内容以及将输出 chunks 的内容到指定位置的流程。而 tree shaking 就发生在封装 chunks构建 chunks 对应的内容的过程中。

为了能更形象的解释 tree shaking,本文会通过一个简单的示例,依次为大家梳理 webpack 的打包过程和 tree shaking。

示例代码如下:

// example.1.js

export default function funcA() {
    console.log('funcA');
}

<span class="copy-code-btn">复制代码</span>
// example.2.js

export const funcB = () => {
    console.log('funcB');
}

export const funcC = () => {
    console.log('funcC');
}

export const funcD = () => {
    console.log('funcD');
}

export const funcE = () => {
    console.log('funcE');
}
<span class="copy-code-btn">复制代码</span>
// example.3.js
import { funcD } from './example.2';
import funcA from './example.1';

export const funcF = () => {
    funcD();
    funcA();
    console.log('funcF');
}

export const funcH = () => {
    console.log('funcH');
}
<span class="copy-code-btn">复制代码</span>
// example.4.js
export const funcG = () => {
    console.log('funcG');
}
<span class="copy-code-btn">复制代码</span>
// main.js
import funcA from './example.1';
import { funcG } from './example.4';
import { funcB } from './example.2';
import(/* webpackChunkName: "example.3" */'./example.3').then((module) => {
    console.log('123');
});
funcB();
<span class="copy-code-btn">复制代码</span>

相应的 webpack 配置如下:

const config = {
    mode: 'production',
    entry: path.resolve(__dirname, '../index'),
    optimization: {
        concatenateModules: false,
        minimize: true,
        runtimeChunk: true,
        usedExports: true,
        moduleIds: 'named',
        sideEffects: true,
    }
};
<span class="copy-code-btn">复制代码</span>

构建模块依赖图

首先,我们先了解一下模块依赖图的构建。

webpack 在编译打包过程中,会根据项目中各个模块之间的依赖关系,递归的构建一个模块依赖图,具体的过程如下:

示例中各个模块,对应的模块依赖图如下:

在图中,我们发现模块之间的依赖关系是通过三种类型的边来确定的:

  • HarmonyImportSideEffectDependency

    HarmonyImportSideEffectDependency 用来表示模块之间的引用关系。示例中,main 模块通过 ES6 - import 的方式引用了 example.1 模块、example.2 模块、example.4 模块,那么 webpack 就会为 example.1、example.2、example.4 创建一个 HarmonyImportSideEffectDependency 类型的 dependency 对象,添加到 main 模块的 dependencies 列表中。

  • HarmImportSpecifierDependency

    HarmonyImportSpecifierDependency 用来表示模块被使用的 export。 示例中, main 模块使用了 example.2 模块提供的 funB, webpack 就会为 example.2 创建一个 HarmImportSpecifierDependency 类型的 dependency 对象,添加到 main 模块的 dependencies 列表中。

  • AsyncDependenciesBolock

    AsyncDependenciesBolock 用来表示需要动态加载的模块。 示例中, main 模块以懒加载的方式引入的 example.3, webpack 会为 example.3 创建一个 AsyncDependenciesBolock 类型的 dependency 对象,添加到 main 模块的 blocks 列表中。

模块依赖图预处理

模块依赖图构建完成以后,接下来就是根据模块依赖图来构建 chunks。

不过在构建 chunks 前,webpack 还需要对模块依赖图进行预处理。

在预处理过程中,webpack 会做如下操作:

  • 确定每个模块的 usedExports

    每个模块的 usedExports 代表着模块被使用的 export。只有确定了每个模块的 usedExports,webpack 才可以将模块未使用的 export 移除。

    在预处理时,webpack 是依据模块依赖图中 HarmImportSpecifierDependency 类型的边来确定每个模块的 usedExports。

    模块依赖图中的每一条 HarmImportSpecifierDependency 边,都对应着依赖模块被使用的 export。示例中,main 模块和 example.4 模块之间没有 HarmImportSpecifierDependency 类型的边,说明 main 模块只引用了 example.4, 但是实际中并未使用 example.4 的默认输出,那么 example.4 模块 usedExports 就是 undefined。 而 main 模块和 example.2 模块之间有指向 funcB 的 HarmImportSpecifierDependency 边、example.3 模块和 example.2 模块之间有指向 funcD 的 HarmImportSpecifierDependency 边,说明 example.2 模块中的 funcB、funcD 又被使用,那么 example.2 的模块的 usedExports 为 funcB 和 funcD。

    处理以后的示例模块依赖图如下:

    确定每个模块的 usedExports,需要 optimization.usedExports 的属性值为 ture。如果 optimization.usedExports 的值为 false,那么每个模块的 usedExports 无法确定,webpack 也无法将未使用的 export 移除。production 模式下, optimization.usedExports 的值为 true。

  • 移除未使用的模块

    确定每个模块的实际输出以后,webpack 接下来会将 export 未被使用的模块从模块依赖图中移除。

    模块可不可以被移除,可以通过模块之间是否同时存在 HarmonyImportSideEffectDependency 和 HarmImportSpecifierDependency 类型的边来确定。如果模块之间只有 HarmonyImportSideEffectDependency 类型的边,那么对应的依赖模块是可以被移除的。

    观察示例的模块依赖图,main 模块 和 example.4 模块之间只有 HarmonyImportSideEffectDependency 类型的边,没有 HarmImportSpecifierDependency 类型的边,说明 example.4 模块只是被 main 模块引用,它的输出并没有被 main 模块使用,那么 examale.4 模块是可以被移除的。而 main 模块和 example.2 模块之间,既有 HarmonyImportSideEffectDependency 类型的边,也有 HarmImportSpecifierDependency 类型的边,说明 example.2 模块的输出有被 main 模块使用,那么 example.2 模块不会被删除移除。

    处理以后的模块依赖图如下:

    移除 export 未被使用的模块,需要 optimization.sideEffects 配置项的值为 true。如果 optimization.sideEffects 的值为 false,那么 export 未被使用的模块不会被移除。production 模式下,optimization.sideEffects 的值默认为 true。

封装 chunks

预处理结束以后,webpack 接下来会将模块依赖图封装成 chunks。webpack 会遍历模块依赖图,找到模块依赖图中 AsyncDependenciesBolock 类型的边,然后将模块依赖图分解为各个 chunks。

观察示例模块依赖图,main 模块和 example.3 模块之间存在 AsyncDependenciesBolock 类型的边,那么 webpack 会根据 AsyncDependenciesBolock 边将模块依赖图拆分为 main 和 example.3 两个 chunk。其中, main chunk 包含 main、example.2 模块, example.3 chunk 包含 example.3、 example.1 模块。

示例对应的 chunks 如下:

构建 chunk 内容

chunks 构建完成以后, webpack 接下来要做的是为每一个 chunk 构建输出内容。webpack 会先为每个 chunk 包含的模块构建内容,然后根据模块的内容,生成 chunk 的内容。构建模块内容时,usedExports 的值(即 optimization.usedExports 配置项的值)会影响最后的结果。

示例中 example.2 模块依据 usedExports 配置项的不同,构建的内容分别如下:

  • optimization.usedExports: false

    此时, example.2 的 usedExports 为 null,构建内容为:

    "./example.2.js":((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
    
        "use strict";
        __webpack_require__.r(__webpack_exports__);
        __webpack_require__.d(__webpack_exports__, {
            "funcB": () => /* binding */ funcB,
            "funcC": () => /* binding */ funcC,
            "funcD": () => /* binding */ funcD,
            "funcE": () => /* binding */ funcE
        });
            const funcB = () => {
                console.log('funcB');
            }
    
            const funcC = () => {
                console.log('funcC');
            }
    
            const funcD = () => {
                console.log('funcD');
            }
    
            const funcE = () => {
                console.log('funcE');
            }
        })
    }
    <span class="copy-code-btn">复制代码</span>
    
  • optimization.useExports: true

    此时, example.2 的 usedExports 为 funcB、funcD,构建内容为:

    "./example.2.js":((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
    
        "use strict";
        __webpack_require__.r(__webpack_exports__);
        __webpack_require__.d(__webpack_exports__, {
            "funcB": () => /* binding */ funcB,
            "funcD": () => /* binding */ funcD
        });
            const funcB = () => {
                console.log('funcB');
            }
    
            const funcC = () => {
                console.log('funcC');
            }
    
            const funcD = () => {
                console.log('funcD');
            }
    
            const funcE = () => {
                console.log('funcE');
            }
        })
    }
    <span class="copy-code-btn">复制代码</span>
    

在上面的构建代码中, webpack_exports 对应 example.2 模块在实际应用中 exports。如果 optimization.usedExports 的值为 false,那么 webpack_exports 包含 example.2 定义的所有 export,如果 optimization.usedExports 的值为 true,那么 webpack_exports 包含 example.2 中定义的且被使用的 export。

chunk 内容构建完成以后,如果我们在配置项中设置了 minimize 属性为 true,webpack 会启用 terser,对构建好的内容进行压缩、混淆处理,并且删除未使用的代码。terser 也会将要处理的内容解析为一个 ast 对象,然后分析 ast 对象, 将模块中未使用的代码移除掉。

production 模式下, minimize 默认为 true

示例中的 example.2 模块,当 optimization.usedExports 的值为 ture 时,webpack_exports 包含 funcB 和 funcD,而 funcA 和 funcE 实际上没有被使用,那么 funcA 和 funcE 就会被 terser 移除掉。 这样, example.2 就完成了 statements - level 的 tree-shaking。

最后,将每个 chunk 的构建内容输出到 output 配置项指定的位置,webpack 的打包就完成了。打包的结果如下:

// main.js
(self.webpackChunkwebpack_treeshaking=self.webpackChunkwebpack_treeshaking||[]).push([[179],
    {
        "./index.js":(e,s,c)=>{
            "use strict";
            var n=c("./src/example.2.js");
            c.e(394).then(c.bind(c,"./src/example.3.js")).then((e=>{console.log("123")})),(0,n.Ii)()
        },
        "./src/example.2.js":(e,s,c)=>{
            "use strict";
            c.d(s,{Ii:()=>n,A_:()=>l});
            const n=()=>{console.log("funcB")},l=()=>{console.log("funcD")}
        }
    },0,[["./index.js",303]]]);
<span class="copy-code-btn">复制代码</span>
// example.3.js
(self.webpackChunkwebpack_treeshaking=self.webpackChunkwebpack_treeshaking||[]).push([[394],{
    "./src/example.1.js":(e,s,c)=>{
        "use strict";
        function n(){console.log("funcA")}
        c.d(s,{Z:()=>n})
    },
    "./src/example.3.js":(e,s,c)=>{
        "use strict";
        c.r(s),
        c.d(s,{funcF:()=>a,funcH:()=>o});
        var n=c("./src/example.2.js"),l=c("./src/example.1.js");
        const a=()=>{(0,n.A_)(),(0,l.Z)(),console.log("funcF")},o=()=>{console.log("funcH")
    }
}}]);
<span class="copy-code-btn">复制代码</span>

写在最后

到这里,相信大家已经对 webpack - treeshaking 的整个实现原理及过程,有了一个比较清晰的了解了吧。

最后,我们再来做一个总结:

  • tree shaking 有两种 level:modules-level 和 statements-level。modules-level 会将为未使用的模块移除, statements-level 会将模块未使用的 export 移除。

  • modules-level 需要设置 optimization.sideEffects 为 true。

  • statements-level 需要配置 optimization.usedExports、optimization.minimize 为 true。

  • 必须使用 ES6 - import 的方式引用模块,否则 tree shaking 不生效;

webpack 学习系列(二):你可能不知道 tree shaking的相似文章

webpack5 代码分离分析webpack 之 LoaderRunner 全方位揭秘分析多图详解,一次性搞懂Webpack Loader分析