Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

webpack4可预测持久化缓存方案探索 #6

Open
HexMox opened this issue Aug 30, 2019 · 0 comments
Open

webpack4可预测持久化缓存方案探索 #6

HexMox opened this issue Aug 30, 2019 · 0 comments

Comments

@HexMox
Copy link
Owner

HexMox commented Aug 30, 2019

前段时间发布一个基于webpack的前端工程时,对生成的增量发布文件列表感到迷惑。

假设业务有两个页面A、B,其webpack入口分别为{a,b}.js

本次发布仅对a.js进行了修改,理想的发布的文件列表应该为:

  • a.html
  • a.[hash].js

但实际上却是:

  • a.html
  • b.html
  • a.[hash].js
  • b.[hash].js
  • vendor.[hash].js

迷惑不解,于是花了点时间去探索webpack生成文件哈希值的奥秘。

前言

探索webpack caching策略之前,首先要明白配合HTTP缓存的前端代码部署发布策略。推荐阅读:

大公司里怎样开发和部署前端代码?

读完后就会明白,现阶段比较成熟的持久化缓存方案就是使静态资源的文件名包含其内容的hash值,并在静态资源服务器配置HTTP缓存规则。基于此可用做到增量发布及很好地利用HTTP缓存能力。

那么webpack提供了什么配置来影响生成的文件名哈希值呢?

output.filename

output.filename可以指定构建结果输出的文件名,其中提供了三个关于哈希值的占位符:

  • [hash]:The hash of the module identifier
    基于每次构建的compilation对象的所有内容计算而来
  • [chunkhash]:The hash of the chunk content
    基于每一个chunk根据自身的内容计算而来
  • [contenthash]:The hash of the content of a file, which is different for each asset
    基于每一个生成文件(asset)内容计算而来

Tips:
可以通过[hash:16]指定哈希值的长度
可以通过output.hashDigestLength指定默认哈希值的长度

补充:
可能有读者不理解什么是chunk, asset, module,请查看官方文档

那么问题来了:

  • 生成环境中究竟使用哪一个占位符呢?
  • CommonChunk/Dynamic Import技术会怎么影响文件hash值
  • 不同级别(业务、依赖)代码的不同操作(新增、修改、删除)会怎么影响hash值以及它们是合理的吗?
  • 如若不合理怎么将hash值的变动降到最小呢?

接下来以一个典型的实践Demo来探索下:

文中所有demo都可以在Github地址找到,基于:

  • webpack v4.25.1
  • Node v8.16.0
  • Mac OS 10.14.5

场景及实践

以下demo基于常见的代码拆包优化,它会

  • 将node_modules依赖包抽离成一个包vendor.xxx.js
  • 将2个entry以上依赖的包抽离成一个包common.xxx.js

配置为:

// webpack.config.js
// ...
  optimization: {
    splitChunks: {
      cacheGroups: {
        vendor: {
          test: /node_modules/,
          chunks: 'all',
          name: 'vendor'
        },
        common: {
          test: /.js$/,
          name: 'common',
          minChunks: 2
        }
      }
    }
  }
// ...

[hash]

假设目前仅有一个页面入口a.js,使用[hash]的占位符进行打包输出:

// a.js
import 'lodash';
// webpack.config.js
module.exports = {
  entry: {
    a: './src/pages/a.js'
  },
  output: {
    filename: '[name].[hash].js'
    // ...
  },
};

打包结果为:

                         Asset      Size  Chunks             Chunk Names
     a.ce71fc0e3be0265f3764.js  1.47 KiB       0  [emitted]  a
vendor.ce71fc0e3be0265f3764.js  69.8 KiB       1  [emitted]  vendor

很奇怪,vendora文件的hash值竟然是一致的,当我随意在a.js加上一行代码后,vendor的文件名也随之变化了,无法有效利用HTTP缓存,这显然不是我们想要的。

[chunkhash]

为了使vendora能拥有不同的hash值,我们将[hash]改为[chunkhash]使之文件hash值根据自身chunk来计算。

// webpack.config.js
module.exports = {
  output: {
    filename: '[name].[chunkhash].js'
    // ...
  },
};

运行结果为:

                         Asset      Size  Chunks             Chunk Names
     a.b4a6f84d92c2cd8e68e2.js  1.47 KiB       0  [emitted]  a
vendor.1f01c1b51cfa1b8560ea.js  69.8 KiB       1  [emitted]  vendor

效果不错,如果任意修改a.js,那么vendor文件应该不会改变,然而事实却不是这样。
如果对a.js模块的修改,导致原有抽离到vendor的modules的id有所变化的话:

// a.js
import '../common/util';
import 'lodash';
                         Asset      Size  Chunks             Chunk Names
     a.c90b7ef837be6067c4db.js  1.51 KiB       0  [emitted]  a
vendor.67cb27b8f3bcb258a6b8.js  69.8 KiB       1  [emitted]  vendor

其实对比前后的vendor结果只是webpack生成的runtime代码(也就是module的id)轻微的不一致而已:

image

image

关于webpack runtime的细节可以自行google

optimization.runtimeChunk

为了解决runtime的影响,我们根据webpack的caching文档中指出可以使用使用配置抽离runtime代码:

// webpack.config.js
// ...
  optimization: {
    runtimeChunk: 'single',
// ...
                          Asset       Size  Chunks             Chunk Names
      a.025a679cc53a17ae5e20.js  112 bytes       0  [emitted]  a
runtime.761a672aee47740d7b60.js   1.42 KiB       1  [emitted]  runtime
 vendor.e0a3e1f04b6066968af9.js   69.8 KiB       2  [emitted]  vendor

重复在a.js添加util依赖后,再次编译结果为:

                          Asset       Size  Chunks             Chunk Names
      a.757fd6c1c697ab9fa7ea.js  152 bytes       0  [emitted]  a
runtime.761a672aee47740d7b60.js   1.42 KiB       1  [emitted]  runtime
 vendor.42d139ff34a38fa36e79.js   69.8 KiB       2  [emitted]  vendor

很遗憾,看了下runtime.xxx.js的内容,只是将runtime顶部的代码进行了抽离而已(所以前后都没有变化),modules的id变化还是使得vendor的hash值发生了变化。

optimization.moduleIds

解决方案可以通过optimization.moduleIds配置来告诉webpack使用文件路径的hash值来作为module的id,而不是自增的id。这样将vendor的相关的id固定下来,就不会改变hash值了。

// webpack.config.js
// ...
  optimization: {
    moduleIds: 'hashed',
// ...
                          Asset       Size  Chunks             Chunk Names
      a.39f663c39a00b0a0d3ed.js  127 bytes       0  [emitted]  a
runtime.761a672aee47740d7b60.js   1.42 KiB       1  [emitted]  runtime
 vendor.43f6930938e9626aee08.js   69.8 KiB       2  [emitted]  vendor

重复在a.js添加util依赖后,再次编译结果为:

                          Asset       Size  Chunks             Chunk Names
      a.0fea31ce608e3a986832.js  177 bytes       0  [emitted]  a
runtime.761a672aee47740d7b60.js   1.42 KiB       1  [emitted]  runtime
 vendor.43f6930938e9626aee08.js   69.8 KiB       2  [emitted]  vendor

到此,js文件hash值的行为已经符合我们的预期了。但真实的项目往往会更为复杂,接下来我们增加CSS模块。

处理CSS

按照官方指引我们添加了CSS的处理:

// webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
// ...
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          {
            loader: MiniCssExtractPlugin.loader,
          },
          'css-loader',
        ],
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      // 注意这里为contenthash,因为官方文档说明了其他两个是不管用的
      filename: '[name].[contenthash].css'
    }),
  ]
// ...

先在上一次基础上打包,结果为:

                          Asset       Size  Chunks             Chunk Names
      a.4f71dbbe92135e074143.js  177 bytes       0  [emitted]  a
runtime.526e924c24f031d6c058.js   1.42 KiB       1  [emitted]  runtime
 vendor.43f6930938e9626aee08.js   69.8 KiB       2  [emitted]  vendor

对比了a.4f71dbbe92135e074143.jsa.0fea31ce608e3a986832.jsruntime.526e924c24f031d6c058.jsruntime.761a672aee47740d7b60.js
可以发现在打包内容完全不变的情况下,改变了webpack配置也会使chunkhash改变(猜测a与runtime chunk的某些配置属性变化了,后续可以更加深入chunkhash的生成原理,这里对于实际场景来说,项目稳定后改变构建的场景是非常少的)。

然后我们简单的添加a.css并在a.js依赖进来:

// a.css
* {
  margin: 0;
  padding: 0;
}
// a.js
// ...
import './a.css';

打包后结果为:

                          Asset       Size  Chunks             Chunk Names
      a.a2e96b7b9cacef0a192b.js  212 bytes       0  [emitted]  a
     a.ad59b1cfef38946db95f.css   33 bytes       0  [emitted]  a
runtime.526e924c24f031d6c058.js   1.42 KiB       1  [emitted]  runtime
 vendor.43f6930938e9626aee08.js   69.8 KiB       2  [emitted]  vendor

现在我们将a.css作些简单改变,再打包一次,预期只有a.css的hash值会改变,结果a.js的hash值(因为chunkhash是基于整个chunk的内容的)也变了:

                          Asset       Size  Chunks             Chunk Names
     a.d8e08571019679820f98.css   63 bytes       0  [emitted]  a
      a.fd7d396d5654ca01204b.js  212 bytes       0  [emitted]  a
runtime.526e924c24f031d6c058.js   1.42 KiB       1  [emitted]  runtime
 vendor.43f6930938e9626aee08.js   69.8 KiB       2  [emitted]  vendor

这是我们不希望看到的,官方的issue提供了解决方案。

[contenthash]

将webpack配置稍微修改下:

// webpack.config.js
// ...
  output: {
    filename: '[name].[contenthash].js',
// ...

更改a.css的前后两次的运行结果对比为:

                          Asset       Size  Chunks             Chunk Names
      a.0d13a673fbf7e0fb82c9.js  212 bytes       0  [emitted]  a
     a.a99fa96ecdccdf2c269e.css   34 bytes       0  [emitted]  a
runtime.ffaf452731b773b5b33e.js   1.42 KiB       1  [emitted]  runtime
 vendor.94725a7abb26f12e6126.js   69.8 KiB       2  [emitted]  vendor
                          Asset       Size  Chunks             Chunk Names
      a.0d13a673fbf7e0fb82c9.js  212 bytes       0  [emitted]  a
     a.d8e08571019679820f98.css   63 bytes       0  [emitted]  a
runtime.ffaf452731b773b5b33e.js   1.42 KiB       1  [emitted]  runtime
 vendor.94725a7abb26f12e6126.js   69.8 KiB       2  [emitted]  vendor

只改变了a.css的hash值,完美。

增加页面入口

接下来我们新增一个页面入口b.js,来探索下页面之间会不会有影响:

// webpack.config.js
// ...
  entry: {
    // ...
    b: './src/pages/b.js',
  },
  optimization: {
    // ...
    splitChunks: {
      minSize: 1, // 为了cacheGroups.common可拆包成功
      cacheGroups: {
        vendor: {
          // ...
          priority: 2,
        },
        common: {
          test: /\.js$/,
          chunks: 'all',
          name: 'common',
          priority: 1,
        }
      }
    }
  },
// ...

这里新增了一个公共的chunk common,尽可能贴近生产环境。尝试不同模块的修改都符合预期,很棒:

  • 修改a.css -> 仅变化a.xxx.css
  • 修改公用的common/util.js -> 仅变化common.xxx.js
  • 修改b.js(引入多一个模块) -> 仅变化b.xxx.js

增加异步加载

当页面功能变得越来越复杂臃肿时,特别是SPA,我们常常会通过dynamic import的方式,拆分异步模块来解决:

// a.js
// ...
import('../async/a.async').then((a) => a.log());
// a.async.js
export const log = () => console.log('a async')

对比前后两次的构建结果,发现变更的不仅是a.js的hash值,runtime.xxx.js的hash值也因为新增了部分异步加载的runtime代码变化了:

                          Asset       Size  Chunks             Chunk Names
     a.a99fa96ecdccdf2c269e.css   34 bytes       3  [emitted]  a
      a.f27987a0484118d434b9.js  174 bytes       3  [emitted]  a
     b.a99fa96ecdccdf2c269e.css   34 bytes       4  [emitted]  b
      b.eb62980a9e5aa5b97832.js  226 bytes       4  [emitted]  b
 common.584c299f230dbafc55e8.js  100 bytes       0  [emitted]  common
runtime.ffaf452731b773b5b33e.js   1.42 KiB       1  [emitted]  runtime
 vendor.94725a7abb26f12e6126.js   69.8 KiB       2  [emitted]  vendor
                          Asset       Size  Chunks             Chunk Names
      5.a8b36218e4d816632f48.js  171 bytes       5  [emitted]  
      a.a21c9b5ef7c674164ee4.js  224 bytes       3  [emitted]  a
     a.a99fa96ecdccdf2c269e.css   34 bytes       3  [emitted]  a
     b.a99fa96ecdccdf2c269e.css   34 bytes       4  [emitted]  b
      b.eb62980a9e5aa5b97832.js  226 bytes       4  [emitted]  b
 common.584c299f230dbafc55e8.js  100 bytes       0  [emitted]  common
runtime.0eba89b4f3878dd2ec40.js   2.18 KiB       1  [emitted]  runtime
 vendor.94725a7abb26f12e6126.js   69.8 KiB       2  [emitted]  vendor

同样的在a.js中再增加一个a2.async.js,结果runtime.xxx.js还是因为新增了chunk信息变更了:

                          Asset       Size  Chunks             Chunk Names
      5.a8b36218e4d816632f48.js  171 bytes       5  [emitted]  
      6.17a8c61193ab8db1915d.js  172 bytes       6  [emitted]  
     a.a99fa96ecdccdf2c269e.css   34 bytes       3  [emitted]  a
      a.cf4d753b92b883744875.js  274 bytes       3  [emitted]  a
     b.a99fa96ecdccdf2c269e.css   34 bytes       4  [emitted]  b
      b.eb62980a9e5aa5b97832.js  226 bytes       4  [emitted]  b
 common.584c299f230dbafc55e8.js  100 bytes       0  [emitted]  common
runtime.6b834e964b97efa45c8d.js   2.21 KiB       1  [emitted]  runtime
 vendor.94725a7abb26f12e6126.js   69.8 KiB       2  [emitted]  vendor

image

这种页面A的改动引起页面B需要加载变更的runtime的方式不科学。实际上更改起来也很简单,只需要在配置即可:

// webpack.config.js
// ...
optimization: {
    runtimeChunk: true,
// ...

改动之后验证就没有问题了。更改了hash的只有a.xxx.jsruntime~a.xxx.js
但细心的同学可能发现了,异步加载的chunk文件名又变成了自增id的模式,尽管我尝试将配置改为:

// webpack.config.js
// ...
optimization: {
    // ...
    chunkIds: 'named',
// ...

也没有作用。因为webpack.NamedChunkPlugin只处理那些有名字的chunk,而异步加载的chunk是默认没有名字的。解决方式有两种:

  1. 自定制webpack.NamedChunkPlugin
// webpack.config.js
// ...
  plugins: [
    new webpack.NamedChunksPlugin((chunk) => {
      if (chunk.name) { 
        return chunk.name; 
      } 
      return [...chunk._modules].map(m => path.relative(m.context, m.request)).join("_"); 
    }),
// ...
  1. 使用magic comment自己标识异步模块的name
// a.js
// ...
import(/* webpackChunkName: "a_async" */'../async/a.async').then((a) => a.log());
import(/* webpackChunkName: "a2_async" */'../async/a2.async').then((a2) => a2.log());

至此已经基本实现webpack4的可预测持久化缓存了,Perfect!

总结

本文探索了基于webpack4的长效缓存实践经验,对于更新迭代频繁的实际业务来说,运用这些优化经验能节省不少的网络流量成本开支,且用户能获得更好的体验。

  • output.filename使用[contenthash]占位符
  • CSS Extract的插件配置文件名使用[contenthash]占位符
  • optimization.runtimeChunk置为true为每一个入口抽离runtime chunk
  • optimization.moduleIds置为hashed稳定module Id防止影响到公共chunk
  • 定制webpack.NamedChunkPlugin插件稳定异步module Id防止异步chunk互相影响

参考链接

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant