4

前言

如何基于webpack做持久化缓存目前感觉是一直没有一个非常好的方案来实践。网上的文章非常多,但是真的有用的非常少,并没有一些真正深入研究和总结的文章。现在依托于于早教宝线上项目和自己的实践,有了一个完整的方案。

正文

1、webpack的hash的两种计算方式

想要做持久化缓存那么就要依赖 webpack 自身提供的两个 hashhashchunkhash

接着就来看看这两个值之间的具体含义和差别吧:

hash: webpack在每一次构建的时候都会产生一个compilation对象,这个hash值就是根据compilation内所有的内容计算而来的值。

chunkhash:这个值是根据每个chunk的内容而计算出来的值。

所以单纯根据上面的描述来说,chunkhash是用来做持久化缓存最有效的。

2、hash和chunkhash的测试

entry 入口文件 入口依赖
pageA a.js a.less->a.css, common.js->common.css
pageB b.js b.less->b.css, common.js->common.css
  • 使用hash计算
const path = require('path')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
module.exports = {
  entry: {
    pageA: './src/a.js',
    pageB: './src/b.js'
  },
  output: {
    filename: '[name]-[hash].js',
    path: path.resolve(__dirname, 'dist')
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ExtractTextPlugin.extract({
          fallback: 'style-loader',
          use: ['css-loader?minimize']
        })
      }
    ]
  },
  plugins: [new ExtractTextPlugin('[name]-[hash].css')]
}

构建结果

Hash: 80c922b349f516e79fb5
Version: webpack 3.8.1
Time: 1014ms
                         Asset      Size  Chunks             Chunk Names
pageB-80c922b349f516e79fb5.js   2.86 kB       0  [emitted]  pageB
pageA-80c922b349f516e79fb5.js   2.84 kB       1  [emitted]  pageA
pageA-80c922b349f516e79fb5.css  21 bytes       1  [emitted]  pageA
pageB-80c922b349f516e79fb5.css  21 bytes       0  [emitted]  pageB

结论

可以发现所有文件的hash全部都是一样的,但是你多构建几次产生的hash都是不一样的。原因在于我们使用了 ExtractTextPluginExtractTextPlugin 本身涉及到异步的抽取流程,所以在生成 assets 资源时存在了不确定性(先后顺序),而 updateHash 则对其敏感,所以就出现了如上所说的 hash 异动的情况。另外所有 assets 资源的 hash 值保持一致,这对于所有资源的持久化缓存来说并没有深远的意义。

  • 使用chunkhash计算
const path = require('path')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
module.exports = {
  entry: {
    pageA: './src/a.js',
    pageB: './src/b.js'
  },
  output: {
    filename: '[name]-[chunkhash].js',
    path: path.resolve(__dirname, 'dist')
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: ExtractTextPlugin.extract({
          fallback: 'style-loader',
          use: ['css-loader?minimize']
        })
      }
    ]
  },
  plugins: [new ExtractTextPlugin('[name]-[chunkhash].css')]
}

构建结果

Hash: 810904f973cc0cf41992
Version: webpack 3.8.1
Time: 1038ms
                         Asset      Size  Chunks             Chunk Names
pageB-e9ed5150262ba39827d4.js   2.86 kB       0  [emitted]  pageB
pageA-3a2e5ef3d4506fce8d93.js   2.84 kB       1  [emitted]  pageA
pageA-3a2e5ef3d4506fce8d93.css  21 bytes       1  [emitted]  pageA
pageB-e9ed5150262ba39827d4.css  21 bytes       0  [emitted]  pageB

结论

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

3、探索css文件的hash和入口文件hash之间的关系

在上面的构建结果中,我们发现css的hash值和入口文件的hash值是一样的,这里我们容易产生疑问,是不是这两个文件之间一定会有联系呢?呆着疑问去修改下b.css文件中的内容,产生构建结果:

Hash: 3d95035f096f3ca08761
Version: webpack 3.8.1
Time: 1028ms
                         Asset      Size  Chunks             Chunk Names
pageB-e9ed5150262ba39827d4.js   2.86 kB       0  [emitted]  pageB
pageA-3a2e5ef3d4506fce8d93.js   2.84 kB       1  [emitted]  pageA
pageA-3a2e5ef3d4506fce8d93.css  21 bytes       1  [emitted]  pageA
pageB-e9ed5150262ba39827d4.css  41 bytes       0  [emitted]  pageB

纳尼???改动css文件内容,为什么css文件的hash没有改变呢?不科学啊,入口文件的hash也没有改变。仔细想了一下 webpack 是将所有的内容都认为是js文件的一部分。在构建的过程中使用 ExtractTextPlugin 将样式抽离出entry chunk 了,而此时的 entry chunk 本身并没有发生改变,改变的是已经被抽离出去的css部分。而chunkunhash 却是根据 chunk 计算出来的,所以不变更应该是正常的。但是这个又不符合我们想要做的持久化缓存的要求,因为又变动就应该改变hash才是。

开心的是 ExtractTextPlugin 插件为我们提供了一个contenthash来变化:

  plugins: [new ExtractTextPlugin('[name]-[contenthash].css')]

修改b.css前后两次构建结果:

Hash: 3d95035f096f3ca08761
Version: webpack 3.8.1
Time: 1091ms
                     Asset      Size  Chunks             Chunk Names
pageB-e9ed5150262ba39827d4.js   2.86 kB       0  [emitted]  pageB
pageA-3a2e5ef3d4506fce8d93.js   2.84 kB       1  [emitted]  pageA
pageA-9783744431577cdcfea658734b7db20f.css  21 bytes       1  [emitted]  pageA
pageB-2d03aa12ae45c64dedd7f66bb88dd3db.css  41 bytes       0  [emitted]  pageB
Hash: 7a96bcf1ef668a49c9d8
Version: webpack 3.8.1
Time: 1193ms
                     Asset      Size  Chunks             Chunk Names
pageB-e9ed5150262ba39827d4.js   2.86 kB       0  [emitted]  pageB
pageA-3a2e5ef3d4506fce8d93.js   2.84 kB       1  [emitted]  pageA
pageA-9783744431577cdcfea658734b7db20f.css  21 bytes       1  [emitted]  pageA
pageB-7e05e00e24f795b674df5701f6a38bd9.css  42 bytes       0  [emitted]  pageB

对比发现修改了样式文件后只有样式文件的hash发生了改变,符合我们想要的预期。

4、module id的不可控和修正

经过上面的测试,我们理所当然的认为我完成了持久化缓存的hash稳定。然后我们不小心删除了a.js中的a.less文件,然后前后两次构建:

Hash: 88ab71080c53db9d9f70
Version: webpack 3.8.1
Time: 1279ms
                                     Asset       Size  Chunks             Chunk Names
             pageB-a2d1e1d73336f17e2dc4.js    3.82 kB       0  [emitted]  pageB
             pageA-96c9f5afea30e7e09628.js     3.8 kB       1  [emitted]  pageA
pageA-d7ac82de795ddf50c9df43291d77b4c8.css   92 bytes       1  [emitted]  pageA
pageB-56185455ea60f01155a65497e9bf6c85.css  108 bytes       0  [emitted]  pageB
Hash: 172153ea2b39c2046a92
Version: webpack 3.8.1
Time: 1260ms
                                     Asset       Size  Chunks             Chunk Names
             pageB-884da67fe2322246ab28.js    3.81 kB       0  [emitted]  pageB
             pageA-4c0dfb634722c556ffa0.js    3.68 kB       1  [emitted]  pageA
pageA-35be2c21107ce4016c324daaa1dd5e28.css   49 bytes       1  [emitted]  pageA
pageB-56185455ea60f01155a65497e9bf6c85.css  108 bytes       0  [emitted]  pageB

奇怪的事产生了,我移除了a.less文件后发现pageB入口文件的hash都改变了。如果只有pageA相关的文件hash变了我还可以理解。但是????为什么都变了???不行我得看看为什么都变了。
image
通过上面的diff发现我们移除了a.less后整体的id发生了改变了。那么这个地方的id我们可以推测是代表的是具体的引用的模块。

接着我们在看看前后两次构建模块的信息:

[3] ./src/a.js 284 bytes {1} [built]
[4] ./src/a.less 41 bytes {1} [built]
[5] ./src/b.js 284 bytes {0} [built]
[6] ./src/b.less 41 bytes {0} [built]
[3] ./src/a.js 264 bytes {1} [built]
[4] ./src/b.js 284 bytes {0} [built]
[5] ./src/b.less 41 bytes {0} [built]

通过对比发现前面的序号在构建出来的pageB中有隐藏pageA相关的信息,这对于我们来做持久化缓存来说是非常不便的。我们期待的是pageB中只包含和自身相关的信息,不包含其他与自身无关的信息。

5、module id的变化

排除与己不相关的module id或者内容

会用webpack的人大概都之都一个特性:Code Splitting,本质上是对 chunk 进行拆分再组合的过程。具体要怎么做呢?

The answer is CommonsChunkPlugin,在plugin中添加:

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

接下来在看看移除pageA中的a.less的前后变化:

Hash: 697b36118920d991364a
Version: webpack 3.8.1
Time: 1488ms
                                       Asset       Size  Chunks             Chunk Names
               pageB-9b2eb6768499c911a728.js  491 bytes       0  [emitted]  pageB
               pageA-c342383ca09604e8e7b8.js  495 bytes       1  [emitted]  pageA
             runtime-b6ec3c0d350aef6cbf3e.js     6.8 kB       2  [emitted]  runtime
  pageA-b812cf5b72744af29181f642fe4dbf38.css   43 bytes       1  [emitted]  pageA
  pageB-af8f1e92fd031bd1d1d8db5390b5d0d5.css   59 bytes       0  [emitted]  pageB
runtime-35be2c21107ce4016c324daaa1dd5e28.css   49 bytes       2  [emitted]  runtime
Hash: 7ddaf109d5aa67c43ce2
Version: webpack 3.8.1
Time: 1793ms
                                       Asset       Size  Chunks             Chunk Names
               pageB-613cc5a6a90adfb635f4.js  491 bytes       0  [emitted]  pageB
               pageA-0b72f85fda69a9442076.js  375 bytes       1  [emitted]  pageA
             runtime-a41b8b8bfe7ec70fd058.js    6.79 kB       2  [emitted]  runtime
  pageB-af8f1e92fd031bd1d1d8db5390b5d0d5.css   59 bytes       0  [emitted]  pageB
runtime-35be2c21107ce4016c324daaa1dd5e28.css   49 bytes       2  [emitted]  runtime

接着在看看两次构建中pageB的对比:
image

经过对比我们发现在pageB中只包含的是自身相关的内容。所以使用CommonsChunkPlugin达到了我们的期望。而抽离出去的代码就是webpack的运行时代码。运行时代码也存储着webpack对module和chunk相关的信息。另外我们发现pageA和pageB的文件大小也发生了变化。导致这个变化的原因是CommonsChunkPlugin会默认的把entry chunk都包含的module抽取到我们取名为runtime的normal chunk中去。

假如我们在开发中每个页面都会用到一些工具库,例如lodash这类的。由于CommonsChunkPlugin的默认行为会抽取公共部分,可能lodash并没有发生改变,但是被抽离在运行时代码中的时候,每次都是会去请求新的。这不能达到我们要求的最小更新原则。所以我们要人工去干预一些代码。

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

在次对边前后两次构建的日志:

Hash: a703a57c828ec32b24e1
Version: webpack 3.8.1
Time: 1493ms
                                     Asset       Size  Chunks                    Chunk Names
            vendor-f11f58b8150930590a10.js     541 kB       0  [emitted]  [big]  vendor
             pageB-7d065cd319176f44c605.js  938 bytes       1  [emitted]         pageB
             pageA-2b7e3707314e7ec4d770.js  910 bytes       2  [emitted]         pageA
           runtime-e68dec8bcad8a5870f0c.js    5.88 kB       3  [emitted]         runtime
pageA-d7ac82de795ddf50c9df43291d77b4c8.css   92 bytes       2  [emitted]         pageA
pageB-56185455ea60f01155a65497e9bf6c85.css  108 bytes       1  [emitted]         pageB
Hash: 26fc9ad18554b28cd8e1
Version: webpack 3.8.1
Time: 1806ms
                                     Asset       Size  Chunks                    Chunk Names
            vendor-d9bad56677b04b803651.js     541 kB       0  [emitted]  [big]  vendor
             pageB-a55dadfbf25a45856d6a.js  929 bytes       1  [emitted]         pageB
             pageA-7cbd77a502262ddcdd19.js  790 bytes       2  [emitted]         pageA
           runtime-fa8eba6e81ed41f50d6f.js    5.88 kB       3  [emitted]         runtime
pageA-35be2c21107ce4016c324daaa1dd5e28.css   49 bytes       2  [emitted]         pageA
pageB-56185455ea60f01155a65497e9bf6c85.css  108 bytes       1  [emitted]         pageB

到此为止我们解决了:排除与己不相关的module id或者内容问题。

稳定module id,尽可能的保持module id保持不变

一个module id是一个模块的唯一标示,并且该标示会出现在对应的entry chunk构建后的代码中。看个pageB的构建后代码的例子:

__webpack_require__(7)
const sum = __webpack_require__(0)
const _ = __webpack_require__(3)

根据前面的实验,模块的增加或者减少都会引起module id的改变,所以为了不引起module id的改变,那么我们只能找一个东西来代替module id作为标示。我们在构建的过程中就将寻找出来替代标示来替换module id。

所以上面的叙述可以转换成两个步骤来行动。

  • 找到替代module id的方式
  • 找到时机替换module id

6、稳定 module id 的相关操作

找到替代module id的方式
我们在日常的开发中,经常引用模块,都是通过地址来引用的。从这里我们可以得到启发,我们能不能够把module id全部替换成路径呢?再一个我们了解到在webpack resolve module阶段我们肯定是可以拿到资源路径的。在开始我们担心平台的路径差异性。幸运的是webpack 的源码其中在 ContextModule#74ContextModule#35 中 webpack 对 module 的路径做了差异性修复。也就是说我们可以放心的通过module的libIdent来获取模块的路径了。

在整个webpack的执行过程中涉及到module id有三个钩子:

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

所以我们只要在before-module-ids中做出修改就好了。

编写插件:

'use strict'

class moduleIDsByFilePath {
    constructor(options) {}

    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
                        })
                    }
                })
            })
        })
    }
}

module.exports = moduleIDsByFilePath

上面的其实已经被webpack抽成一个插件了:

NamedModulesPlugin

所以只需要在插件那一部分里面添加上

new webpack.NamedModulesPlugin()

接下来对比下两次构建前后文件的变化:

Hash: e5bc78237ca9a3ad31f8
Version: webpack 3.8.1
Time: 1508ms
                                     Asset       Size  Chunks                    Chunk Names
            vendor-ebd9bfc583f45a344630.js     541 kB       0  [emitted]  [big]  vendor
             pageB-432105effc229524c683.js    1.09 kB       1  [emitted]         pageB
             pageA-158bf2a923c98ab49be2.js    1.09 kB       2  [emitted]         pageA
           runtime-9ca4cebe90e444e723b9.js    5.88 kB       3  [emitted]         runtime
pageA-d7ac82de795ddf50c9df43291d77b4c8.css   92 bytes       2  [emitted]         pageA
pageB-56185455ea60f01155a65497e9bf6c85.css  108 bytes       1  [emitted]         pageB
Hash: 7dce5d9dc88f619522fe
Version: webpack 3.8.1
Time: 1422ms
                                     Asset       Size  Chunks                    Chunk Names
            vendor-ebd9bfc583f45a344630.js     541 kB       0  [emitted]  [big]  vendor
             pageB-432105effc229524c683.js    1.09 kB       1  [emitted]         pageB
             pageA-dae883ddaeff861761da.js  940 bytes       2  [emitted]         pageA
           runtime-c874a0c304fa03493296.js    5.88 kB       3  [emitted]         runtime
pageA-35be2c21107ce4016c324daaa1dd5e28.css   49 bytes       2  [emitted]         pageA
pageB-56185455ea60f01155a65497e9bf6c85.css  108 bytes       1  [emitted]         pageB

哇,我们对比发现只有相关改动的文件和运行时代码发生了改变,vendor和pageB相关都没有发生改变。美滋滋~~

这下我们达到了我们的目的,我们可以去看看我们构建后的代码了:

__webpack_require__("./src/b.less")
const sum = __webpack_require__("./src/common.js")
const _ = __webpack_require__("./node_modules/lodash/lodash.js")

真的是变成了路径,成功~~。但是新的问题貌似又来了,和之前的文件对比发现我们的文件普遍比之前的变大了。好吧,是我们换成文件路径的时候造成的。这个时候我们能不能用hash来代替文件路径呢?答案是可以,官方也有插件可以供我们使用:

new webpack.HashedModuleIdsPlugin()

官方说 NamedModulesPlugin 适合在开发环境,而在生产环境下请使用 HashedModuleIdsPlugin。
这样我们就达成了使用hash来代替原来的module id使之稳定。而且构建后的代码也不会变化太大。

本以为可以到此为止了。但是细心的人会发现runtime文件每次编译都发生了变化。是什么导致呢的?来看看吧:
image
我们观察发现,在我们的entry chunk数量没有发生变化的时候,改变一个entry chunk的内容导致runtime内容发生变化的只有chunk id这个时候问题就又来了。根据上面稳定module id的操作一样,数值型的chunk id不稳定性太大,我们要换,方式和上面一样。

  • 找到稳定chunk id的方式
  • 找到改变chunk id的时机

7、稳定chunk id的相关操作

找到稳定chunk id的方式

因为我们知道webpack在打包的时候入口是具有唯一性的,那么很简单我们能不能够用入口对应的name呢?所以这里就比较简单了我们就用我们的entry name来替换chunk id。

找到改变chunk id的时机

根据经验module 有上面的过程那么 chunk我觉得也是有的。

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

所以编写插件:

'use strict'

class chunkIDsByFilePath {
    constructor(options) {}

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

module.exports = chunkIDsByFilePath

不巧的是官方也有这个插件所以不用我们写。

NamedChunksPlugin

构建后的代码里面我们可以看到了:

/******/         script.src = __webpack_require__.p + "" + chunkId + "-" + {"vendor":"ed00d7222262ac99e510","pageA":"b5b4e2893bce99fd5c57","pageB":"34be879b3374ac9b2072"}[chunkId] + ".js";

原来的chunk id现在全部变成了entry name了,变更的风险又小了一点了。美滋滋~~

我们换成名字后那么问题又和上面module id换成name 又一样的问题,文件会变大。这个时候还是想到和上面的方式一样用hash来处理。这个时候就真的要编写插件了。安利一波我们自己写的
webpack-hashed-chunk-id-plugin

到此持久化缓存中遇到的核心难题都已经处理完了。

最后

如果你想要快速搭建一个项目,欢迎使用这边的项目架构哦。
webpack-project-seed已经有线上项目用的用这个在跑了哦。顺便star一个吧。

感谢:@pigcan


Charming
154 声望46 粉丝

前端小菜鸡,打杂工程师~~