webpack穩定moduleid和chunkid以實現持久化快取的梳理

Copyes發表於2017-11-27

前言

如何基於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計算
    ```js
    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')]
    }
**構建結果**
```js
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
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
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
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

相關文章