在进入实际优化分析之前,首先需要进行两项准备工作:
1.准备基于时间的分析工具:我们需要一类插件,来帮助我们统计项目构建过程中在编译阶段的耗时情况,这类工具可以是上一课中我们尝试手写的,也可以是使用第三方的工具。例如 speed-measure-webpack-plugin。
2.准备基于产物内容的分析工具:从产物内容着手分析是另一个可行的方式,因为从中我们可以找到对产物包体积影响最大的包的构成,从而找到那些冗余的、可以被优化的依赖项。通常,减少这些冗余的依赖包模块,不仅能减小最后的包体积大小,也能提升构建模块时的效率。通常可以使用 webpack-bundle-analyzer 分析产物内容。

编译阶段优化

减少执行编译的模块

提升编译模块阶段效率的第一个方向就是减少执行编译的模块。显而易见,如果一个项目每次构建都需要编译 1000 个模块,但是通过分析后发现其中有 500 个不需要编译,显而易见,经过优化后,构建效率可以大幅提升。当然,前提是找到原本不需要进行构建的模块,下面我们就来逐一分析。

IgnorePlugin

有的依赖包,除了项目所需的模块内容外,还会附带一些多余的模块。典型的例子是 moment 这个包,一般情况下在构建时会自动引入其 locale 目录下的多国语言包,但对于大多数情况而言,项目中只需要引入本国语言包即可。而 Webpack 提供的 IgnorePlugin 即可在构建模块时直接剔除那些需要被排除的模块,从而提升构建模块的速度,并减少产物体积除了 moment 包以外,其他一些带有国际化模块的依赖包,例如之前介绍 Mock 工具中提到的 Faker.js 等都可以应用这一优化方式。

按需引入类库模块

第二种典型的减少执行模块的方式是按需引入。这种方式一般适用于工具类库性质的依赖包的优化,典型例子是 lodash 依赖包。通常在项目里我们只用到了少数几个 lodash 的方法,但是构建时却发现构建时引入了整个依赖包,要解决这个问题,效果最佳的方式是在导入声明时只导入依赖包内的特定模块,这样就可以大大减少构建时间,以及产物的体积,除了在导入时声明特定模块之外,还可以使用 babel-plugin-lodash 或 babel-plugin-import 等插件达到同样的效果。
另外,有同学也许会想到 Tree Shaking,这一特性也能减少产物包的体积,但是这里有两点需要注意:
Tree Shaking 需要相应导入的依赖包使用 ES6 模块化,而 lodash 还是基于 CommonJS ,需要替换为 lodash-es 才能生效。
相应的操作是在优化阶段进行的,换句话说,Tree Shaking 并不能减少模块编译阶段的构建时间。

DllPlugin

DllPlugin 是另一类减少构建模块的方式,它的核心思想是将项目依赖的框架等模块单独构建打包,与普通构建流程区分开。例如,原先一个依赖 React 与 react-dom 的文件,在构建时
而在通过 DllPlugin 和 DllReferencePlugin 分别配置后的构建时间就变成如下图所示,由于构建时减少了最耗时的模块,构建效率瞬间提升十倍。

Externals

Webpack 配置中的 externals 和 DllPlugin 解决的是同一类问题:将依赖的框架等模块从构建过程中移除。它们的区别在于:
在 Webpack 的配置方面,externals 更简单,而 DllPlugin 需要独立的配置文件。
DllPlugin 包含了依赖包的独立构建流程,而 externals 配置中不包含依赖框架的生成方式,通常使用已传入 CDN 的依赖包。
externals 配置的依赖包需要单独指定依赖模块的加载方式:全局对象、CommonJS、AMD 等。
在引用依赖包的子模块时,DllPlugin 无须更改,而 externals 则会将子模块打入项目包中。
externals 的示例如下面两张图,可以看到经过 externals 配置后,构建速度有了很大提升

提升单个模块构建的速度

提升编译阶段效率的第二个方向,是在保持构建模块数量不变的情况下,提升单个模块构建的速度。具体来说,是通过减少构建单个模块时的一些处理逻辑来提升速度。这个方向的优化主要有以下几种:

include/exclude

Webpack 加载器配置中的 include/exclude,是常用的优化特定模块构建速度的方式之一。
include 的用途是只对符合条件的模块使用指定 Loader 进行转换处理。而 exclude 则相反,不对特定条件的模块使用该 Loader(例如不使用 babel-loader 处理 node_modules 中的模块)。如下面两张图片所示。
这里有两点需要注意:
从上面的第二张图中可以看到,jquery 和 lodash 的编译过程仍然花费了数百毫秒,说明通过 include/exclude 排除的模块,并非不进行编译,而是使用 Webpack 默认的 js 模块编译器进行编译(例如推断依赖包的模块类型,加上装饰代码等)。
在一个 loader 中的 include 与 exclude 配置存在冲突的情况下,优先使用 exclude 的配置,而忽略冲突的 include 部分的配置,具体可以参照示例代码中的 webpack.inexclude.config.js。

noParse

Webpack 配置中的 module.noParse 则是在上述 include/exclude 的基础上,进一步省略了使用默认 js 模块编译器进行编译的时间,如下面两张图片所示。

Source Map

Source Map 对于构建时间的影响在第三课中已经展开讨论过,这里再稍做总结:对于生产环境的代码构建而言,会根据项目实际情况判断是否开启 Source Map。在开启 Source Map 的情况下,优先选择与源文件分离的类型,例如 “source-map”。有条件也可以配合错误监控系统,将 Source Map 的构建和使用在线下监控后台中进行,以提升普通构建部署流程的速度。

TypeScript 编译优化

Webpack 中编译 TS 有两种方式:使用 ts-loader 或使用 babel-loader。其中,在使用 ts-loader 时,由于 ts-loader 默认在编译前进行类型检查,因此编译时间往往比较慢,如下面的图片所示。
通过加上配置项 transpileOnly: true,可以在编译时忽略类型检查,从而大大提升 TS 模块的编译速度,如下面的图片所示。
而 babel-loader 则需要单独安装 @babel/preset-typescript 来支持编译 TS(Babel 7 之前的版本则还是需要使用 ts-loader)。babel-loader 的编译效率与上述 ts-loader 优化后的效率相当,如下面的图片所示。
不过单独使用这一功能就丧失了 TS 中重要的类型检查功能,因此在许多脚手架中往往配合 ForkTsCheckerWebpackPlugin 一同使用。

Resolve
Webpack 中的 resolve 配置制定的是在构建时指定查找模块文件的规则,例如:

  • resolve.modules:指定查找模块的目录范围。
  • resolve.extensions:指定查找模块的文件类型范围。
  • resolve.mainFields:指定查找模块的 package.json 中主文件的属性名。
  • resolve.symlinks:指定在查找模块时是否处理软连接。

这些规则在处理每个模块时都会有所应用,因此尽管对小型项目的构建速度来说影响不大,但对于大型的模块众多的项目而言,这些配置的变化就可能产生客观的构建时长区别。例如下面的示例就展示了使用默认配置和增加了大量无效范围后,构建时长的变化情况:

并行构建以提升总体效率

第三个编译阶段提效的方向是使用并行的方式来提升构建的效率。并行构建的方案早在 Webpack 2 时代已经出现,随着目前最新稳定版本 Webpack 4 的发布,人们发现在一般项目的开发阶段和小型项目的各构建流程中已经用不到这种并发的思路了,因为在这些情况下,并发所需要的多进程管理与通信所带来的额外时间成本可能会超过使用工具带来的收益。但是在大中型项目的生产环境构建时,这类工具仍有发挥作用的空间。这里我们介绍两类并行构建的工具: HappyPack 与 thread-loader,以及 parallel-webpack。

HappyPack 与 thread-loader

这两种工具的本质作用相同,都作用于模块编译的 Loader 上,用于在特定 Loader 的编译过程中,以开启多进程的方式加速编译。HappyPack 诞生较早,而 thread-loader 参照它的效果实现了更符合 Webpack 中 Loader 的编写方式。下面就以 thread-loader 为例,来看下应用前后的构建时长对比,如下面的两张图所示。

parallel-webpack

并发构建的第二种场景是针对与多配置构建。Webpack 的配置文件可以是一个包含多个子配置对象的数组,在执行这类多配置构建时,默认串行执行,而通过 parallel-webpack,就能实现相关配置的并行处理。从下图的示例中可以看到,通过不同配置的并行构建,构建时长缩短了 30%:

总结
这节课我们整理了 Webpack 构建中编译模块阶段的构建效率优化方案。对于这一阶段的构建效率优化可以分为三个方向:以减少执行构建的模块数量为目的的方向、以提升单个模块构建速度为目的的方向,以及通过并行构建以提升整体构建效率的方向。每个方向都包含了若干解决工具和配置。

打包阶段的优化

  1. 针对某些任务,使用效率更高的工具或配置项,从而提升当前任务的工作效率。
  2. 提升特定任务的优化效果,以减少传递给下一任务的数据量,从而提升后续环节的工作效率。

    以提升当前任务工作效率为目标的方案

    一般在项目的优化阶段,主要耗时的任务有两个:一个是生成 ChunkAssets,即根据 Chunk 信息生成 Chunk 的产物代码;另一个是优化 Assets,即压缩 Chunk 产物代码。
    第一个任务主要在 Webpack 引擎内部的模块中处理,相对而言优化手段较少,主要集中在利用缓存方面,具体将在下节课中讨论。而在压缩 Chunk 产物代码的过程中会用到一些第三方插件,选择不同的插件,以及插件中的不同配置都可能会对其中的效率产生影响。

    面向 JS 的压缩工具

    Webpack 4 中内置了 TerserWebpackPlugin 作为默认的 JS 压缩工具,之前的版本则需要在项目配置中单独引入,早期主要使用的是 UglifyJSWebpackPlugin。这两个 Webpack 插件内部的压缩功能分别基于 Terser 和 UglifyJS。
    从第三方的测试结果看,两者在压缩效率与质量方面差别不大,但 Terser 整体上略胜一筹。
    从本节课示例代码的运行结果(npm run build:jscomp)来看,如下面的表格所示,在不带任何优化配置的情况下,3 个测试文件的构建结果都是 Terser 效果更好。

    Terser 和 UglifyJS 插件中的效率优化

    Terser 原本是 Fork 自 uglify-es 的项目(Fork 指从开源项目的某一版本分离出来成为独立的项目),其绝大部分的 API 和参数都与 uglify-es 和 uglify-js@3 兼容。因此,两者对应参数的作用与优化方式也基本相同,这里就以 Terser 为例来分析其中的优化方向。
    在作为 Webpack 插件的 TerserWebpackPlugin 中,对执行效率产生影响的配置主要分为 3 个方面:
    Cache 选项:默认开启,使用缓存能够极大程度上提升再次构建时的工作效率,这方面的细节我们将在下节课中展开讨论。
    Parallel 选项:默认开启,并发选项在大多数情况下能够提升该插件的工作效率,但具体提升的程度则因项目而异。在小型项目中,多进程通信的额外消耗可能会抵消其带来的益处。
    terserOptions 选项:即 Terser 工具中的 minify 选项集合。这些选项是对具体压缩处理过程产生影响的配置项。我们主要来看其中的compress和mangle选项,不同选项的压缩结果如下面的代码所示: ```javascript //源代码./src/example-terser-opts.js

function HelloWorld() {

const foo = ‘1234’

console.log(HelloWorld, foo)

}

HelloWorld()

//默认配置项compress={}, mangle=true的压缩后代码

function(e,t){!function e(){console.log(e,”1234”)}()}});

//compress=false的压缩后代码

function(e,r){function t(){var e=”1234”;console.log(t,e)}t()}});

//mangle=false的压缩代码

function(module,exports){!function HelloWorld(){console.log(HelloWorld,”1234”)}()}});

//compress=false,mangle=false的压缩后代码

function(module,exports){function HelloWorld(){var foo=”1234”;console.log(HelloWorld,foo)}HelloWorld()}});

  1. 从上面的例子中可以看到:
  2. 1. **compress 参数的作用**是执行特定的压缩策略,例如省略变量赋值的语句,从而将变量的值直接替换到引入变量的位置上,减小代码体积。而当 compress 参数为 false 时,这类压缩策略不再生效,示例代码压缩后的体积从 1.16KB 增加到 1.2KB,对压缩质量的影响有限。
  3. 2. **mangle 参数的作用**是对源代码中的变量与函数名称进行压缩,当参数为 false 时,示例代码压缩后的体积从 1.16KB 增加到 1.84KB,对代码压缩的效果影响非常大。
  4. 从结果中可以看到,当**compress**参数为 false 时,压缩阶段的效率有明显提升,同时对压缩的质量影响较小。在需要对压缩阶段的效率进行优化的情况下,**可以优先选择设置该参数**。
  5. <a name="Vqje9"></a>
  6. #### 面向 CSS 的压缩工具
  7. CSS 同样有几种压缩工具可供选择:[OptimizeCSSAssetsPlugin](https://www.npmjs.com/package/optimize-css-assets-webpack-plugin)(在 Create-React-App 中使用)、[OptimizeCSSNanoPlugin](https://www.npmjs.com/package/@intervolga/optimize-cssnano-plugin)(在 VUE-CLI 中使用),以及[CSSMinimizerWebpackPlugin](https://www.npmjs.com/package/css-minimizer-webpack-plugin)(2020 年 Webpack 社区新发布的 CSS 压缩插件)。<br />这三个插件在压缩 CSS 代码功能方面,都默认基于 [cssnano](https://cssnano.co/) 实现,因此在压缩质量方面没有什么差别。<br />在压缩效率方面,首先值得一提的是最新发布的 CSSMinimizerWebpackPlugin,它**支持缓存和多进程**,这是另外两个工具不具备的。而在非缓存的普通压缩过程方面,整体上 3 个工具相差不大,不同的参数结果略有不同,如下面的表格所示(下面结果为示例代码中 example-css 的执行构建结果)。<br />注:CSSMinimizerWebpackPlugin 中默认开启多进程选项 parallel,但是在测试示例较小的情况下,多进程的通信时间反而可能导致效率的降低。测试中关闭多进程选项后,构建时间明显缩短。<br />从上面的表格中可以看到,三个插件的构建时间基本相近,在开启 sourceMap 的情况下 CSSMinimizerWebpackPlugin 的构建时间相对较长。但考虑到**只有这一新发布的插件支持缓存和多进程**等对项目构建效率影响明显的功能,即使在压缩 CSS 的时间较长的情况下,还是**推荐使用它**。
  8. <a name="eFnd8"></a>
  9. ### 以提升后续环节工作效率为目标的方案
  10. 优化阶段的另一类优化方向是通过对本环节的处理减少后续环节处理内容,以便提升后续环节的工作效率。我们列举两个案例:Split Chunks(分包) Tree Shaking(摇树)。
  11. <a name="r9jAk"></a>
  12. #### Split Chunks
  13. [Split Chunks(分包)](https://webpack.js.org/guides/code-splitting/)是指在 Chunk 生成之后,将原先以入口点来划分的 Chunks 根据一定的规则(例如异步引入或分离公共依赖等原则),分离出子 Chunk 的过程。<br />Split Chunks 有诸多优点,例如有利于缓存命中(下节课中会提到)、有利于运行时的持久化文件缓存等。其中有一类情况能提升后续环节的工作效率,即通过分包来抽离多个入口点引用的公共依赖。我们通过下面的代码示例(npm run build:split)来看一下。
  14. ```javascript
  15. ./src/example-split1.js
  16. import { slice } from 'lodash'
  17. console.log('slice', slice([1]))
  18. ./src/example-split2.js
  19. import { join } from 'lodash'
  20. console.log('join', join([1], [2]))
  21. ./webpack.split.config.js
  22. ...
  23. optimization: {
  24. ...
  25. splitChunks: {
  26. chunks: 'all'
  27. }
  28. }
  29. ...

在这个示例中,有两个入口文件引入了相同的依赖包 lodash,在没有额外设置分包的情况下, lodash 被同时打入到两个产物文件中,在后续的压缩代码阶段耗时 1740ms。而在设置分包规则为 chunks:’all’ 的情况下,通过分离公共依赖到单独的 Chunk,使得在后续压缩代码阶段,只需要压缩一次 lodash 的依赖包代码,从而减少了压缩时长,总耗时为 1036ms。通过下面两张图片也可以看出这样的变化。
这里起作用的是 Webpack 4 中内置的 SplitChunksPlugin,该插件在 production 模式下默认启用。其默认的分包规则为 chunks: ‘async‘,作用是分离动态引入的模块 (import(‘…’)),在处理动态引入的模块时能够自动分离其中的公共依赖。
但是对于示例中多入口静态引用相同依赖包的情况,则不会处理分包。而设置为 chunks: ‘all’,则能够将所有的依赖情况都进行分包处理,从而减少了重复引入相同模块代码的情况。SplitChunksPlugin 的工作阶段是在optimizeChunks阶段(Webpack 4 中是在 optimizeChunksAdvanced,在 Webpack 5 中去掉了 basic 和 advanced,合并为 optimizeChunks),而压缩代码是在 optimizeChunkAssets 阶段,从而起到提升后续环节工作效率的作用。

Tree Shaking

Tree Shaking(摇树)是指在构建打包过程中,移除那些引入但未被使用的无效代码(Dead-code elimination)。这种优化手段最早应用于在 Rollup 工具中,而在 Webpack 2 之后的版本中, Webpack 开始内置这一功能。下面我们先来看一下 Tree Shaking 的例子,如下面的表格所示:
image.png
可以看到,引入不同的依赖包(lodash vs lodash-es)、不同的引入方式,以及是否使用 babel 等,都会对 Tree Shaking 的效果产生影响。下面我们就来分析具体原因。

  1. ES6 模块: 首先,只有 ES6 类型的模块才能进行Tree Shaking。因为 ES6 模块的依赖关系是确定的,因此可以进行不依赖运行时的静态分析,而 CommonJS 类型的模块则不能。因此,CommonJS 类型的模块 lodash,在无论哪种引入方式下都不能实现 Tree Shaking,而需要依赖第三方提供的插件(例如 babel-plugin-lodash 等)才能实现动态删除无效代码。而 ES6 风格的模块 lodash-es,则可以进行 Tree Shaking 优化。
  2. 引入方式:以 default 方式引入的模块,无法被 Tree Shaking;而引入单个导出对象的方式,无论是使用 import * as xxx 的语法,还是 import {xxx} 的语法,都可以进行 Tree Shaking。
  3. sideEffects:在 Webpack 4 中,会根据依赖模块 package.json 中的 sideEffects 属性来确认对应的依赖包代码是否会产生副作用。只有 sideEffects 为 false 的依赖包(或不在 sideEffects 对应数组中的文件),才可以实现安全移除未使用代码的功能。在上面的例子中,如果我们查看 lodash-es 的 package.json 文件,可以看到其中包含了 “sideEffects”:false 的描述。此外,在 Webpack 配置的加载器规则和优化配置项中,分别有 rule.sideEffects(默认为 false)和 optimization.sideEffects(默认为 true)选项,前者指代在要处理的模块中是否有副作用,后者指代在优化过程中是否遵循依赖模块的副作用描述。尤其前者,常用于对 CSS 文件模块开启副作用模式,以防止被移除。
  4. Babel:在 Babel 7 之前的babel-preset-env中,modules 的默认选项为 ‘commonjs‘,因此在使用 babel 处理模块时,即使模块本身是 ES6 风格的,也会在转换过程中,因为被转换而导致无法在后续优化阶段应用 Tree Shaking。而在 Babel 7 之后的 @babel/preset-env 中,modules 选项默认为 ‘auto’,它的含义是对 ES6 风格的模块不做转换(等同于 modules: false),而将其他类型的模块默认转换为 CommonJS 风格。因此我们会看到,后者即使经过 babel 处理,也能应用 Tree Shaking。

    总结


    这一阶段的优化方向大致可分为两类:一类是以提升当前任务工作效率为目标的方案,这部分我们讨论了压缩 JS 时选择合适的压缩工具与配置优化项,以及压缩 CSS 时对优化工具的选择。另一类是以提升后续环节工作效率为目标的方案,这部分我们讨论了 splitChunks 的作用和配置项,以及应用 Tree Shaking 的一些注意事项。希望通过本节课的学习,帮助你加深对这一阶段 Webpack 处理逻辑的理解,也能够对其中的一些优化方式有更清晰的理解。