基于 webpack 的持久化缓存方案

2,158 阅读14分钟
原文链接: github.com

如何基于 webpack 做持久化缓存似乎一直处于没有最佳实践的状态。网路上各式各样的文章很多,open 的 bug 反馈和建议成堆,很容易让人迷茫和心智崩溃。

作为开发者最大的诉求是:在 entry 内部内容未发生变更的情况下构建之后也能稳定不变。

TL;DR;

拉到最后看总结 XD

hash 的两种计算方式

想要做持久化缓存的首要一步是 hash,在 webpack 中提供了两种方式,hashchunkhash

在此或许有不少同学就这两者之间的差别就模糊了:

hash:在 webpack 一次构建中会产生一个 compilation 对象,该 hash 值是对 compilation 内所有的内容计算而来的,

chunkhash:每一个 chunk 都根据自身的内容计算而来。

单从上诉描述来看,chunkhash 应该在持久化缓存中更为有效。

到底是否如此呢,接下来我们设定一个应用场景。

设定场景

entry 入口文件 入口文件依赖链
pageA a.js a.less <- a.css
common.js <- common.less <- common.css
lodash
pageB b.js b.less <- b.css
common.js <- common.less <- common.css
lodash
  • hash 计算方式为 hash 时:
......
module.exports = {
  entry: {
    "pageA": "./a.js",
    "pageB": "./b.js",
  },
  output: {
    path: path.join(cwd, 'dist'),
    filename: '[name]-[hash].js'
  },
  module: {
    rules: ...
  },
  plugins: [
    new ExtractTextPlugin('[name]-[hash].css'),
  ]
}

构建结果:

Hash: 7ee8fcb953c70a896294
Version: webpack 3.8.1
Time: 6308ms
                         Asset       Size  Chunks                    Chunk Names
 pageB-7ee8fcb953c70a896294.js     525 kB       0  [emitted]  [big]  pageB
 pageA-7ee8fcb953c70a896294.js     525 kB       1  [emitted]  [big]  pageA
pageA-7ee8fcb953c70a896294.css  147 bytes       1  [emitted]         pageA
pageB-7ee8fcb953c70a896294.css  150 bytes       0  [emitted]         pageB

如果细心一点,多尝试几次,可以发现即使在全部内容未变动的情况下 hash 值也会发生变更,原因在于我们使用了 extract,extract 本身涉及到异步的抽取流程,所以在生成 assets 资源时存在了不确定性(先后顺序),而 updateHash 则对其敏感,所以就出现了如上所说的 hash 异动的情况。另外所有 assets 资源的 hash 值保持一致,这对于所有资源的持久化缓存来说并没有深远的意义。

  • hash 计算方式为 chunkhash 时:
......
module.exports = {
  entry: {
    "pageA": "./a.js",
    "pageB": "./b.js",
  },
  output: {
    path: path.join(cwd, 'dist'),
    filename: '[name]-[chunkhash].js'
  },
  module: {
    rules: ...
  },
  plugins: [
    new ExtractTextPlugin('[name]-[chunkhash].css'),
  ]
}

构建结果:

Hash: 1b432b2e0ea7c80439ff
Version: webpack 3.8.1
Time: 1069ms
                         Asset       Size  Chunks                    Chunk Names
 pageB-58011d1656e7b568204e.js     525 kB       0  [emitted]  [big]  pageB
 pageA-5c744cecf5ed9dd0feaf.js     525 kB       1  [emitted]  [big]  pageA
pageA-5c744cecf5ed9dd0feaf.css  147 bytes       1  [emitted]         pageA
pageB-58011d1656e7b568204e.css  150 bytes       0  [emitted]         pageB

此时可以发现,运行多少次,hash 的异动没有了,每个 entry 拥有了自己独一的 hash 值,细心的你或许会发现此时样式资源的 hash 值和 入口脚本保持了一致,这似乎并不符合我们的想法,冥冥之中告诉我们发生了某些坏事情。

然后尝试随意修改 b.css 然后重新构建得到以下日志,

Hash: 50abba81a316ad20f82a
Version: webpack 3.8.1
Time: 1595ms
                         Asset       Size  Chunks                    Chunk Names
 pageB-58011d1656e7b568204e.js     525 kB       0  [emitted]  [big]  pageB
 pageA-5c744cecf5ed9dd0feaf.js     525 kB       1  [emitted]  [big]  pageA
pageA-5c744cecf5ed9dd0feaf.css  147 bytes       1  [emitted]         pageA
pageB-58011d1656e7b568204e.css  147 bytes       0  [emitted]         pageB

不可思议的恐怖的事情发生了,居然 PageB 脚本和样式的 hash 值均未发生改变。为什么?细想一下不难理解,因为在 webpack 中所有的内容都视为 js 的一部分,而当构建发生,extract 生效后,样式被抽离出 entry chunk,此时对于 entry chunk 来说其本身并未发生改变,因为改变的部分已经被抽离变成 normal chunk,而 chunkhash 是根据 chunk 内容而来,所以不变更应该是符合预期的行为。虽然原理和结果符合预期,但是这并不是持久化缓存所需要的。幸运的是,extract-text-plugin 为抽离出来的内容提供了 contenthash 即:new ExtractTextPlugin('[name]-[contenthash].css')

Hash: 50abba81a316ad20f82a
Version: webpack 3.8.1
Time: 1177ms
                                     Asset       Size  Chunks                    Chunk Names
             pageB-58011d1656e7b568204e.js     525 kB       0  [emitted]  [big]  pageB
             pageA-5c744cecf5ed9dd0feaf.js     525 kB       1  [emitted]  [big]  pageA
pageA-3ebfe4559258be46a13401ec147e4012.css  147 bytes       1  [emitted]         pageA
pageB-c584acc56d4dd7606ab09eb7b3bd5e9f.css  147 bytes       0  [emitted]         pageB

此时我们再修改 b.css 然后重新构建得到以下日志,

Hash: 08c8682f823ef6f0d661
Version: webpack 3.8.1
Time: 1313ms
                                     Asset       Size  Chunks                    Chunk Names
             pageB-58011d1656e7b568204e.js     525 kB       0  [emitted]  [big]  pageB
             pageA-5c744cecf5ed9dd0feaf.js     525 kB       1  [emitted]  [big]  pageA
pageA-3ebfe4559258be46a13401ec147e4012.css  147 bytes       1  [emitted]         pageA
pageB-0651d43f16a9b34b4b38459143ac5dd8.css  150 bytes       0  [emitted]         pageB

很棒!一切符合预期,只有 pageB 的样式 hash 发生了变更。你以为事情都结束了,然而总是会一波三折

接下来我们尝试在 a.js 中除去依赖 a.less,再进行一次构建,得到以下日志

Hash: 649f27b36d142e5e39cc
Version: webpack 3.8.1
Time: 1557ms
                                     Asset       Size  Chunks                    Chunk Names
             pageB-0ca5aed30feb05b1a5e2.js     525 kB       0  [emitted]  [big]  pageB
             pageA-1a8ce6dcab969d4e4480.js     525 kB       1  [emitted]  [big]  pageA
pageA-f83ea969c4ec627cb92bea42f12b75d6.css   91 bytes       1  [emitted]         pageA
pageB-0651d43f16a9b34b4b38459143ac5dd8.css  150 bytes       0  [emitted]         pageB

奇怪的事情再次发生,这边我们可以理解 pageA 的脚本和样式发生变化。但是对于 pageB 的脚本也发生变化感觉并不符合预期。

所以我们 pageB.js 去看一看到底是什么发生了变更。

通过如下命令我们可以获知具体的变更位置

$ git diff dist/pageB-58011d1656e7b568204e.js dist/pageB-0ca5aed30feb05b1a5e2.js

结果为:

 /******/       __webpack_require__.p = "";
 /******/
 /******/       // Load entry module and return exports
-/******/       return __webpack_require__(__webpack_require__.s = 75);
+/******/       return __webpack_require__(__webpack_require__.s = 74);
 /******/ })
 /************************************************************************/
 /******/ ([
/***/ }),
 /* 73 */,
-/* 74 */,
-/* 75 */
+/* 74 */
 /***/ (function(module, exports, __webpack_require__) {

 "use strict";


 console.log('bx');
-__webpack_require__(76);
+__webpack_require__(75);
 __webpack_require__(38);
 __webpack_require__(40);

 /***/ }),
-/* 76 */
+/* 75 */
 /***/ (function(module, exports) {

 // removed by extract-text-webpack-plugin

以上我们可以明确的知道,当 pageA 内移除 a.less 后整体的 id 发生了变更。那么可以推测的是 id 代表着具体的引用的模块。

其实在构建结束时,webpack 会给到我们具体的每个模块分配到的 id 。

case: pageA 移除 a.less 前

[73] ./a.js 93 bytes {1} [built]
[74] ./a.less 41 bytes {1} [built]
[75] ./b.js 94 bytes {0} [built]
[76] ./b.less 41 bytes {0} [built]

case: pageA 移除 a.less 后

[73] ./a.js 72 bytes {1} [built]
[74] ./b.js 94 bytes {0} [built]
[75] ./b.less 41 bytes {0} [built]

通过比较发现,在 pageA 移除 a.less 的依赖前,居然在其构建出来的代码中,隐藏着/* 73 */,/* 74 */,,也就是说 pageB 的脚本中包含着 a.js, a.less 的模块 id 信息。这对于持久化来说并不符合预期。我们期待的是 pageB 中不会包含任何和它并不相关的内容。

这边衍生出两个命题

命题1:如何把不相关的 module id 或者说内容摒除在外

命题2:如何能让 module id 尽可能的保持不变

module id 异动

我们来一个一个看。

命题1:如何把不相关的 module id 或者说内容摒除在外

简单来说,我们的目标就是把这些不相关的内容摒除在 pageA 和 pageB 的 entry chunk 之外。

对 webpack 熟悉的人或多或少听说过 Code Splitting,本质上是对 chunk 进行拆分再组合的过程。那谁能完成此任务呢?

相信你已经猜到了 - CommonsChunkPlugin

接下来我们回退所有之前的变更。来检验我们的猜测是否正确。

在构建配置中我们加上 CommonsChunkPlugin

...
plugins: [
  new ExtractTextPlugin('[name]-[contenthash].css'),
+ new webpack.optimize.CommonsChunkPlugin({
+   name: 'runtime'
+ }),
],
...

case: pageA 移除 a.less 前

Hash: fc0f3a602209ca0adea9
Version: webpack 3.8.1
Time: 1182ms
                                       Asset       Size  Chunks                    Chunk Names
               pageB-ec1c1e788034e2312e56.js  316 bytes       0  [emitted]         pageB
               pageA-cd16b75b434f1ff41442.js  315 bytes       1  [emitted]         pageA
             runtime-3f77fc83f59d6c4208c4.js     529 kB       2  [emitted]  [big]  runtime
  pageA-8c3d50283e85cb98eafa5ed6a3432bab.css   56 bytes       1  [emitted]         pageA
  pageB-64db1330bc88b15e8c5ae69a711f8179.css   59 bytes       0  [emitted]         pageB
runtime-f83ea969c4ec627cb92bea42f12b75d6.css   91 bytes       2  [emitted]         runtime

case: pageA 移除 a.less 后

Hash: 8881467bf592ceb67696
Version: webpack 3.8.1
Time: 1185ms
                                       Asset       Size  Chunks                    Chunk Names
               pageB-8e3a2584840133ffc827.js  316 bytes       0  [emitted]         pageB
               pageA-a5d2ad06fbaf6a0e42e0.js  190 bytes       1  [emitted]         pageA
             runtime-f8bc79ce500737007969.js     529 kB       2  [emitted]  [big]  runtime
  pageB-64db1330bc88b15e8c5ae69a711f8179.css   59 bytes       0  [emitted]         pageB
runtime-f83ea969c4ec627cb92bea42f12b75d6.css   91 bytes       2  [emitted]         runtime

此时我们再通过如下命令

$ git diff dist/pageB-8e3a2584840133ffc827.js dist/pageB-ec1c1e788034e2312e56.js

对 pageB 的脚本来进行对比

 webpackJsonp([0],{

-/***/ 74:
+/***/ 75:
 /***/ (function(module, exports, __webpack_require__) {

 "use strict";


 console.log('bx');
-__webpack_require__(75);
+__webpack_require__(76);
 __webpack_require__(27);
 __webpack_require__(28);

 /***/ }),

-/***/ 75:
+/***/ 76:
 /***/ (function(module, exports) {

 // removed by extract-text-webpack-plugin

 /***/ })

-},[74]);
\ No newline at end of file
+},[75]);
\ No newline at end of file

发现模块的内容终于不再包含和 pageB 不相关的其他的内容。换言之 CommonsChunkPlugin 达到了我们的预期,其实这部分内容即是 webpack 的 runtime,他存储着 webpack 对 module 和 chunk 的信息。另外有趣的是 pageA 和 pageB 在尺寸上也有了惊人的减小,原因在于默认行为的 CommonsChunkPlugin 会把 entry chunk 都包含的 module 抽取到这个名为 runtime 的 normal chunk 中。在持久化缓存中我们的目标是力争变更达到最小化。但是在如上两次变更中不难发现我们仅仅是变更了 pageA 但是 runtime pageB pageA 却都发生了变更,另外由于 runtime 中由于 CommonsChunkPlugin 的默认行为抽取了 lodash,我们有充分的理由相信 lodash 并未更新但却需要花费高昂的代价去更新,这并不符合最小化原则。

所以在这边需要谈到的另外一点便是 CommonsChunkPlugin 的用法并不仅仅局限于自动化的抽取,在持久化缓存的背景下我们也需要人为去干预这部分内容,真正意义上去抽取公共内容,并尽量保证后续不再变更。

在这里需要再迈出一步去自定义公共部分的内容。注意 runtime 要放在最后!

...
entry: {
  "pageA": "./a.js",
  "pageB": "./b.js",
+ "vendor": [ "lodash" ],
},
...
plugins: [
  new ExtractTextPlugin('[name]-[contenthash].css'),
+ new webpack.optimize.CommonsChunkPlugin({
+   name: 'vendor',
+   minChunks: Infinity
+ }),
  new webpack.optimize.CommonsChunkPlugin({
    name: 'runtime'
  }),
],
...

我们再对所有的变更进行回退。再来看看是否会满足我们的期望!

case: pageA 移除 a.less 前

Hash: 719ec2641ed362269d4e
Version: webpack 3.8.1
Time: 4190ms
                                     Asset       Size  Chunks                    Chunk Names
            vendor-32e0dd05f48355cde3dd.js     523 kB       0  [emitted]  [big]  vendor
             pageB-204aff67bf5908c0939c.js  559 bytes       1  [emitted]         pageB
             pageA-44af68ebd687b6c800f7.js  558 bytes       2  [emitted]         pageA
           runtime-77e92c75831aa5a249a7.js    5.88 kB       3  [emitted]         runtime
pageA-3ebfe4559258be46a13401ec147e4012.css  147 bytes       2  [emitted]         pageA
pageB-0651d43f16a9b34b4b38459143ac5dd8.css  150 bytes       1  [emitted]         pageB

case: pageA 移除 a.less 后

Hash: 93ab4ab5c33423421e51
Version: webpack 3.8.1
Time: 4039ms
                                     Asset       Size  Chunks                    Chunk Names
            vendor-329a6b18e90435921ff8.js     523 kB       0  [emitted]  [big]  vendor
             pageB-96f40d170374a713b0ce.js  559 bytes       1  [emitted]         pageB
             pageA-1d31b041a29dcde01cc5.js  433 bytes       2  [emitted]         pageA
           runtime-f612a395e44e034757a4.js    5.88 kB       3  [emitted]         runtime
pageA-f83ea969c4ec627cb92bea42f12b75d6.css   91 bytes       2  [emitted]         pageA
pageB-0651d43f16a9b34b4b38459143ac5dd8.css  150 bytes       1  [emitted]         pageB

到此为止,合理利用 CommonsChunkPlugin 我们解决了命题 1

命题2:如何能让 module id 尽可能的保持不变

module id 是一个模块的唯一性标识,且该标识会出现在构建之后的代码中,如以下 pageB 脚本片段

/***/ 74:
/***/ (function(module, exports, __webpack_require__) {

"use strict";


console.log('bx');
__webpack_require__(75);
__webpack_require__(13);
__webpack_require__(15);

/***/ }),

模块的增减肯定或者引用权重的变更肯定会导致 id 的变更(这边对 id 如何进行分配不做展开讨论,如有兴趣可以以 webpack@1 中的 OccurrenceOrderPlugin 作为切入,该插件在 webpack@2 中被默认内置)。所以不难想象如果要解决这个问题,肯定是需要再找一个能保持唯一性的内容,并在构建期间进行 id 订正。

所以命题二被拆分成两个部分。

  • 找到替代数值型 module id 方式
  • 找到时机进行 id 订正

找到替代数值型 module id 方式

直觉的第一反应肯定是路径,因为在一次构建中资源的路径肯定是唯一的,另外我们也可以非常庆幸在 webpack 中肯定在 resolve module 的环节中拿到资源的路径。

不过谈到路径,我们不得不担忧一下,windows 和 macos 下路径的 sep 是不一致的,如果我们把 id 生成这一块单独拿出来自己做了,会不会还要处理一大堆可能存在的差异性问题。带着这样的困惑我查阅了 webpack 的源码其中在 ContextModule#74ContextModule#35 中 webpack 对 module 的路径做了差异性修复。

也就是说我们可以放心的通过 module 的 libIdent 方法来获取模块的路径

找到时机进行 id 订正

时机就不是难事了,在 webpack 中我一直认为最 NB 的地方在于其整体插件的实现全部基于它的 tapable 事件系统,在灵活性上堪称完美。事件机制这部分内容我会在后续着重写文章分享。

这边我们只需要知道的是,在整个 webpack 执行过程中涉及 moudle id 的事件有

before-module-ids -> optimize-module-ids -> after-optimize-module-ids

所以我们只需要在 before-module-ids 这个时机内进行 id 订正即可。

实现 module id 稳定

// 插件实现核心片段
apply(compiler) {
    compiler.plugin("compilation", (compilation) => {
        compilation.plugin("before-module-ids", (modules) => {
            modules.forEach((module) => {
                if(module.id === null && module.libIdent) {
                    module.id = module.libIdent({
                        context: this.options.context || compiler.options.context
                    });
                }
            });
        });
    });
}

这部分内容,已经被 webpack 抽取为一个内置插件 NamedModulesPlugin

所以只需一小步在构建配置中添加该插件即可

...
entry: {
  "pageA": "./a.js",
  "pageB": "./b.js",
  "vendor": [ "lodash" ],
},
...
plugins: [
  new ExtractTextPlugin('[name]-[contenthash].css'),
+ new webpack.NamedModulesPlugin(),
  new webpack.optimize.CommonsChunkPlugin({
    name: 'vendor',
    minChunks: Infinity
  }),
  new webpack.optimize.CommonsChunkPlugin({
    name: 'runtime'
  }),
],
...

回滚之前所有的代码修改,我们再来做相应的比较

case: pageA 移除 a.less 前

Hash: 563971a30d909bbcb0db
Version: webpack 3.8.1
Time: 1271ms
                                     Asset       Size  Chunks                    Chunk Names
            vendor-a5620db988a639410257.js     539 kB       0  [emitted]  [big]  vendor
             pageB-42b894ca482a061570ae.js  681 bytes       1  [emitted]         pageB
             pageA-b7d7de62392f41af1f78.js  680 bytes       2  [emitted]         pageA
           runtime-dc322ed118963cd2e12a.js    5.88 kB       3  [emitted]         runtime
pageA-3ebfe4559258be46a13401ec147e4012.css  147 bytes       2  [emitted]         pageA
pageB-0651d43f16a9b34b4b38459143ac5dd8.css  150 bytes       1  [emitted]         pageB

case: pageA 移除 a.less 后

Hash: 0d277f49f54159bc7286
Version: webpack 3.8.1
Time: 950ms
                                     Asset       Size  Chunks                    Chunk Names
            vendor-a5620db988a639410257.js     539 kB       0  [emitted]  [big]  vendor
             pageB-42b894ca482a061570ae.js  681 bytes       1  [emitted]         pageB
             pageA-bedb93c1db950da4fea1.js  539 bytes       2  [emitted]         pageA
           runtime-85b317d7b21588411828.js    5.88 kB       3  [emitted]         runtime
pageA-f83ea969c4ec627cb92bea42f12b75d6.css   91 bytes       2  [emitted]         pageA
pageB-0651d43f16a9b34b4b38459143ac5dd8.css  150 bytes       1  [emitted]         pageB

自此利用 NamedModulesPlugin 我们做到了 pageA 中的变更只引发了 pageA 的脚本、样式、和 runtime 的变更,而 vendor,pageB 的脚本和样式均未发生变更。

一窥 pageB 的代码片段

/***/ "./b.js":
/***/ (function(module, exports, __webpack_require__) {

"use strict";


console.log('bx');
__webpack_require__("./b.less");
__webpack_require__("./common.js");
__webpack_require__("./node_modules/_lodash@4.17.4@lodash/lodash.js");

/***/ }),

确实模块的 id 被替换成了模块的路径。但是不得不规避的问题是,尺寸变大了,因为 id 数字 和 路径的字符数不是一个量级,以 vendor 为例,应用方案前后尺寸上增加了 16KB。或许有同学已经想到,那我对路径做次 hash 然后取几位不就得了,是的没错,webpack 官方就是这么做的。NamedModulesPlugin 适合在开发环境,而在生产环境下请使用 HashedModuleIdsPlugin

所以在生产环境下,为了获得最佳尺寸我们需要变更下构建的配置

...
entry: {
  "pageA": "./a.js",
  "pageB": "./b.js",
  "vendor": [ "lodash" ],
},
...
plugins: [
  new ExtractTextPlugin('[name]-[contenthash].css'),
- new webpack.NamedModulesPlugin(),
+ new webpack.HashedModuleIdsPlugin(),
  new webpack.optimize.CommonsChunkPlugin({
    name: 'vendor',
    minChunks: Infinity
  }),
  new webpack.optimize.CommonsChunkPlugin({
    name: 'runtime'
  }),
],
...
Hash: 80871a9833e531391384
Version: webpack 3.8.1
Time: 1230ms
                                     Asset       Size  Chunks                    Chunk Names
            vendor-2e968166c755a7385f9b.js     524 kB       0  [emitted]  [big]  vendor
             pageB-68be4dda51b5b08538f2.js  595 bytes       1  [emitted]         pageB
             pageA-a70b7fa4d67cb16cb1f7.js  461 bytes       2  [emitted]         pageA
           runtime-6897b6cc7d074a5b2039.js    5.88 kB       3  [emitted]         runtime
pageA-f83ea969c4ec627cb92bea42f12b75d6.css   91 bytes       2  [emitted]         pageA
pageB-0651d43f16a9b34b4b38459143ac5dd8.css  150 bytes       1  [emitted]         pageB

在生产环境下把 NamedModulesPlugin 替换为 HashedModuleIdsPlugin,在包的尺寸增加幅度上上达到了可接受的范围,以 vendor 为例,只增加了 1KB。

事情到此我以为可以结束了,直到我 diff 了一下 runtime 才发现持久化缓存似乎还可以继续深挖。

$ diff --git a/dist/runtime-85b317d7b21588411828.js b/dist/runtime-dc322ed118963cd2e12a.js
 /******/               if (__webpack_require__.nc) {
 /******/                       script.setAttribute("nonce", __webpack_require__.nc);
 /******/               }
-/******/               script.src = __webpack_require__.p + "" + chunkId + "-" + {"0":"a5620db988a639410257","1":"42b894ca482a061570ae","2":"bedb93c1db950da4fea1"}[chunkId] + ".js";
+/******/               script.src = __webpack_require__.p + "" + chunkId + "-" + {"0":"a5620db988a639410257","1":"42b894ca482a061570ae","2":"b7d7de62392f41af1f78"}[chunkId] + ".js";
 /******/               var timeout = setTimeout(onScriptComplete, 120000);
 /******/               script.onerror = script.onload = onScriptComplete;
 /******/               function onScriptComplete() {

我们发现在 3 个 entry 入口未改变的情况下,变更某个 entry chunk 的内容,对应 runtime 脚本的变更只是涉及到了 chunk id 的变更。基于 module id 的经验,自然想到了是不是有相应的唯一性内容来取代现有的 chunk id,因为数值型的 chunk id 总会存在不确定性。

所以至此问题又再次被拆分成两个命题:

  • 找到替代现有 chunk id 表达唯一性的方式
  • 找到时机进行 chunk id 订正

chunk id 的不稳定性

接下来我们一个一个看

命题1:找到替代现有 chunk id 表达唯一性的方式

因为我们知道在 webpack 中 entry 其实是具有唯一性的,而 entry chunk 的 name 即来源于我们对 entry 名的设置。所以这里的问题变得很简单我们只需要把每个 chunk 对应的 id 指向到对应 chunk 的 name 即可。

命题2:找到时机进行 chunk id 订正

在整个 webpack 执行过程中涉及 moudle id 的事件有

before-chunk-ids -> optimize-chunk-ids -> after-optimize-chunk-ids

所以我们只需要在 before-chunk-ids 这个时机内进行 chunk id 订正即可。

伪代码:

apply(compiler) {
    compiler.plugin("compilation", (compilation) => {
        compilation.plugin("before-chunk-ids", (chunks) => {
            chunks.forEach((chunk) => {
                if(chunk.id === null) {
                    chunk.id = chunk.name;
                }
            });
        });
    });
}

非常简单。

在 webpack@2 时期作者把这个部分的实现引入到了官方插件,即 NamedChunksPlugin

所以在一般需求下我们只需要在构建配置中添加 NamedChunksPlugin 的插件即可。

...
entry: {
  "pageA": "./a.js",
  "pageB": "./b.js",
  "vendor": [ "lodash" ],
},
...
plugins: [
  new ExtractTextPlugin('[name]-[contenthash].css'),
  new webpack.NamedModulesPlugin(),
+ new webpack.NamedChunksPlugin(),
  new webpack.optimize.CommonsChunkPlugin({
    name: 'vendor',
    minChunks: Infinity
  }),
  new webpack.optimize.CommonsChunkPlugin({
    name: 'runtime'
  }),
],
...

runtime 的 diff

 /******/
 /******/       // objects to store loaded and loading chunks
 /******/       var installedChunks = {
-/******/               3: 0
+/******/               "runtime": 0
 /******/       };
 /******/
 /******/       // The require function
@@ -91,7 +91,7 @@
 /******/               if (__webpack_require__.nc) {
 /******/                       script.setAttribute("nonce", __webpack_require__.nc);
 /******/               }
-/******/               script.src = __webpack_require__.p + "" + chunkId + "-" + {"0":"a5620db988a639410257","1":"42b894ca482a061570ae","2":"b7d7de62392f41af1f78"}[chunkId] + ".js";
+/******/               script.src = __webpack_require__.p + "" + chunkId + "-" + {"vendor":"45cd76029c7d91d6fc76","pageA":"0abd02f11fa4c29e99b3","pageB":"2b8c3672b02ff026db06"}[chunkId] + ".js";
 /******/               var timeout = setTimeout(onScriptComplete, 120000);
 /******/               script.onerror = script.onload = onScriptComplete;
 /******/               function onScriptComplete() {

可以看到标示 chunk 唯一性的 id 值被替换成了我们 entry 入口的名称。非常棒!感觉出岔子的机会又减小了不少。

讨论这个问题的另外一个原因是像 webpack@2 中的 dynamic import 或者 webpack@1 时的 require.ensure 会将代码抽离出来形成一个独立的 bundle,在 webpack 中我们把这种行为叫成 Code Splitting,一旦代码被抽离出来,最终在构建结果中会出现 0.[hash].js 1.[hash].js ,或多或少大家对此都有过困扰。

可以预想的是通过该 plugin 我们能比较好解决这个问题,一方面我们可以尝试定义这些被动态加载的模块的名称,另外一方面我们也可以遇见,假定一个构建场景会生成多个 [chunk-id].[chunkhash].js, 当 Code Splitting 的 chunk 需要变更时,比如减少了一个,此时你没法保证在新一个 compilation 中还继续分配到上一个 compilation 中的 [chunk-id],所以通过 name 命名的方式恰好可以顺带解决这个问题。

只是在这边我们需要稍微对 NamedChunksPlugin 做一些变更。

...
entry: {
  "pageA": "./a.js",
  "pageB": "./b.js",
  "vendor": [ "lodash" ],
},
...
plugins: [
  new ExtractTextPlugin('[name]-[contenthash].css'),
  new webpack.NamedModulesPlugin(),
- new webpack.NamedChunksPlugin(),
+ new webpack.NamedChunksPlugin((chunk) => {
+   if (chunk.name) {
+     return chunk.name;
+   }

+   return chunk.mapModules(m => path.relative(m.context, m.request)).join("_");
+ }),
  new webpack.optimize.CommonsChunkPlugin({
    name: 'vendor',
    minChunks: Infinity
  }),
  new webpack.optimize.CommonsChunkPlugin({
    name: 'runtime'
  }),
],
...

总结

要做到持久化缓存需要做好以下几点:

  1. 对脚本文件应用 [chunkhash] 对 extractTextPlugin 应用的的文件应用 [contenthash]
  2. 使用 CommonsChunkPlugin 合理抽出公共库 vendor(包含社区工具库这些 如 lodash), 如果必要也可以抽取业务公共库 common(公共部分的业务逻辑),以及 webpack的 runtime
  3. 在开发环境下使用 NamedModulesPlugin 来固化 module id,在生产环境下使用 HashedModuleIdsPlugin 来固化 module id
  4. 使用 NamedChunksPlugin 来固化 runtime 内以及在使用动态加载时分离出的 chunk 的 chunk id。
  5. 建议阅读一下全文。