vlambda博客
学习文章列表

浏览器缓存策略与 webpack 持久化缓存

浏览器通过请求头实现缓存,关键的请求头有 cache-control,expires,last-Modified,ETag 等。我们从时间和空间两个角度来看浏览器缓存。

时间

  • 浏览器发送第一次请求:不缓存,服务端根据设定的缓存策略返回相应的 header,如:cache-control,expires,last-Modified,ETag。

  • 浏览器发送第二次请求:

  1. 是否:有 cache-control 且不过期。是的话返回本地磁盘缓存,状态值 200(from cache);否的话

  2. 是否:有 expires 且不过期。是的话返回本地磁盘缓存,状态值 200(from cache);否的话

  3. 是否:有 Etag。是的话请求头添加 If-None-Match,值就是上次返回的 Etag 值,然后发送给服务端。服务端对比 If-None-Match 与现有的 Etag 值是否一样;一样的话只返回 header,状态码 304,浏览器从本地磁盘获取缓存信息;不一样走正常流程,返回 header+body,状态码 200。没有 Etag 的话

  4. 是否:有 last-Modified。是的话添加请求头 If-Modified-Since,值是上次返回的 last-Modified,然后发送给服务端。服务端对比 If-Modified-Since 与现有的是否一样;一样的话返回只返回 header,状态码 304,浏览器从本地磁盘获取缓存信息;不一样走正常流程,返回 header+body,状态码 200。没有 last-Modified 的话:

  5. 没有缓存,正常请求

其中 1,2 阶段称为强缓存策略,它的特点是不需要和服务端通信就决定是否使用缓存,cache-control 优先级大于 expires。3,4 阶段称为协商缓存策略,它的特点是需要和服务端通信决定是否用缓存,Etag 优先级大于 last-Modified。

如图:

空间

  • 浏览器和服务端:服务端需要决定使用哪种缓存策略并在响应头返回;前端不需要设置,是浏览器本身机制。

  • html 和静态资源:通常 html 不设置缓存,因为其它资源的入口都是 html 文件;静态资源(js,css,图片等)会设置缓存

部署时缓存的问题

如果缓存就按理论上设置,那就太简单了。在实际应用有个严重的问题,我们不仅要缓存代码,还需要更新代码。如果静态资源名字不变,怎么让浏览器即能缓存又能在有新代码时更新。最简单的解决方式就是静态资源路径添加一个版本值,版本不变就走缓存策略,版本变了就加载新资源。如下:

<script src="xx/xx.js?v=24334"></script>

然而这种处理方式在部署时有问题,在张云龙这篇精彩的文章中,详细介绍了这些缘由,重点总结如下:

静态资源和页面是分开部署的:

  1. 先部署页面再部署静态资源,会出现用户访问到旧的资源

  2. 先部署静态资源再部署页面,会出现没有缓存用户加载到新资源而报错

这些问题的本质是以上的部署方式是“覆盖式发布”,解决方式是“非覆盖式发布”。即用静态资源的文件摘要信息给文件命名,这样每次更新资源不会覆盖原来的资源,先将资源发布上去。这时候存在两种资源,用户用旧页面访问旧资源,然后再更新页面,用户变成新页面访问新资源,就能做到无缝切换。简单来说就是给静态文件名加 hash 值。

那如何实现呢?现在前端代码都用 webpack 之类的构建工具打包,那么我们看下结合 webpack 该怎么做,怎么才能做到持久化缓存?

webpack 持久化缓存

1. 我们知道 webpack 给文件名添加 hash 值是很简单的,问题是有 hash/chunkhash/contenthash,使用哪个?

先看下官方定义:

  • hash: unique hash generated for every build

  • chunkhash: hashes based on each chunks' content

  • contenthash: hashes generated for extracted content

hash 每次构建都不同(实际上 webpack 当前版本 4.41 在内容不变时每次构建是一样的),且多个文件共用一个值,只要其中一个模块内容变了,所有文件名都变了,不能用于缓存。

chunkhash 也不行,问题出现在每一个 chunk 可能包括第三方库或者提取的 css 文件。比如 css 文件变了,引用它的 js 文件所生成的 chunk 也变了。

contenthash 按照内容生成 hash,是我们想要的。

我们来实验上面的结论,如下所示,有两个入口文件 index 和 bar,

module.exports = {


mode: "none",


entry: {


index: "./src/index.js",


bar: "./src/bar.js"


},


output: {


path: path.resolve(__dirname, "dist"),


filename: "[name].[hash].js"


}


};

验证 hash 的步骤:先编译,再改变 index 内容,再编译。观看两次编译的结果。

浏览器缓存策略与 webpack 持久化缓存

浏览器缓存策略与 webpack 持久化缓存

可以看出 hash 值在同一次编译的不同文件是一样的;只改变 index 文件内容,bar 的值也跟着变了。

注:全篇的内容都是根据以下版本,按照上面这种改变单一变量的方法试验得出,往后结论不一一贴图,各位可自行试验。

"devDependencies": {


"webpack": "^4.41.2",


"webpack-cli": "^3.3.10"


}

2. webpack 打包的文件不仅包括业务代码,还有第三方库和运行时代码,需要将他们提取出来,缓存才不会相互干扰。

module.exports = {


entry: {


index: "./src/index.js",


bar: "./src/bar.js"


},


output: {


filename: "[name].[contenthash].js"


},


optimization: {


splitChunks: {


cacheGroups: {


vendor: {


test: /[\\/]node_modules[\\/]/,


name: "vendors",


chunks: "all"


}


}


},


runtimeChunk: {


name: "manifest"


}


}


};

第三方库提取方式是设置 optimization 的 splitChunks 的 cacheGroups。splitChunks 能提取模块,cacheGroups 能缓存模块,并且 cacheGroups 的配置会覆盖 splitChunks 相同配置,既能提取又能缓存,故只需设置 cacheGroups。

运行时代码的提取方式为配置 runtimeChunk,默认为 false,表示运行时代码嵌入到不同的 chunk 文件中;现在将运行时代码提取出来,并命名为 manifest。

3. moduleName 和 chunkName 对文件的影响

一个文件被分离为 3 个文件。我们多出来了一个问题:文件间怎么相互依赖的,会影响彼此打包吗?

浏览器缓存策略与 webpack 持久化缓存

我们看了下 index 文件编译后的结果,可以看到确实相互依赖,这些数字是 moduleId 或者 chunkId。它们在每一次编译中按数字递增生成,这样有可能我们只是简单添加一个模块,就使得 moduleId 或 chunkId 改变,导致缓存失败。解决方法是将 moduleId 和 chunkId 改成按照文件路径生成。

optimization: {


namedModules: true,


namedChunks: true


},

编译后结果如下:这个是名为 index 的 chunk,它由名为"./src/index.js"这个 module 和名为"manifest"和"vendors"的 chunk 组成。

浏览器缓存策略与 webpack 持久化缓存

通常我们还会进一步设置 moduleIds:

optimization: {


moduleIds: 'hashed',


namedModules: true,


namedChunks: true


},

浏览器缓存策略与 webpack 持久化缓存

这样子 moduleId 在编译后的文件是文件目录的 hash 值,更加安全。这也是 namedChunks 在 production 默认为 false 的原因,不想依赖的文件路径在编译后的文件直接展示,但是为了持久性缓存,这里也只能打开。

补充一下 module/chunk/bundle 区别有助于理解:
  • module:就是 js 模块,

  • chunk:webpack 编译过程中由多个 module 组成的文件

  • bundle:bundle 是 chunk 文件的最终状态,是 webpack 编译后的结果

比如:

详情以及截图来源于https://stackoverflow.com/questions/42523436/what-are-module-chunk-and-bundle-in-webpack

4. css 文件缓存

最后当 css 代码提取成单独文件,当我们改变 css 时,怎么保证不影响引用它的 js 文件呢?配置如下:

plugins: [


new MiniCssExtractPlugin({


filename: "[contenthash].css"


})


]

5 总结:webpack 持久化缓存目标是当且仅当该文件内容变动才改变该文件名字的 hash 值。

所有配置总结如下,每行配置后面都有详细说明:

const MiniCssExtractPlugin = require("mini-css-extract-plugin");


module.exports = {


output: {


filename: [name].[contenthash].js, // 让hash值只在内容变动时更新


chunkFilename: [name].[contenthash].js // 动态引入的模块命名,同上


},


module: {


rules: [


{


test: /\.css$/,


use: [


"loader: MiniCssExtractPlugin.loader", // 提取出来css


"css-loader"


]


}


]


},


optimization: {


moduleIds: "hashed", // 混淆文件路径名


runtimeChunk: { name: 'manifest' }, // 提取runtime代码命名为manifest


namedModules: true, // 让模块id根据路径设置,避免每增加新模块,所有id都改变,造成缓存失效的情况


namedChunks: true, // 避免增加entrypoint,其他文件都缓存失效


cacheGroups: {


vendor: { // 提取第三方库文件


test: /[\\/]node_modules[\\/]/,


name: 'vendors',


chunks: 'all',


},


},


}


plugins: [


new webpack.HashedModuleIdsPlugin(), // 与namedModules: true作用一样


new MiniCssExtractPlugin({


filename: "[contenthash].css", // css文件也是按contenthash命名


chunkFilename: "[contenthash].css", // 动态引入的css命名,同上


})


],


}

总结

浏览器有其缓存机制,想要既能缓存又能在部署时没有问题,需要给静态文件名添加 hash 值。在 webpack 中,有些配置能让我们实现持久化缓存。当然在探索这些属性的过程中,使用单一变量法,一步步测试得出结果,限于篇幅不一一截图。感兴趣同学可自行验证。

参考资料

https://webpack.js.org/guides/caching/#output-filenames

https://www.zhihu.com/question/20790576

https://github.com/amandakelake/blog/issues/41

https://imweb.io/topic/5b6f224a3cb5a02f33c013ba

https://coderlt.coding.me/2016/11/21/web-cache/

https://segmentfault.com/a/1190000015919928#item-3-2

关于奇舞周刊