Skip to content

webpack的打包优化

引言

本章主要介绍一些优化 Webpack 配置的方法,目的是让打包的速度更快,输出的资源更小。首先重述一条软件工程领域的经验——不要过早优化,在项目的初期不要看到任何优化点就拿来加到项目中,这样不但增加了复杂度,优化的效果也不会太理想。一般是当项目发展到一定规模后,性能问题随之而来,这时再去分析然后对症下药,才有可能达到理想的优化效果。

HappyPack

HappyPack 是一个通过多线程来提升 Webpack 打包速度的工具。我们可以猜测 HappyPack 这个名字的由来,也许是它的作者在使用 Webpack 过程中无法忍受其漫长的打包过程,于是自己写了一个插件让速度快了很多,摆脱了构建的痛苦。对于很多大中型工程而言,HappyPack 确实可以显著地缩短打包时间。首先让我们了解一下它是如何工作的。

工作原理

在打包过程中有一项非常耗时的工作,就是使用 loader 将各种资源进行转译处理。最常见的包括使用 babel-loader 转译 ES6+语法和 ts-loader 转译 TypeScript。我们可以简单地将代码转译的工作流程概括如下:

  1. 从配置中获取打包入口;
  2. 匹配 loader 规则,并对入口模块进行转译;
  3. 对转译后的模块进行依赖查找(如 a.js 中加载了 b.js 和 c.js);
  4. 对新找到的模块重复进行步骤 2)和步骤 3),直到没有新的依赖模块。

不难看出从步骤 2)到步骤 4)是一个递归的过程,Webpack 需要一步步地获取更深层级的资源,然后逐个进行转译。这里的问题在于 Webpack 是单线程的,假设一个模块依赖于几个其他模块,Webpack 必须对这些模块逐个进行转译。虽然这些转译任务彼此之间没有任何依赖关系,却必须串行地执行。HappyPack 恰恰以此为切入点,它的核心特性是可以开启多个线程,并行地对不同模块进行转译,这样就可以充分利用本地的计算资源来提升打包速度。

HappyPack 适用于那些转译任务比较重的工程,当我们把类似 babel-loader 和 ts-loader 迁移到 HappyPack 之上后,一般都可以收到不错的效果,而对于其他的如 sass-loader、less-loader 本身消耗时间并不太多的工程则效果一般。

单个 loader 的优化

在实际使用时,要用 HappyPack 提供的 loader 来替换原有 loader,并将原有的那个通过 HappyPack 插件传进去。例如: 在 module.rules 中,我们使用 happypack/loader 替换了原有的 babel-loader,并在 plugins 中添加了 HappyPack 的插件,将原有的 babel-loader 连同它的配置插入进去即可。

多个 loader 的优化

在使用多个 HappyPack loader 的同时也就意味着要插入多个 HappyPack 的插件,每个插件加上 id 来作为标识。同时我们也可以为每个插件设置具体不同的配置项,如使用的线程数、是否开启 debug 模式等。

缩小打包作用域

从宏观角度来看,提升性能的方法无非两种:增加资源或者缩小范围。增加资源就是指使用更多 CPU 和内存,用更多的计算能力来缩短执行任务的时间;缩小范围则是针对任务本身,比如去掉冗余的流程,尽量不做重复性的工作等。前面我们说的 HappyPack 属于增加资源,那么接下来我们再谈谈如何缩小范围。

  • exclude 和 include: 对于 JS 来说,一般要把 node_modules 目录排除掉,另外当 exclude 和 include 规则有重叠的部分时,exclude 的优先级更高。
  • noParse: 有些库我们是希望 Webpack 完全不要去进行解析的,即不希望应用任何 loader 规则,库的内部也不会有对其他模块的依赖,那么这时可以使用 noParse 对其进行忽略。
  • IgnorePlugin: exclude 和 include 是确定 loader 的规则范围,noParse 是不去解析但仍会打包到 bundle 中。IgnorePlugin 可以完全排除一些模块,被排除的模块即便被引用了也不会被打包进资源文件中。
  • Cache: 有些 loader 会有一个 cache 配置项,用来在编译代码后同时保存一份缓存,在执行下一次编译前会先检查源码文件是否有变化,如果没有就直接采用缓存,也就是上次编译的结果。这样相当于实际编译的只有变化了的文件,整体速度上会有一定提升。

动态链接库与 DllPlugin

动态链接库是早期 Windows 系统由于受限于当时计算机内存空间较小的问题而出现的一种内存优化方法。当一段相同的子程序被多个程序调用时,为了减少内存消耗,可以将这段子程序存储为一个可执行文件,当被多个程序调用时只在内存中生成和使用同一个实例。

DllPlugin 借鉴了动态链接库的这种思路,对于第三方模块或者一些不常变化的模块,可以将它们预先编译和打包,然后在项目实际构建过程中直接取用即可。当然,通过 DllPlugin 实际生成的还是 JS 文件而不是动态链接库,取这个名字只是由于方法类似罢了。在打包 vendor 的时候还会附加生成一份 vendor 的模块清单,这份清单将会在工程业务模块打包时起到链接和索引的作用。

DllPlugin 和 Code Splitting 有点类似,都可以用来提取公共模块,但本质上有一些区别。Code Splitting 的思路是设置一些特定的规则并在打包的过程中根据这些规则提取模块;DllPlugin 则是将 vendor 完全拆出来,有自己的一整套 Webpack 配置并独立打包,在实际工程构建时就不用再对它进行任何处理,直接取用即可。因此,理论上来说,DllPlugin 会比 Code Splitting 在打包速度上更胜一筹,但也相应地增加了配置,以及资源管理的复杂度。

vendor 配置

首先需要为动态链接库单独创建一个 Webpack 配置文件,比如命名为 webpack.vendor.config.js,用来区别工程本身的配置文件 webpack.config.js。

请看下面的例子:

js
// webpack.vendor.config.js
const path = require("path");
const webpack = require("webpack");
const dllAssetPath = path.join(__dirname, "dll");
const dllLibraryName = "dllExample";
module.exports = {
  entry: ["react"],
  output: {
    path: dllAssetPath,
    filename: "vendor.js",
    library: dllLibraryName,
  },
  plugins: [
    new webpack.DllPlugin({
      name: dllLibraryName,
      path: path.join(dllAssetPath, "manifest.json"),
    }),
  ],
};

配置中的 entry 指定了把哪些模块打包为 vendor。plugins 的部分我们引入了 Dll-Plugin,并添加了以下配置项。

  • name:导出的 dll library 的名字,它需要与 output.library 的值对应。
  • path:资源清单的绝对路径,业务代码打包时将会使用这个清单进行模块索引。

Vendor 打包

接下来我们就要打包 vendor 并生成资源清单了。为了后续运行方便,可以在 package.json 中配置一条 npm script,如下所示:

json
// package.json
{
  ...
  "scripts": {
    "dll": "webpack --config webpack.vendor.config.js"
  },
}

运行 npm run dll 后会生成一个 dll 目录,里面有两个文件 vendor.js 和 manifest.json,前者包含了库的代码,后者则是资源清单。 可以预览一下生成的 vendor.js,它以一个立即执行函数表达式的声明开始。

js
var dllExample = (function (params) {
  // ...
})(params);

上面的 dllExample 正是我们在 webpack.vendor.config.js 中指定的 dllLibraryName。 接着打开 manifest.json,其大体内容如下:

json
{
  "name": "dllExample",
  "content": {
    "./node_modules/fbjs/lib/invariant.js": {
      "id": 0,
      "buildMeta": { "providedExports": true }
    },
    ...
  }
}

manifest.json 中有一个 name 字段,这是我们通过 DllPlugin 中的 name 配置项指定的。

链接到业务代码

将 vendor 链接到项目中很简单,这里我们将使用与 DllPlugin 配套的插件 DllReferencePlugin,它起到一个索引和链接的作用。在工程的 webpack 配置文件(webpack.config.js)中,通过 DllReferencePlugin 来获取刚刚打包好的资源清单,然后在页面中添加 vendor.js 的引用就可以了。请看下面的示例:

js
// webpack.config.js
const path = require("path");
const webpack = require("webpack");
module.exports = {
  // ...
  plugins: [
    new webpack.DllReferencePlugin({
      manifest: require(path.join(__dirname, "dll/manifest.json")),
    }),
  ],
};
html
// index.html
<body>
  <!-- ... -->
  <script src="dll/vendor.js"></script>
  <script src="dist/app.js"></script>
</body>

当页面执行到 vendor.js 时,会声明 dllExample 全局变量。而 manifest 相当于我们注入 app.js 的资源地图,app.js 会先通过 name 字段找到名为 dllExample 的 library,再进一步获取其内部模块。这就是我们在 webpack.vendor.config.js 中给 DllPlugin 的 name 和 output.library 赋相同值的原因。如果页面报“变量 dllExample 不存在”的错误,那么有可能就是没有指定正确的 output.library,或者忘记了在业务代码前加载 vendor.js。

潜在问题

目前我们的配置还存在一个潜在的问题。当我们打开 manifest.json 后,可以发现每个模块都有一个 id,其值是按照数字顺序递增的。业务代码在引用 vendor 中模块的时候也是引用的这个数字 id。当我们更改 vendor 时这个数字 id 也会随之发生变化。

假设我们的工程中目前有以下资源文件,并为每个资源都加上了 chunk hash。

  • vendor@[hash].js(通过 DllPlugin 构建)
  • page1@[hash].js
  • page2@[hash].js
  • util@[hash].js

现在 vendor 中有一些模块,不妨假定其中包含了 react,其 id 是 5。当尝试添加更多的模块到 vendor 中(比如 util.js 使用了 moment.js,我们希望 moment.js 也通过 DllPlugin 打包)时,那么重新进行 Dll 构建时 moment.js 有可能会出现在 react 之前,此时 react 的 id 就变为了 6。page1.js 和 page2.js 是通过 id 进行引用的,因此它们的文件内容也相应发生了改变。此时我们可能会面临以下两种情况:

  • page1.js 和 page2.js 的 chunk hash 均发生了改变。这是我们不希望看到的,因为它们内容本身并没有改变,而现在 vendor 的变化却使得用户必须重新下载所有资源。
  • page1.js 和 page.js 的 chunk hash 没有改变。这种情况大多发生在较老版本的 Webpack 中,并且比第 1 种情况更为糟糕。因为 vendor 中的模块 id 改变了,而用户却由于没有更新缓存而继续使用过去版本的 page1.js 和 page2.js,也就引用不到新的 vendor 模块而导致页面错误。对于开发者来说,这个问题很难排查,因为在开发环境下一切都是正常的,只有在生产环境会看到页面崩溃。

这个问题的根源在于,当我们对 vendor 进行操作时,本来 vendor 中不应该受到影响的模块却改变了它们的 id。解决这个问题的方法很简单,在打包 vendor 时添加上 HashedModuleIdsPlugin。请看下面的例子:

js
// webpack.vendor.config.js
module.exports = {
  // ...
  plugins: [
    new webpack.DllPlugin({
      name: dllLibraryName,
      path: path.join(dllAssetPath, "manifest.json"),
    }),
    new webpack.HashedModuleIdsPlugin(),
  ],
};

这个插件是在 Webpack 3 中被引入进来的,主要就是为了解决数字 id 的问题。从 Webpack 3 开始,模块 id 不仅可以是数字,也可以是字符串。HashedModuleIdsPlugin 可以把 id 的生成算法改为根据模块的引用路径生成一个字符串 hash。比如一个模块的 id 是 2Nul(hash 值),因为它的引用路径不会因为操作 vendor 中的其他模块而改变,id 将会是统一的,这样就解决了我们前面提到的问题。

tree shaking

在第 2 章我们介绍过,ES6 Module 依赖关系的构建是在代码编译时而非运行时。基于这项特性 Webpack 提供了 tree shaking 功能,它可以在打包过程中帮助我们检测工程中没有被引用过的模块,这部分代码将永远无法被执行到,因此也被称为“死代码”。Webpack 会对这部分代码进行标记,并在资源压缩时将它们从最终的 bundle 中去掉。下面的例子简单展示了 tree shaking 是如何工作的。

js
// index.js
import { foo } from "./util";
foo();

// util.js
export function foo() {
  console.log("foo");
}
export function bar() {
  // 没有被任何其他模块引用,属于“死代码”
  console.log("bar");
}

在 Webpack 打包时会对 bar()添加一个标记,在正常开发模式下它仍然存在,只是在生产环境的压缩那一步会被移除掉。 tree shaking 有时可以使 bundle 体积显著减小,而实现 tree shaking 则需要一些前提条件。

ES6 Module

tree shaking 只能对 ES6 Module 生效。有时我们会发现虽然只引用了某个库中的一个接口,却把整个库加载进来了,而 bundle 的体积并没有因为 tree shaking 而减小。这可能是由于该库是使用 CommonJS 的形式导出的,为了获得更好的兼容性,目前大部分的 npm 包还在使用 CommonJS 的形式。也有一些 npm 包同时提供了 ES6 Module 和 CommonJS 两种形式导出,我们应该尽可能使用 ES6 Module 形式的模块,这样 tree shaking 的效率更高。

使用 Webpack 进行依赖关系构建

如果我们在工程中使用了 babel-loader,那么一定要通过配置来禁用它的模块依赖解析。因为如果由 babel-loader 来做依赖解析,Webpack 接收到的就都是转化过的 CommonJS 形式的模块,无法进行 tree-shaking。禁用 babel-loader 模块依赖解析的配置示例如下:

js
module.exports = {
  // ...
  module: {
    rules: [{
      test: /\.js$/,
      exclude: /node_modules/,
      use: [{
        loader: 'babel-loader',
        options: {
          presets: [
            // 这里一定要加上 modules: false
            [@babel/preset-env, { modules: false }]
          ],
        },
      }],
    }],
  },
};

用压缩工具去除死代码

tree shaking 本身只是为死代码添加上标记,真正去除死代码是通过压缩工具来进行的。使用我们前面介绍过的 terser-webpack-plugin 即可。在 Webpack 4 之后的版本中,将 mode 设置为 production 也可以达到相同的效果。

小结

在这一章中,我们介绍了加快打包速度,减小资源体积的一些方法。对于一些对性能要求高的项目来说这些方法可以起到一定的效果。最后需要强调的是,每一种优化策略都有其使用场景,并不是任何一个点放在一切项目中都有效。当我们发现性能的问题时,还是要根据现有情况分析出瓶颈在哪里,然后对症下药。

备案号:闽ICP备2024028309号-1