缓存

本指南继续沿用 起步管理输出代码分离 中的代码示例。

以上,我们使用 webpack 来打包模块化的应用程序,webpack 会生成一个可部署的 /dist 目录,然后把打包后的内容放置在此目录中。只要 /dist 目录中的内容部署到 server 上,client(通常是浏览器)就能够访问网站此 server 的网站及其资源。而最后一步获取资源是比较耗费时间的,这就是为什么浏览器使用一种名为 缓存 的技术。可以通过命中缓存,以降低网络流量,使网站加载速度更快,然而,如果我们在部署新版本时不更改资源的文件名,浏览器可能会认为它没有被更新,就会使用它的缓存版本。由于缓存的存在,当你需要获取新的代码时,就会显得很棘手。

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

输出文件的文件名(output filename)

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

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

project

  1. webpack-demo
  2. |- package.json
  3. |- webpack.config.js
  4. |- /dist
  5. |- /src
  6. |- index.js
  7. |- /node_modules

webpack.config.js

  1. const path = require('path');
  2. const CleanWebpackPlugin = require('clean-webpack-plugin');
  3. const HtmlWebpackPlugin = require('html-webpack-plugin');
  4. module.exports = {
  5. entry: './src/index.js',
  6. plugins: [
  7. new CleanWebpackPlugin(['dist']),
  8. new HtmlWebpackPlugin({
  9. - title: 'Output Management'
  10. + title: 'Caching'
  11. })
  12. ],
  13. output: {
  14. - filename: 'bundle.js',
  15. + filename: '[name].[contenthash].js',
  16. path: path.resolve(__dirname, 'dist')
  17. }
  18. };

使用此配置,然后运行我们的 build script npm run build,产生以下输出:

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

可以看到,bundle 的名称是它内容(通过 hash)的映射。如果我们不做修改,然后再次运行构建,我们以为文件名会保持不变。然而,如果我们真的运行,可能会发现情况并非如此 - 文件名发生变化:

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

这也是因为 webpack 在入口 chunk 中,包含了某些 boilerplate(引导模板),特别是 runtime 和 manifest。(译注:boilerplate 指 webpack 运行时的引导代码)

输出可能会因当前的 webpack 版本而稍有差异。与旧版本相比,新版本不一定有完全相同的问题,但我们仍然推荐的以下步骤,确保结果可靠。

提取引导模板(extracting boilerplate)

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

webpack.config.js

  1. const path = require('path');
  2. const CleanWebpackPlugin = require('clean-webpack-plugin');
  3. const HtmlWebpackPlugin = require('html-webpack-plugin');
  4. module.exports = {
  5. entry: './src/index.js',
  6. plugins: [
  7. new CleanWebpackPlugin(['dist']),
  8. new HtmlWebpackPlugin({
  9. title: 'Caching'
  10. ],
  11. output: {
  12. filename: '[name].[contenthash].js',
  13. path: path.resolve(__dirname, 'dist')
  14. },
  15. + optimization: {
  16. + runtimeChunk: 'single'
  17. + }
  18. };

再次构建,然后查看提取出来的 runtime bundle:

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

将第三方库(library)(例如 lodashreact)提取到单独的 vendor chunk 文件中,是比较推荐的做法,这是因为,它们很少像本地的源代码那样频繁修改。因此通过实现以上步骤,利用 client 的长效缓存机制,命中缓存来消除请求,并减少向 server 获取资源,同时还能保证 client 代码和 server 代码版本一致。 这可以通过使用 SplitChunksPlugin 示例 2 中演示的 SplitChunksPlugin 插件的 cacheGroups 选项来实现。我们在 optimization.splitChunks 添加如下 cacheGroups 参数并构建:

webpack.config.js

  1. var path = require('path');
  2. const CleanWebpackPlugin = require('clean-webpack-plugin');
  3. const HtmlWebpackPlugin = require('html-webpack-plugin');
  4. module.exports = {
  5. entry: './src/index.js',
  6. plugins: [
  7. new CleanWebpackPlugin(['dist']),
  8. new HtmlWebpackPlugin({
  9. title: 'Caching'
  10. }),
  11. ],
  12. output: {
  13. filename: '[name].[contenthash].js',
  14. path: path.resolve(__dirname, 'dist')
  15. },
  16. optimization: {
  17. - runtimeChunk: 'single'
  18. + runtimeChunk: 'single',
  19. + splitChunks: {
  20. + cacheGroups: {
  21. + vendor: {
  22. + test: /[\\/]node_modules[\\/]/,
  23. + name: 'vendors',
  24. + chunks: 'all'
  25. + }
  26. + }
  27. + }
  28. }
  29. };

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

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

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

模块标识符(module identifier)

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

project

  1. webpack-demo
  2. |- package.json
  3. |- webpack.config.js
  4. |- /dist
  5. |- /src
  6. |- index.js
  7. + |- print.js
  8. |- /node_modules

print.js

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

src/index.js

  1. import _ from 'lodash';
  2. + import Print from './print';
  3. function component() {
  4. var element = document.createElement('div');
  5. // lodash 是由当前 script 脚本 import 进来的
  6. element.innerHTML = _.join(['Hello', 'webpack'], ' ');
  7. + element.onclick = Print.bind(null, 'Hello webpack!');
  8. return element;
  9. }
  10. document.body.appendChild(component());

再次运行构建,然后我们期望的是,只有 main bundle 的 hash 发生变化,然而……

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

……我们可以看到这三个文件的 hash 都变化了。这是因为每个 module.id 会默认地基于解析顺序(resolve order)进行增量。也就是说,当解析顺序发生变化,ID 也会随之改变。因此,简要概括:

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

第一个和最后一个都是符合预期的行为 - 而 vendor hash 发生变化是我们要修复的。幸运的是,可以使用两个插件来解决这个问题。第一个插件是 NamedModulesPlugin,将使用模块的路径,而不是一个数字 identifier。虽然此插件有助于在开发环境下产生更加可读的输出结果,然而其执行时间会有些长。第二个选择是使用 HashedModuleIdsPlugin,推荐用于生产环境构建:

webpack.config.js

  1. const path = require('path');
  2. + const webpack = require('webpack');
  3. const CleanWebpackPlugin = require('clean-webpack-plugin');
  4. const HtmlWebpackPlugin = require('html-webpack-plugin');
  5. module.exports = {
  6. entry: './src/index.js',
  7. plugins: [
  8. new CleanWebpackPlugin(['dist']),
  9. new HtmlWebpackPlugin({
  10. title: 'Caching'
  11. }),
  12. + new webpack.HashedModuleIdsPlugin()
  13. ],
  14. output: {
  15. filename: '[name].[contenthash].js',
  16. path: path.resolve(__dirname, 'dist')
  17. },
  18. optimization: {
  19. runtimeChunk: 'single',
  20. splitChunks: {
  21. cacheGroups: {
  22. vendor: {
  23. test: /[\\/]node_modules[\\/]/,
  24. name: 'vendors',
  25. chunks: 'all'
  26. }
  27. }
  28. }
  29. }
  30. };

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

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

然后,修改 src/index.js,临时移除额外的依赖:

src/index.js

  1. import _ from 'lodash';
  2. - import Print from './print';
  3. + // import Print from './print';
  4. function component() {
  5. var element = document.createElement('div');
  6. // lodash 是由当前 script 脚本 import 进来的
  7. element.innerHTML = _.join(['Hello', 'webpack'], ' ');
  8. - element.onclick = Print.bind(null, 'Hello webpack!');
  9. + // element.onclick = Print.bind(null, 'Hello webpack!');
  10. return element;
  11. }
  12. document.body.appendChild(component());

最后,再次运行我们的构建:

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

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

结论

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


进一步阅读


贡献人员

EugeneHlushko EugeneHlushko afontcu afontcu dannycjones dannycjones fadysamirsadek fadysamirsadek jouni-kantola jouni-kantola okonet okonet rosavage rosavage saiprasad2595 saiprasad2595 skipjack skipjack