缓存

接下来继续使用 webpack 打包模块化应用程序。webpack 会在打包后生成可部署的 /dist 目录,并将打包后的内容放在此目录。一旦 /dist 目录中的内容部署到服务器上,客户端(通常是浏览器)就能够访问此服务器以获取站点及其资源。由于获取服务器资源是比较耗费时间的操作,因此浏览器使用了一种名为 缓存 的技术。命中缓存可以降低网络流量,使网站加载速度更快。然而,如果在部署资源的最新版本时没有更改资源的文件名,浏览器可能会认为它没有被更新,从而使用它的缓存版本。由于缓存的存在,当需要获取新的代码时,就会显得很棘手。

这篇指南的重点在于通过必要配置确保 webpack 编译生成的文件能够被客户端缓存;当文件内容变化后,客户端又能够请求到新的文件。

输出文件的文件名

更改 output.filename 中的 substitutions 以定义输出文件的名称。webpack 提供了一种称为 可替换模板字符串(substitution) 的方式,通过带括号字符串来模板化文件名。其中,[contenthash] 将根据资源内容创建唯一哈希值。当资源内容发生变化时,[contenthash] 也会发生变化。

这里使用 起步 中的示例和 管理输出 中的 plugins 插件作为项目基础,所以不必手动维护 index.html 文件:

project

webpack-demo
|- package.json
|- package-lock.json
|- webpack.config.js
|- /dist
|- /src
  |- index.js
|- /node_modules

webpack.config.js

  const path = require('path');
  const HtmlWebpackPlugin = require('html-webpack-plugin');

  module.exports = {
    entry: './src/index.js',
    plugins: [
      new HtmlWebpackPlugin({
-       title: 'Output Management',
+       title: 'Caching',
      }),
    ],
    output: {
-     filename: 'bundle.js',
+     filename: '[name].[contenthash].js',
      path: path.resolve(__dirname, 'dist'),
      clean: true,
    },
  };

调整配置后构建脚本,将会看到像下面一样的输出:

...
                       Asset       Size  Chunks                    Chunk Names
main.7e2c49a622975ebd9b7e.js     544 kB       0  [emitted]  [big]  main
                  index.html  197 bytes          [emitted]
...

可以发现 bundle 的名称是其内容通过哈希的映射。也许会认为,如果不修改原始文件直接再次运行构建,文件名将保持不变。然而事实并非如此,试试再次构建脚本:

...
                       Asset       Size  Chunks                    Chunk Names
main.205199ab45963f6a62ec.js     544 kB       0  [emitted]  [big]  main
                  index.html  197 bytes          [emitted]
...

再次执行构建后发现,尽管没有修改原始文件,bundle 的名称仍然发生了修改。这是因为 webpack 在入口 chunk 中包含了某些引导模板(boilerplate),特别是 runtime 和 manifest。

提取引导模板

正如在 代码分离 中所学到的,SplitChunksPlugin 插件可以用于将模块分离到单独的 bundle 中。webpack 还提供了一个优化功能,可以使用 optimization.runtimeChunk 选项将 runtime 代码拆分为一个单独的 chunk。将其设置为 single 以便为所有 chunk 创建一个 runtime bundle:

webpack.config.js

  const path = require('path');
  const HtmlWebpackPlugin = require('html-webpack-plugin');

  module.exports = {
    entry: './src/index.js',
    plugins: [
      new HtmlWebpackPlugin({
      title: 'Caching',
      }),
    ],
    output: {
      filename: '[name].[contenthash].js',
      path: path.resolve(__dirname, 'dist'),
      clean: true,
    },
+   optimization: {
+     runtimeChunk: 'single',
+   },
  };

更改配置后再次构建,查看提取出来的 runtime bundle:

Hash: 82c9c385607b2150fab2
Version: webpack 4.12.0
Time: 3027ms
                          Asset       Size  Chunks             Chunk Names
runtime.cc17ae2a94ec771e9221.js   1.42 KiB       0  [emitted]  runtime
   main.e81de2cf758ada72f306.js   69.5 KiB       1  [emitted]  main
                     index.html  275 bytes          [emitted]
[1] (webpack)/buildin/module.js 497 bytes {1} [built]
[2] (webpack)/buildin/global.js 489 bytes {1} [built]
[3] ./src/index.js 309 bytes {1} [built]
    + 1 hidden module

由于像 lodashreact 这样的第三方库很少像本地源代码一样频繁修改,因此通常推荐将第三方库提取到单独的 vendor chunk 中。这一步将减少客户端对服务器的请求,同时保证自身代码与服务器一致。可以通过使用 SplitChunksPlugin 示例 2 中演示的 SplitChunksPlugin 插件的 cacheGroups 选项来实现。试试在 optimization.splitChunks 添加如下 cacheGroups 参数并执行构建:

webpack.config.js

  const path = require('path');
  const HtmlWebpackPlugin = require('html-webpack-plugin');

  module.exports = {
    entry: './src/index.js',
    plugins: [
      new HtmlWebpackPlugin({
      title: 'Caching',
      }),
    ],
    output: {
      filename: '[name].[contenthash].js',
      path: path.resolve(__dirname, 'dist'),
      clean: true,
    },
    optimization: {
      runtimeChunk: 'single',
+     splitChunks: {
+       cacheGroups: {
+         vendor: {
+           test: /[\\/]node_modules[\\/]/,
+           name: 'vendors',
+           chunks: 'all',
+         },
+       },
+     },
    },
  };

再次构建,然后查看新的 vendor bundle:

...
                          Asset       Size  Chunks             Chunk Names
runtime.cc17ae2a94ec771e9221.js   1.42 KiB       0  [emitted]  runtime
vendors.a42c3ca0d742766d7a28.js   69.4 KiB       1  [emitted]  vendors
   main.abf44fedb7d11d4312d7.js  240 bytes       2  [emitted]  main
                     index.html  353 bytes          [emitted]
...

现在,可以看到 main 不再含有来自 node_modules 目录的 vendor 代码,并且体积减少到 240 bytes

模块标识符

在项目中再添加一个模块 print.js

project

webpack-demo
|- package.json
|- package-lock.json
|- webpack.config.js
|- /dist
|- /src
  |- index.js
+ |- print.js
|- /node_modules

print.js

+ export default function print(text) {
+   console.log(text);
+ };

src/index.js

  import _ from 'lodash';
+ import Print from './print';

  function component() {
    const element = document.createElement('div');

    // lodash 现在使用 import 引入
    element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+   element.onclick = Print.bind(null, 'Hello webpack!');

    return element;
  }

  document.body.appendChild(component());

通常我们会期望,当再次执行构建后,只有 main bundle 的哈希值会发生变化,然而……

...
                           Asset       Size  Chunks                    Chunk Names
  runtime.1400d5af64fc1b7b3a45.js    5.85 kB      0  [emitted]         runtime
  vendor.a7561fb0e9a071baadb9.js     541 kB       1  [emitted]  [big]  vendor
    main.b746e3eb72875af2caa9.js    1.22 kB       2  [emitted]         main
                      index.html  352 bytes          [emitted]
...

可以发现,三个文件的哈希值都发生了变化。这是因为每个 module.id 会默认基于解析顺序增加。换言之,当解析顺序发生变化,ID 也会随之改变。简要概括便是:

  • main bundle 会随着自身的新增内容的修改而发生变化。
  • vendor bundle 会随着自身的 module.id 的变化而发生变化。
  • manifest runtime 会因为现在包含一个新模块的引用而发生变化。

上面的第一点与最后一点都是符合预期的行为,而 vendor 的哈希值发生变化是我们要修复的。试试将 optimization.moduleIds 设置为 'deterministic'

webpack.config.js

  const path = require('path');
  const HtmlWebpackPlugin = require('html-webpack-plugin');

  module.exports = {
    entry: './src/index.js',
    plugins: [
      new HtmlWebpackPlugin({
        title: 'Caching',
      }),
    ],
    output: {
      filename: '[name].[contenthash].js',
      path: path.resolve(__dirname, 'dist'),
      clean: true,
    },
    optimization: {
+     moduleIds: 'deterministic',
      runtimeChunk: 'single',
      splitChunks: {
        cacheGroups: {
          vendor: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendors',
            chunks: 'all',
          },
        },
      },
    },
  };

现在,不论是否添加任何新的本地依赖,对于前后两次构建,vendor 的哈希值都应保持一致:

...
                          Asset       Size  Chunks             Chunk Names
   main.216e852f60c8829c2289.js  340 bytes       0  [emitted]  main
vendors.55e79e5927a639d21a1b.js   69.5 KiB       1  [emitted]  vendors
runtime.725a1a51ede5ae0cfde0.js   1.42 KiB       2  [emitted]  runtime
                     index.html  353 bytes          [emitted]
Entrypoint main = runtime.725a1a51ede5ae0cfde0.js vendors.55e79e5927a639d21a1b.js main.216e852f60c8829c2289.js
...

接下来修改 src/index.js 临时移除额外的依赖:

src/index.js

  import _ from 'lodash';
- import Print from './print';
+ // import Print from './print';

  function component() {
    const element = document.createElement('div');

    // lodash 现在使用 import 引入
    element.innerHTML = _.join(['Hello', 'webpack'], ' ');
-   element.onclick = Print.bind(null, 'Hello webpack!');
+   // element.onclick = Print.bind(null, 'Hello webpack!');

    return element;
  }

  document.body.appendChild(component());

最后,再次执行构建:

...
                          Asset       Size  Chunks             Chunk Names
   main.ad717f2466ce655fff5c.js  274 bytes       0  [emitted]  main
vendors.55e79e5927a639d21a1b.js   69.5 KiB       1  [emitted]  vendors
runtime.725a1a51ede5ae0cfde0.js   1.42 KiB       2  [emitted]  runtime
                     index.html  353 bytes          [emitted]
Entrypoint main = runtime.725a1a51ede5ae0cfde0.js vendors.55e79e5927a639d21a1b.js main.ad717f2466ce655fff5c.js
...

可以看到,在这两次构建中,vendor bundle 的文件名称都是 55e79e5927a639d21a1b

总结

缓存可能很复杂,但是从应用程序或站点用户可以获得的收益来看,这值得付出努力。想要了解更多信息,请查看下面 延伸阅读 部分。

延伸阅读

12 位贡献者

okonetjouni-kantolaskipjackdannycjonesfadysamirsadekafontcurosavagesaiprasad2595EugeneHlushkoAnayaDesignaholznersnitin315