webpack多頁應用架構系列(十六):善用瀏覽器快取,該去則去,該留則留

array_huang發表於2017-07-24

本文首發於Array_Huang的技術部落格——實用至上,非經作者同意,請勿轉載。
原文地址:https://segmentfault.com/a/1190000010317802
如果您對本系列文章感興趣,歡迎關注訂閱這裡:https://segmentfault.com/blog/array_huang

前言

一個成熟的專案,自然離不開迭代更新;那麼在部署前端這一塊,我們免不了總是要顧及到瀏覽器快取的,本文將介紹如何在 webpack (架構)的幫助下,妥善處理好瀏覽器快取。

實際上,我很早以前就想寫這一part了,只是苦於當時我所掌握的方案不如人意,便不敢獻醜了;而自從
webpack 升級到 v2 版本後,以及第三方plugin的日益豐富,我們也有了更多的手段來處理cache。

瀏覽器快取簡單介紹

下面來簡單介紹一下瀏覽器快取,以及為何我要在標題中強調“該去則去,該留則留”。

瀏覽器快取是啥?

瀏覽器快取(Browser Cache),是瀏覽器為了節省網路頻寬、加快網站訪問速度而推出的一項功能。瀏覽器快取的執行機制是這樣的:

  1. 使用者使用瀏覽器第一次訪問某網站頁面,該頁面上引入了各種各樣的靜態資源(js/css/圖片/字型……),瀏覽器會把這些靜態資源,甚至是頁面本身(html檔案),都一一儲存到本地。
  2. 使用者在後續的訪問中,如果需要再次請求同樣的靜態資源(根據 url 進行匹配),且靜態資源沒有過期(伺服器端有一系列判別資源是否過期的策略,比如Cache-ControlPragmaETagExpiresLast-Modified),則直接使用前面本地儲存的資源,而不需要重複請求。

由於webpack只負責構建生成網站前端的靜態資源,不涉及伺服器,因此本文不討論以HTTP Header為基礎的快取控制策略;那我們討論什麼呢?

很簡單,由於瀏覽器是根據靜態資源的url來判斷該靜態資源是否已有快取,而靜態資源的檔案目錄又是相對固定的,那麼重點明顯就在於靜態資源的檔名了;我們就通過操控靜態資源的檔名,來決定靜態資源的“去留”。

瀏覽器快取,該留不留會怎麼樣?

每次部署上線新版本,靜態資源的檔名若有變化,則瀏覽器判斷是第一次讀取這個靜態資源;那麼,即便這個靜態資源的內容跟上一版的完全一致,瀏覽器也要重新下載這個靜態資源,浪費網路頻寬、拖慢頁面載入速度。

瀏覽器快取,該去不去會怎麼樣?

每次部署上線新版本,靜態資源的檔名若沒有變化,則瀏覽器判斷可載入之前快取下來的靜態資源;那麼,即便這個靜態資源的內容跟上一版的有所變化,瀏覽器也察覺不到,使用了老版本的靜態資源。那這會造成什麼樣的影響呢?可大可小,小至使用者看到的依然是老版的資源,達不到上線更新版本的目的;大至造成網站執行報錯、佈局錯位等問題。

如何通過操控靜態資源的檔名達到控制瀏覽器快取的目的呢?

在webpack關於檔名命名的配置中,存在一系列的變數(或者理解成命名規則也可),通過這些變數,我們可以根據所要生成的檔案的具體情況來進行命名,而不必預設好一個固定的名稱。在快取處理這一塊,我們主要用到[hash][chunkhash]這兩個變數。關於這兩個變數的介紹,我在之前的文章 —— 《webpack配置常用部分有哪些?》就已經解釋過是什麼意思了,這裡就不再累述。

這裡總結下[hash][chunkhash]這兩個變數的用法:

  • [hash]的話,由於每次使用 webpack 構建程式碼的時候,此 hash 字串都會更新,因此相當於強制重新整理瀏覽器快取
  • [chunkhash]的話,則會根據具體 chunk 的內容來形成一個 hash 字串來插入到檔名上;換句說, chunk 的內容不變,該 chunk 所對應生成出來的檔案的檔名也不會變,由此,瀏覽器快取便能得以繼續利用

有哪些資源是需要兼顧瀏覽器快取的?

理論上來說,除了HTML檔案外(HTML檔案的路徑需要保持相對固定,只能從伺服器端入手),webpack生成的所有檔案都需要處理好瀏覽器快取的問題。

js

在 webpack 架構下,js檔案也有不同型別,因此也需要不同的配置:

  1. 入口檔案(Entry):在webpack配置中的output.filename引數中,讓生成的檔名中帶上[chunkhash]即可。
  2. 非同步載入的chunk:output.chunkFilename引數,操作同上。
  3. 通過CommonsChunkPlugin生成的檔案:在CommonsChunkPlugin的配置引數中有filename這一項,操作同上。但需要注意的是,如果你使用[chunkhash]的話,webpack 構建的時候可是會報錯的哦;那可咋辦呢,用[hash]的話,這common chunk不就每次上線新版本都強制重新整理了嗎?這其實是因為,webpack 的 runtime && manifest 會統一儲存在你的common chunk裡,解決的方法,就請看下面關於“webpack 的 runtime && manifest”的部分了。

css

對於css來說,如果你是用style-loader直接把css內聯到<head>裡的,那麼,你管好引入該css的js檔案的瀏覽器快取就好了。

而如果你是使用extract-text-webpack-plugin把css獨立打包成css檔案的,那麼在檔名的配置上,同樣加上[chunkhash]即可加上[contenthash]即可(感謝@FLYiNg_hbt 提醒)。這個[contenthash]是什麼東西呢?其實就是extract-text-webpack-plugin為了與[chunkhash]區分開,而自定義的一個命名規則,其實際含義跟[chunkhash]可以說是一致的,只是[chunkhash]已被佔用作為 chunk 的內容 hash 字串了,繼續用[chunkhash]會造成下述問題

圖片、字型檔案等靜態資源

《聽說webpack連圖片和字型也能打包?》裡介紹的,處理這類靜態資源一般使用url-loaderfile-loader

對於url-loader來說,就不需要關心瀏覽器快取了,因為它是把靜態資源轉化成 dataurl 了,而並非獨立的檔案。

而對於file-loader來說,同樣是在檔名的配置上加上[chunkhash]即可。另外需要注意的是,url-loader一般搭配有降級到file-loader的配置(使用loader載入的檔案大於一個你設定的值就降級到使用file-loader來載入),同樣需要在檔名的配置上加上[chunkhash]

webpack 的runtime && manifest

所謂的runtime,就是幫助 webpack 編譯構建後的打包檔案在瀏覽器執行的一些輔助程式碼段,換句話說,打包後的檔案,除了你自己的原始碼和npm庫外,還有 webpack 提供的一點輔助程式碼段。

而 manifest,則是 webpack 用以查詢 chunk 真實路徑所使用的一份關係表,簡單來說,就是 chunk 名對應 chunk 路徑的關係表。manifest 一般來說會被藏到 runtime 裡,因此我們檢視 runtime 的時候,雖然能找得到 manifest,但一般都不那麼直觀,形如下面這一段(僅common chunk部分):

u.type = "text/javascript", u.charset = "utf-8", u.async = !0, u.timeout = 12e4, n.nc && u.setAttribute("nonce", n.nc), u.src = n.p + "" + e + "." + {
    0: "e6d1dff43f64d01297d3",
    1: "7ad996b8cbd7556a3e56",
    2: "c55991cf244b3d833c32",
    3: "ecbcdaa771c68c97ac38",
    4: "6565e12e7bad74df24c3",
    5: "9f2774b4601839780fc6"
}[e] + ".bundle.js";

runtime && manifest被打包到哪裡去了?

那麼,這runtime && manifest的程式碼段,會被放到哪裡呢?一般來說,如果沒有使用CommonsChunkPlugin生成common chunkruntime && manifest會被放在以入口檔案為首的chunk(俗稱“大包”)裡,如果是我們這種多頁(又稱多入口)應用,則會每個大包一份runtime && manifest;這誇張的冗餘我們自然是不能忍的,那麼
用上CommonsChunkPlugin後,runtime && manifest就會統一遷到common chunk了。

runtime && manifestcommon chunk帶來的快取危機

雖說把runtime && manifest遷到common chunk後,程式碼冗餘的問題算是解決了,但卻造成另一問題:由於我們在上述的靜態資源的檔名命名上都採用了[chunkhash]的方案,因此也使得只要我們稍一改動原始碼,就會有起碼一個 chunk 的命名會產生變化,這就會導致我們的runtime && manifest也產生變化,從而導致我們的common chunk也發生變化,這或許就是 webpack 規定含有runtime && manifestcommon chunk不能使用[chunkhash]的原因吧(反正chunkhash肯定會變的,還不如不用呢是不是)。

要解決上述問題(這問題很嚴重啊我摔,common chunk怎麼能用不上快取啊,這可是最大的chunk啊),我們就需要把runtime && manifest給獨立出去。方法也很簡單,在用來打包common chunkCommonsChunkPlugin後,再加一CommonsChunkPlugin

  /* 抽取出所有通用的部分 */
  new webpack.optimize.CommonsChunkPlugin({
    name: `commons/commons`,      // 需要注意的是,chunk的name不能相同!!!
    filename: `[name]/bundle.[chunkhash].js`, // 由於runtime獨立出去了,這裡便可以使用[chunkhash]了
    minChunks: 4,
  }),
  /* 抽取出webpack的runtime程式碼,避免稍微修改一下入口檔案就會改動commonChunk,導致原本有效的瀏覽器快取失效 */
  new webpack.optimize.CommonsChunkPlugin({
    name: `webpack-runtime`,
    filename: `commons/commons/webpack-runtime.[hash].js`, // 注意runtime只能用[hash]
  }),

這樣一來,runtime && manifest程式碼段就會被打包到這個名為webpack-runtime的 chunk 裡了。這是什麼原理呢?據說是在使用CommonsChunkPlugin的情況下, webpack 會把runtime && manifest打包到最後面的一個CommonsChunkPlugin生成的 chunk 裡,而如果這個chunk沒有其它程式碼,那麼自然就達到了把runtime && manifest獨立出去的目的了。

需要注意的是,如果你用了html-webpack-plugin來生成html頁面,記得要把這runtime && manifest的 chunk 插入到html頁面上,不然頁面報錯了可不怪我哦。

至此,由於runtime && manifest獨立出去成一個chunk了,於是common chunk的命名便可以使用[chunkhash]了,也就是說,common chunk現在也能做到公共模組內容有更新了,才更新檔名;另一方面,這個獨立出去的 runtime && manifest chunk,是每次 webpack 打包構建的時候都會更新了。

有必要把 manifest 從 runtime && manifest chunk 中獨立出去嗎?

是的,不用驚訝,的確是有這麼一個騷操作。

把 manifest 獨立出去的理由是這樣的:manifest 獨立出去後,runtime 的部分基本上就不會有變動了;到這裡,我們就知道,runtime && manifest裡實際上就是 manifest 在變;因此把 manifest 獨立出去,也是進一步地利用瀏覽器快取(可以把 runtime 的快取保留下來)。

具體是怎麼做的呢?主流有倆方案:

  • 利用chunk-manifest-webpack-plugin把 manifest 生成一個json檔案,然後由 webpack 非同步載入。
  • 如果你是用html-webpack-plugin來生成html頁面的話,還可以利用inline-chunk-manifest-html-webpack-pluginhtml-webpack-plugin作者推薦)來把manifest直接輸出到html頁面上,這樣就能省一個 Http 請求了。

我試用過第二種方案,好使,但最終還是放棄了,為什麼呢?

把 manifest 獨立出去後,只剩下 runtime 的 chunk 的命名還是隻能用[hash],而不能利用[chunkhash],這就導致我們根本沒法利用瀏覽器快取。後來,我又想出一個折衷的辦法,連[hash]也不要了,直接寫死一個檔名;這樣的話,的確瀏覽器快取就能儲存下來了。但後來我還是反轉了自己,這種方法雖然能留下瀏覽器快取,卻做不到“該去則去”。或許大家會有疑問,你不是說 runtime 不會變的嗎,那留下快取有什麼關係呀?是的,在同一 webpack 環境下 runtime 的確不會變,但難保 webpack 環境改變後,這runtime會怎麼樣呀。比如說 webpack 的版本升級了、 webpack 的配置改了、loader & plugin 的版本升級了,在這些情況下,誰敢保證 runtime 永遠不會變啊?這 runtime 一用錯了過期的快取,那很可能整個系統都會崩潰的啊,這個險我實在是冒不起,所以只能作罷。

不過我看了下Array-Huang/webpack-seedruntime && manifest chunk,也才 2kb 而已嘛,你們管好自己的強迫症和程式碼潔癖好嗎?!

快取問題雜項

模組id帶來的快取問題

webpack 處理模組(module)間依賴關係時,需要給各個模組定一個 id 以作標識。webpack 預設的 id 命名規則是根據模組引入的順序,賦予一個整數(1、2、3……)。當你在原始碼中任意增添或刪減一個模組的依賴,都會對整個
id 序列造成極大的影響,可謂是“牽一髮而動全身”了。那麼這對我們的瀏覽器快取會有什麼樣直接的影響呢?影響就是會造成,各個chunk中都不一定有實質的變化,但引用的依賴模組id卻都變了,這明顯就會造成 chunk 的檔名的變動,從而影響瀏覽器快取。

webpack 官方文件裡推薦我們使用一個已內建進 webpack2 裡的 plugin:HashedModuleIdsPlugin,這個 plugin 的官方文件在這裡

webpack1 時代便有一個NamedModulesPlugin,它的原理是直接使用模組的相對路徑作為模組的 id,這樣只要模組的相對路徑,模組 id 也就不會變了。那麼這個HashedModuleIdsPlugin對比起NamedModulesPlugin來說又有什麼進步呢?

是這樣的,由於模組的相對路徑有可能會很長,那麼就會佔用大量的空間,這一點是一直為社群所詬病的;但這個HashedModuleIdsPlugin是根據模組的相對路徑生成(預設使用md5演算法)一個長度可配置(預設擷取4位)的字串作為模組的 id,那麼它佔用的空間就很小了,大家也就可以安心服用了。

To generate identifiers that are preserved over builds, webpack supplies the NamedModulesPlugin (recommended for development) and HashedModuleIdsPlugin (recommended for production).

從上可知,官方是推薦開發環境用NamedModulesPlugin,而生產環境用HashedModuleIdsPlugin的,原因似乎是與熱更新(hmr)有關;不過就我看來,僅在生產環境用HashedModuleIdsPlugin就行了,開發環境還管啥瀏覽器快取啊,俺開 chrome dev-tool 設定了不用任何瀏覽器快取的。

用法也挺簡單的,直接加到plugin引數就成了:

plugins: {
  // 其它plugin
  new webpack.HashedModuleIdsPlugin(),  
}

由某些 plugin 造成的檔案改動監測失敗

有些 plugin 會生成獨立的 chunk 檔案,比如CommonsChunkPluginExtractTextPlugin(從js中提取出css程式碼段並生成獨立的css檔案) 。

這些 plugin 在生成 chunk 的檔名時,可能沒料想到後續還會有其它 plugin (比如用來混淆程式碼的UglifyJsPlugin)會對程式碼進行修改,因此,由此生成的 chunk 檔名,並不能完全反映檔案內容的變化。

另外,ExtractTextPlugin有個比較嚴重的問題,那就是它生成檔名所用的[chunkhash]是直接取自於引用該css程式碼段的 js chunk ;換句話說,如果我只是修改 css 程式碼段,而不動 js 程式碼,那麼最後生成出來的css檔名依然沒有變化,這可算是非常嚴重的瀏覽器快取“該去不去”問題了。
2017-07-26 改動:改用[contenthash]便不會出現此問題,上見css部分

有一款 plugin 能解決以上問題:webpack-plugin-hash-output

There are other webpack plugins for hashing out there. But when they run, they don`t “see” the final form of the code, because they run before plugins like webpack.optimize.UglifyJsPlugin. In other words, if you change webpack.optimize.UglifyJsPlugin config, your hashes won`t change, creating potential conflicts with cached resources.

The main difference is that webpack-plugin-hash-output runs in the last compilation step. So any change in webpack or any other plugin that actually changes the output, will be “seen” by this plugin, and therefore that change will be reflected in the hash.

簡單來說,就是這個webpack-plugin-hash-output會在 webpack 編譯的最後階段,重新對所有的檔案取檔案內容的 md5 值,這就保證了檔案內容的變化一定會反映在檔名上了。

用法也比較簡單:

plugins: {
  // 其它plugin
  new HashOutput({
    manifestFiles: `webpack-runtime`, // 指定包含 manifest 在內的 chunk
  }),
}

總結

瀏覽器快取很重要,很重要,很重要,出問題了怕不是要給領導追著打。另外,這一塊的細節特別多,必須方方面面都顧到,不然哪一方面出了紕漏就全域性泡湯。

示例程式碼

諸位看本系列文章,搭配我在Github上的腳手架專案食用更佳哦(笑):Array-Huang/webpack-seedhttps://github.com/Array-Huang/webpack-seed)。

附系列文章目錄(同步更新)

本文首發於Array_Huang的技術部落格——實用至上,非經作者同意,請勿轉載。
原文地址:https://segmentfault.com/a/1190000010317802
如果您對本系列文章感興趣,歡迎關注訂閱這裡:https://segmentfault.com/blog/array_huang

相關文章