webpack 持久化快取實踐

發表於2018-01-15

前言

最近在看 webpack 如何做持久化快取的內容,發現其中還是有一些坑點的,正好有時間就將它們整理總結一下,讀完本文你大致能夠明白:

  1. 什麼是持久化快取,為什麼做持久化快取?
  2. webpack 如何做持久化快取?
  3. webpack 做快取的一些注意點。

持久化快取

首先我們需要去解釋一下,什麼是持久化快取,在現在前後端分離的應用大行其道的背景下,前端 html,css,js 往往是以一種靜態資原始檔的形式存在於伺服器,通過介面來獲取資料來展示動態內容。這就涉及到公司如何去部署前端程式碼的問題,所以就涉及到一個更新部署的問題,是先部署頁面,還是先部署資源?

  1. 先部署頁面,再部署資源:在二者部署的時間間隔內,如果有使用者訪問頁面,就會在新的頁面結構中載入舊的資源,並且把這個舊版本資源當做新版本快取起來,其結果就是:使用者訪問到一個樣式錯亂的頁面,除非手動去重新整理,否則在資源快取過期之前,頁面會一直處於錯亂的狀態。
  2. 先部署資源,再部署頁面:在部署時間間隔內,有舊版本的資源本地快取的使用者訪問網站,由於請求的頁面是舊版本,資源引用沒有改變,瀏覽器將直接使用本地快取,這樣屬於正常情況,但沒有本地快取或者快取過期的使用者在訪問網站的時候,就會出現舊版本頁面載入新版本資源的情況,導致頁面執行錯誤。

所以我們需要一種部署策略來保證在更新我們線上的程式碼的時候,線上使用者也能平滑地過渡並且正確開啟我們的網站。

推薦先看這個回答:大公司裡怎樣開發和部署前端程式碼?

當你讀完上面的回答,大致就會明白,現在比較成熟的持久化快取方案就是在靜態資源的名字後面加 hash 值,因為每次修改檔案生成的 hash 值不一樣,這樣做的好處在於增量式釋出檔案,避免覆蓋掉之前檔案從而導致線上的使用者訪問失效。

因為只要做到每次釋出的靜態資源(css, js, img)的名稱都是獨一無二的,那麼我就可以:

  • 針對 html 檔案:不開啟快取,把 html 放到自己的伺服器上,關閉伺服器的快取,自己的伺服器只提供 html 檔案和資料介面
  • 針對靜態的 js,css,圖片等檔案:開啟 cdn 和快取,將靜態資源上傳到 cdn 服務商,我們可以對資源開啟長期快取,因為每個資源的路徑都是獨一無二的,所以不會導致資源被覆蓋,保證線上使用者訪問的穩定性。
  • 每次釋出更新的時候,先將靜態資源(js, css, img) 傳到 cdn 服務上,然後再上傳 html 檔案,這樣既保證了老使用者能否正常訪問,又能讓新使用者看到新的頁面。

上面大致介紹了下主流的前端持久化快取方案,那麼我們為什麼需要做持久化快取呢?

  1. 使用者使用瀏覽器第一次訪問我們的站點時,該頁面引入了各式各樣的靜態資源,如果我們能做到持久化快取的話,可以在 http 響應頭加上 Cache-control 或 Expires 欄位來設定快取,瀏覽器可以將這些資源一一快取到本地。
  2. 使用者在後續訪問的時候,如果需要再次請求同樣的靜態資源,且靜態資源沒有過期,那麼瀏覽器可以直接走本地快取而不用再通過網路請求資源。

webpack 如何做持久化快取

上面簡單介紹完持久化快取,下面這個才是重點,那麼我們應該如何在 webpack 中進行持久化快取的呢,我們需要做到以下兩點:

  1. 保證 hash 值的唯一性,即為每個打包後的資源生成一個獨一無二的 hash 值,只要打包內容不一致,那麼 hash 值就不一致。
  2. 保證 hash 值的穩定性,我們需要做到修改某個模組的時候,只有受影響的打包後檔案 hash 值改變,與該模組無關的打包檔案 hash 值不變。

hash 檔名是實現持久化快取的第一步,目前 webpack 有兩種計算 hash 的方式([hash] 和 [chunkhash])

  • hash 代表每次 webpack 在編譯的過程中會生成唯一的 hash 值,在專案中任何一個檔案改動後就會被重新建立,然後 webpack 計算新的 hash 值。
  • chunkhash 是根據模組計算出來的 hash 值,所以某個檔案的改動只會影響它本身的 hash 值,不會影響其他檔案。

所以如果你只是單純地將所有內容打包成同一個檔案,那麼 hash 就能夠滿足你了,如果你的專案涉及到拆包,分模組進行載入等等,那麼你需要用 chunkhash,來保證每次更新之後只有相關的檔案 hash 值發生改變。

所以我們在一份具有持久化快取的 webpack 配置應該長這樣:

上面程式碼的含義就是:以 index.js 為入口,將所有的程式碼全部打包成一個檔案取名為 index.xxxx.js 並放到 dist 目錄下,現在我們可以在每次更新專案的時候做到生成新命名的檔案了。

如果是應付簡單的場景,這樣做就夠了,但是在大型多頁面應用中,我們往往需要對頁面進行效能優化:

  1. 分離業務程式碼和第三方的程式碼:之所以將業務程式碼和第三方程式碼分離出來,是因為業務程式碼更新頻率高,而第三方程式碼更新迭代速度慢,所以我們將第三方程式碼(庫,框架)進行抽離,這樣可以充分利用瀏覽器的快取來載入第三方庫。
  2. 按需載入:比如在使用 React-Router 的時候,當使用者需要訪問到某個路由的時候再去載入對應的元件,那麼使用者沒有必要在一開始的時候就將所有的路由元件下載到本地。
  3. 在多頁面應用中,我們往往可以將公共模組進行抽離,比如 header, footer 等等,這樣頁面在進行跳轉的時候這些公共模組因為存在於快取裡,就可以直接進行載入了,而不是再進行網路請求了。

那麼如何進行拆包,分模組進行載入,這就需要 webpack 內建外掛:CommonsChunkPlugin,下面我將通過一個例子,來詮釋 webpack 該如何進行配置。

本文的程式碼放在我的 Github 上,有興趣的可以下載來看看:

閱讀下面的內容之前我強烈建議你看下我之前的文章:深入理解 webpack 檔案打包機制,理解 webpack 檔案的打包的機制有助於你更好地實現持久化快取。

例子大概是這樣描述的:它由兩個頁面組成 pageA 和 pageB

上面的頁面內容基本簡單涉及到了我們拆分模組的三種模式:拆分公共庫,按需載入和拆分公共模組。那麼接下來要來配置 webpack:

第一個 CommonsChunkPlugin 用於抽離公共模組,相當於是說 webpack 大佬,如果你看到某個模組被載入兩次即以上,那麼請你幫我移到 common chunk 裡面,這裡 minChunks 為 2,粒度拆解最細,你可以根據自己的實際情況,看選擇是用多少次模組才將它們抽離。

第二個 CommonsChunkPlugin 用來提取第三方程式碼,將它們進行抽離,判斷資源是否來自 node_modules,如果是,則說明是第三方模組,那就將它們抽離。相當於是告訴 webpack 大佬,如果你看見某些模組是來自 node_modules 目錄的,並且名字是 .js 結尾的話,麻煩把他們都移到 vendor chunk 裡去,如果 vendor chunk 不存在的話,就建立一個新的。

這樣配置有什麼好處,隨著業務的增長,我們依賴的第三方庫程式碼很可能會越來越多,如果我們專門配置一個入口來存放第三方程式碼,這時候我們的 webpack.config.js 就會變成:

第三個 ExtractTextPlugin 外掛用於將 css 從打包好的 js 檔案中抽離,生成獨立的 css 檔案,想象一下,當你只是修改了下樣式,並沒有修改頁面的功能邏輯,你肯定不希望你的 js 檔案 hash 值變化,你肯定是希望 css 和 js 能夠相互分開,且互不影響。

執行 webpack 後可以看到打包之後的效果:

可以看出 css 和 js 已經分離,並且我們對模組進行了拆分,保證了模組 chunk 的唯一性,當你每次更新程式碼的時候,會生成不一樣的 hash 值。

唯一性有了,那麼我們需要保證 hash 值的穩定性,試想下這樣的場景,你肯定不希望你修改某部分的程式碼(模組,css)導致了檔案的 hash 值全變了,那麼顯然是不明智的,那麼我們去做到 hash 值變化最小化呢?

換句話說,我們就要找出 webpack 編譯中會導致快取失效的因素,想辦法去解決或優化它?

影響 chunkhash 值變化主要由以下四個部分引起的:

  1. 包含模組的原始碼
  2. webpack 用於啟動執行的 runtime 程式碼
  3. webpack 生成的模組 moduleid(包括包含模組 id 和被引用的依賴模組 id)
  4. chunkID

這四部分只要有任意部分發生變化,生成的分塊檔案就不一樣了,快取也就會失效,下面就從四個部分一一介紹:

一、原始碼變化:

顯然不用多說,快取必須要重新整理,不然就有問題了

二、webpack 啟動執行的 runtime 程式碼:

看過我之前的文章:深入理解 webpack 檔案打包機制 就會知道,在 webpack 啟動的時候需要執行一些啟動程式碼。

大致內容像上面這樣,它們是 webpack 的一些啟動程式碼,它們是一些函式,告訴瀏覽器如何載入 webpack 定義的模組。

其中有一行程式碼每次更新都會改變的,因為啟動程式碼需要清楚地知道 chunkid 和 chunkhash 值得對應關係,這樣在非同步載入的時候才能正確地拼接出非同步 js 檔案的路徑。

那麼這部分程式碼最終放在哪個檔案呢?因為我們剛才配置的時候最後生成的 common chunk 模組,那麼這部分執行時程式碼會被直接內建在裡面,這就導致了,我們每次更新我們業務程式碼(pageA, pageB, 模組)的時候, common chunkhash 會一直變化,但是這顯然不符合我們的設想,因為我們只是要用 common chunk 用來存放公共模組(這裡指的是 componentA),那麼我 componentA 都沒去修改,憑啥 chunkhash 需要變了。

所以我們需要將這部分 runtime 程式碼抽離成單獨檔案。

這相當於是告訴 webpack 幫我把執行時程式碼抽離,放到單獨的檔案中。

多生成了一個 runtime.xxxx.js,以後你在改動業務程式碼的時候,common chunk 的 hash 值就不會變了,取而代之的是 runtime chunk hash 值會變,既然這部分程式碼是動態的,可以通過 chunk-manifest-webpack-plugin 將他們 inline 到 html 中,減少一次網路請求。

三、webpack 生成的模組 moduleid

在 webpack2 中預設載入 OccurrenceOrderPlugin 這個外掛,OccurrenceOrderPlugin 外掛會按引入次數最多的模組進行排序,引入次數的模組的 moduleId 越小,但是這仍然是不穩定的,隨著你程式碼量的增加,雖然程式碼引用次數的模組 moduleId 越小,越不容易變化,但是難免還是不確定的。

預設情況下,模組的 id 是這個模組在模組陣列中的索引。OccurenceOrderPlugin 會將引用次數多的模組放在前面,在每次編譯時模組的順序都是一致的,如果你修改程式碼時新增或刪除了一些模組,這將可能會影響到所有模組的 id。

最佳實踐方案是通過 HashedModuleIdsPlugin 這個外掛,這個外掛會根據模組的相對路徑生成一個長度只有四位的字串作為模組的 id,既隱藏了模組的路徑資訊,又減少了模組 id 的長度。

這樣一來,改變 moduleId 的方式就只有檔案路徑的改變了,只要你的檔案路徑值不變,生成四位的字串就不變,hash 值也不變。增加或刪除業務程式碼模組不會對 moduleid 產生任何影響。

四、chunkID

實際情況中分塊的個數的順序在多次編譯之間大多都是固定的, 不太容易發生變化。

這裡涉及的只是比較基礎的模組拆分,還有一些其它情況沒有考慮到,比如非同步載入元件中包含公共模組,可以再次將公共模組進行抽離。形成非同步公共 chunk 模組。有想深入學習的可以看這篇文章:Webpack 大法之 Code Splitting

webpack 做快取的一些注意點

  1. CSS 檔案 hash 值失效的問題
  2. 不建議線上釋出使用 DllPlugin 外掛

CSS 檔案 hash 值失效的問題:

ExtractTextPlugin 有個比較嚴重的問題,那就是它生成檔名所用的[chunkhash]是直接取自於引用該 css 程式碼段的 js chunk ;換句話說,如果我只是修改 css 程式碼段,而不動 js 程式碼,那麼最後生成出來的 css 檔名依然沒有變化。

所以我們需要將 ExtractTextPlugin 中的 chunkhash 改為 contenthash,顧名思義,contenthash 代表的是文字檔案內容的 hash 值,也就是隻有 style 檔案的 hash 值。這樣編譯出來的 js 和 css 檔案就有獨立的 hash 值了。

如果你使用的是 webpack2,webpack3,那麼恭喜你,這樣就足夠了,js 檔案和 css 檔案修改都不會影響到相互的 hash 值。那如果你使用的是 webpack1,那麼就會出現問題。

具體來講就是 webpack1 和 webpack 在計算 chunkhash 值得不同:

webpack1 在涉及的時候並沒有考慮像 ExtractTextPlugin 會將模組內容抽離的問題,所以它在計算 chunkhash 的時候是通過打包之前模組內容去計算的,也就是說在計算的時候 css 內容也包含在內,之後才將 css 內容抽離成單獨的檔案,

那麼就會出現:如果只修改了 css 檔案,未修改引用的 js 檔案,那麼編譯輸出的 js 檔案的 hash 值也會改變。

對此,webpack2 做了改進,它是基於打包後檔案內容來計算 hash 值的,所以是在 ExtractTextPlugin 抽離 css 程式碼之後,所以就不存在上述這樣的問題。如果不幸的你還在使用 webpack1,那麼推薦你使用 md5-hash-webpack-plugin 外掛來改變 webpack 計算 hash 的策略。

不建議線上釋出使用 DllPlugin 外掛

為什麼這麼說呢?因為最近有朋友來問我,他們 leader 不讓線上上用 DllPlugin 外掛,來問我為什麼?

DllPlugin 本身有幾個缺點:

  1. 首先你需要額外多配置一份 webpack 配置,增加工作量。
  2. 其中一個頁面用到了一個體積很大的第三方依賴庫而其它頁面根本不需要用到,但若直接將它打包在 dll.js 裡很不值得,每次頁面開啟都要去載入這段無用的程式碼,無法使用到 webpack2 的 Code Splitting 功能。
  3. 第一次開啟的時候需要下載 dll 檔案,因為你把很多庫全部打在一起了,導致 dll 檔案很大,首次進入頁面載入速度很慢。

雖然你可以打包成 dll 檔案,然後讓瀏覽器去讀取快取,這樣下次就不用再去請求,比如你用 lodash 其中一個函式,而你用dll會將整個 lodash 檔案打進去,這就會導致你載入無用程式碼過多,不利於首屏渲染時間。

我認為的正確的姿勢是:

  1. 像 React、Vue 這樣整體性偏強的庫,可以生成 vendor 第三方庫來去做快取,因為你一般技術體系是固定的,一個站點裡面基本上都會用到統一技術體系,所以生成 vendor 庫用於快取。
  2. 像 antd、lodash 這種功能性元件庫,可以通過 tree shaking 來進行消除,只保留有用的程式碼,千萬不要直接打到 vendor 第三方庫裡,不然你將大量執行無用的程式碼。

結語

好了,感覺我又扯了很多,最近在看 webpack 確實收穫不少,希望大家能從文章中也能有所收穫。另外推薦再次推薦一下我之前寫的文章,能夠更好地幫你理解檔案快取機制:深入理解 webpack 檔案打包機制

參考連結:

相關文章