基於webpack4[.3+]構建可預測的持久化快取方案

楞球不將就發表於2018-09-11

基於webpack4[.3+]構建可預測的持久化快取方案

本文針對的是`immutable content+long max-age`型別的web快取。
校驗快取及service worker的處理方案後續有時間再更新。
複製程式碼

web快取的好處不用多說,自從webpack一桶江湖後,如何做Predictable long term caching with Webpack讓配置工程師們頭疼不已。

webpack4.3前,有相當多的文章介紹如何處理(見參考),這裡想做些更到位的探索。

問題

當業務開發完成,準備上線時,問題就來了?:

  1. 如何保證不同內容的資源擁有唯一的標識(hash值)?
  2. 修改了業務程式碼,重新打包,會不會導致所有資源的標識值都變動?
  3. 如果想穩定hash值,如何確保將變動的檔名降到最低?
  4. css/wasm等資源的變動,是否會影響chunk的雜湊值?
  5. 業務中引用的順序改變,是否會改變chunk的雜湊值?是否應該?
  6. dynamic import的檔案是否支援良好?
  7. 增刪多個入口檔案,是否會影響已有的雜湊值?

不要放棄治療?本文測試時候的一些版本:

Node.js: v10.8.0
Webpack: v4.17.1
複製程式碼

TL;DR

  • webpack@4.3後的contenthash很爽很安逸?
  • 使用HashedModuleIdsPlugin穩定moduleId。該外掛會根據模組的相對路徑生成一個四位數的hash作為模組id, 建議用於生產環境?
  • 使用HashedModuleIdsPlugin穩定chunkId。
  • webpack@5會擁有開箱即用的持久化快取(官方是這樣設想的?,webapck4號稱零配置,仍然誕生了大量的高階配置工程師)

需要長效快取的資源

  • 圖片、字型等media資源 media資源可以使用file-loader根據資源內容生成hash值,配合url-loader可以按需內聯成base64格式,這裡不多說。

  • css css資源如果不做特殊處理,會直接打進js檔案中;生產環境我們通常會使用mini-css-extract-plugin抽取到單獨的檔案中或是內聯。

  • js js檔案的處理要麻煩的多,作為唯一的入口資源,js管理著其他module,引入了無窮無盡的疑問,這也是我們接下來的重點。

webpack4 hash型別

hash型別 描述
hash The hash of the module identifier
chunkhash The hash of the chunk
contenthash (webpack > 4.3.0) The hash of the content(only)

contenthash應該是一個比較重要的feature,webpack核心開發者認為這個可以完全替代chunkhash(見 issue#2096),也許會在webpack5中將contenthash改成[hash]

那麼他們的區別在哪裡呢?

簡單來說,當chunk中包含css、wasm時,如果css有改動,chunkhash也會發生改變,導致chunk的雜湊值變動;如果使用contenthash,css的改動不會影響chunk的雜湊值,因為它是依據chunk 的js內容生成的。

知道有這麼幾種就夠了,下面就從最基本的例子開始吧?‍♂️。

栗子們

接下來都會在production mode下測試(如果你不清楚webpack4新增的mode模式,去翻翻webpack mode 文件吧)。

涉及到的拆包策略,會一筆帶過,後續有時間再詳細聊聊拆包相關的問題~

1. 簡單的hash

最簡單的配置檔案如下?,

// webapck.config.js
const path = require('path'); 
const webpack = require('webpack'); 
module.exports = { 
    mode:'production',
    entry: { 
        index: './src/index.js', 
    }, 
    output: { 
        path: path.join(__dirname, 'dist'), 
        filename: '[name].[hash].js',
  }, 
};
複製程式碼

入口檔案index.js很簡單:

// index.js
console.log('hello webapck?')
複製程式碼

打包結果:

基於webpack4[.3+]構建可預測的持久化快取方案

這個例子使用了name + hash進行檔案命名,因為hash是根據 module identifier生成的,這意味著只要業務中有一點點小小的改動,hash值就會變,來看下面的例子。

2. 增加一個vendors

讓我們來增加一點點複雜性。

@灰大 在對Webpack的hash穩定性的初步探索中展示了一個有趣的例子,我們也來試試看。

現在我們給入口檔案增加一個a.js模組:

// index.js
import './a';
console.log('hello webpack?');
複製程式碼

a模組引入了lodash中的identity方法:

// a.js
import {identity} from 'lodash';
identity();
複製程式碼

然後修改下webpack配置檔案,以便抽出vendors檔案及manifest。這裡多說一句,runtimeChunk非常的小,同時可預見的並不會有體積上的大變,所以可以考慮內聯進html。

// webapck.config.js
...
module.exports = { 
...
  // 使用splitChunks預設策略拆包,同時提取runtime
   optimization: {
        runtimeChunk: true,
        splitChunks: {
            chunks: 'all'
        }
    },
};
複製程式碼

打包結果是:

基於webpack4[.3+]構建可預測的持久化快取方案

[hash] 的問題

相信你已經注意到了,上圖打包後,所有的檔案都具有相同的hash值,這意味著什麼呢?

每一次業務迭代上線,使用者端要重新接收靜態資源,因為hash值每次都會變動,之前的一切快取都失效了?。

所以,我們想要做持久化快取,肯定是不會用[hash]了。

3. chunkhash瞭解一下?

在webpack4.3之前,我們只能選擇chunkhash進行模組標識,然而這個玩意兒如不是很穩,配置工程師們廢了九牛二虎之力用了各種黑科技才將hash值儘可能的穩定。

新出的contenthash和chunkhash有多大的區別呢??

來看下面幾個例子。

使用chunkhash

我們將[hash]換成[chunkhash],看下打包結果:

基於webpack4[.3+]構建可預測的持久化快取方案

index、vendors和runtime都擁有了不同的雜湊值,so far so good

我們繼續灰大的例子,在index.js中增加b.js模組,b模組只有一行程式碼:

// index.js
import './b';  // 增加了b.js
import './a';

console.log('hello webpack?');
複製程式碼
// b.js
console.log('no can no bb');
複製程式碼

打包結果:

基於webpack4[.3+]構建可預測的持久化快取方案

index檔案的雜湊值變動符合預期,但是vendors的實質內容仍然是lodash包的identity方法,這個也變了就不能忍了。

原因是webpack4預設按照resolving order使用自增id進行模組標識,所以插入了b.js導致vendors的id錯後了一個數,這一點我們diff一下兩個vendors檔案就可以看出,兩個檔案只有這裡不同:

基於webpack4[.3+]構建可預測的持久化快取方案

灰大文章中也提到了,解決方案很簡單,使用HashedModuleIdsPlugin,這是一個內建外掛,它會根據模組路徑生成模組id,問題就迎刃而解了:

(起初比較擔心根據module path進行hash計算後命名,這樣的方式是否會因作業系統不同而產生差異,畢竟已經吃過一次虧了,見windows/linux下path路徑不一致的問題 ,好在webpack官方已經處理過這個問題了,無需操心了)

// webpack.config.js
...
plugins:[
    new webpack.HashedModuleIdsPlugin({
        // 替換掉base64,減少一丟丟時間
        hashDigest: 'hex'
    }),
]
...
複製程式碼

(設定optimization.moduleIds:'hash'可以達到同樣的效果,不過需要webapck@4.16.0以上

打包結果:

// 有b模組時:
        index.a169ecea96a59afbb472.js  243 bytes       0  [emitted]  index
vendors~index.6b77ad9a953ec4f883b0.js   69.5 KiB       1  [emitted]  vendors~index
runtime~index.ec8eb4cb2ebdc83c76ed.js   1.42 KiB       2  [emitted]  runtime~index

// 沒有b模組時:
        index.8296fb0301ada4a021b1.js  185 bytes       0  [emitted]  index
vendors~index.6b77ad9a953ec4f883b0.js   69.5 KiB       1  [emitted]  vendors~index
runtime~index.ec8eb4cb2ebdc83c76ed.js   1.42 KiB       2  [emitted]  runtime~index
複製程式碼

4. 增加一個css 模組

入口檔案增加c.css?,c的內容不重要:

// index.js
import './c.css';
import './b';
import './a';
...
複製程式碼

配置一下mini-css-extract-plugin將這個css模組抽取出來:

// webpack.config.js
...
module: {
        rules: [
            {
                test: /\.css$/,
                include: [
                    path.resolve(__dirname, 'src')
                ],
                use: [
                    {loader: MiniCssExtractPlugin.loader},
                    {loader: 'css-loader'}
                ]
            }
        ]
    },
plugins:[
    new webpack.HashedModuleIdsPlugin(),
    // 增加css抽取
    new MiniCssExtractPlugin({
        filename: '[name].[contenthash].css',
        chunkFilename: '[name].[contenthash].css'
    })
]
...
複製程式碼

然後打包。 改動一點c.css中的內容,再次打包。

這兩次打包過程,我們只對c.css檔案做了改動,預期是什麼呢? 當然是希望只有css檔案的雜湊值有改動,然而事情並不符合預期:

// 增加了c.css
                                Asset       Size  Chunks             Chunk Names
       index.90d7b62bebabc8f078cd.css   59 bytes       0  [emitted]  index
        index.e5d6f6e2219665941029.js  276 bytes       0  [emitted]  index
vendors~index.6b77ad9a953ec4f883b0.js   69.5 KiB       1  [emitted]  vendors~index
runtime~index.de3e5c92fb3035ae4940.js   1.42 KiB       2  [emitted]  runtime~index

// 改動c.css中的程式碼後
                                Asset       Size  Chunks             Chunk Names
       index.22b9c488a93511dc43ba.css   94 bytes       0  [emitted]  index
        index.704b09118c28427d4e8f.js  276 bytes       0  [emitted]  index
vendors~index.6b77ad9a953ec4f883b0.js   69.5 KiB       1  [emitted]  vendors~index
runtime~index.de3e5c92fb3035ae4940.js   1.42 KiB       2  [emitted]  runtime~index
複製程式碼

注意看index.js的雜湊值? 打包後,入口檔案的雜湊值竟然也變了,這就很讓人頭疼了。

5. contenthash治癒一切?

contenthash並不能解決moduleId自增的問題

使用contenthash和chunkhash,在上述vendors檔案的行為上,有什麼樣的區別呢? 能否解決因模組變動的問題?

答案是不能?。 畢竟檔案內容中包含了變動的東西,還是需要HashedModuleIdsPlugin外掛。

contenthash威力所在

contenthash可以解決的是,css模組修改後,js雜湊值變動的問題。

修改配置檔案?:

...
    output: {
        path: path.resolve(__dirname, './dist'),
        // 改成contenthash
        filename: '[name].[contenthash].js'        
    },
...    
複製程式碼

直接來看對比:

// 增加了c.css
                                Asset       Size  Chunks             Chunk Names
       index.22b9c488a93511dc43ba.css   94 bytes       0  [emitted]  index
        index.41e5e160a222e08ed18d.js  276 bytes       0  [emitted]  index
vendors~index.ec19a3033220507df6ac.js   69.5 KiB       1  [emitted]  vendors~index
runtime~index.d25723c2af2e039a9728.js   1.42 KiB       2  [emitted]  runtime~index

// 改動c.css中的程式碼後
                                Asset       Size  Chunks             Chunk Names
       index.a4afb491e06f1bb91750.css   60 bytes       0  [emitted]  index
        index.41e5e160a222e08ed18d.js  276 bytes       0  [emitted]  index
vendors~index.ec19a3033220507df6ac.js   69.5 KiB       1  [emitted]  vendors~index
runtime~index.d25723c2af2e039a9728.js   1.42 KiB       2  [emitted]  runtime~index
複製程式碼

可以看到,index.js的chunk 雜湊值在改動前後是完全一致的?。

6. 增加非同步模組

為了優化首屏效能或是業務變得原來越臃腫時,我們不可避免的會進行一些非同步模組的抽取和載入,通過dynamic import方式就很安逸。

然而,非同步模組作為一個新的chunk,他的雜湊值是啥樣的嘞?

我們增加一個非同步模組試試看。

// webpack.config.js
...
output: {
        path: path.resolve(__dirname, './dist'),
        filename: '[name].[contenthash].js',
        // 增加chunkFilename
        chunkFilename: '[name].[contenthash].js'
},
...    
複製程式碼
// async-module.js
export default {
    content: 'async-module'
};


// index.js
import './c.css';
import './b';
import './a';
// 增加這個模組
import('./async-module').then(a => console.log(a));

console.log('hello webpack?');
複製程式碼

async-module的內容也是不重要,重要的是增加這個模組前後的雜湊值有了很大的變化! 沒有非同步模組:

基於webpack4[.3+]構建可預測的持久化快取方案

增加非同步模組:

基於webpack4[.3+]構建可預測的持久化快取方案

再增加第二個非同步模組:

基於webpack4[.3+]構建可預測的持久化快取方案

上面的對比簡直是一夜回到解放前。。。除了css檔案的雜湊值線上,其他的都發生了改變。

究其原因,是因為雖然我們穩定住了moduleId,但是對chunkId無能為力,而且非同步的模組因為沒有chunk.name,導致又使用了數字自增進行命名。

好在我們還有NamedChunksPlugin可以進行chunkId的穩定?:

// webapck.config.js
...
plugin:{
      new webpack.NamedChunksPlugin(
            chunk => chunk.name || Array.from(chunk.modulesIterable, m => m.id).join("_")
     ),
        ...
}
...
複製程式碼

除此之外還有其他的方式可以穩定chunkId,不過由於或多或少的缺點在這裡就不贅述了,來看現在打包的結果:

基於webpack4[.3+]構建可預測的持久化快取方案

可以看出,非同步模組也都有了name值,同時vendors的雜湊值也迴歸了。

7. 增加第二個入口檔案

在業務迭代過程中,經常會增刪一些頁面,那麼這樣的場景,雜湊值是如何變化的呢?

// webpack.config.js
...
entry: {
        index: './src/index.js',
        index2: './src/index2.js'
    },
...    
複製程式碼

我們增加一個index2入口檔案,內容是一句console.log('i am index2~'),來看打包結果:

基於webpack4[.3+]構建可預測的持久化快取方案
可以看到,除了增加了index2.js和runtime~index2.js這兩個檔案外,其餘檔案的雜湊值都沒有變動,完美?

原因是我們已經穩定住了ChunkId,各個chunks不會再根據resolving order進行數字自增操作了。

在實際生產環境中,當新引入的chunk依賴了其他公用模組時,還是會導致一些檔案的雜湊值變動,不過這個可以通過拆包策略來解決,這裡就不贅述了。

總結

本文通過一些例子,總結了通過webpack4做長效快取的原理以及踩坑實踐,而且這些已經運用在了我們的實際業務中,對於頻繁迭代的業務來說,有相當大的效能提升。

webpack4的長效快取相比之前的版本有了很大的進步,也有些許不足,但是相信這些在webapck5中都會得到解決?‍♀️~

參考

相關文章