Doma

Doma

解決單頁應用中的 ChunkLoadError

公司內部的 DevOps 平台在每週一下班後會進行一次部署更新。在 Sentry 上,我觀察到在更新部署的第二天,常常會集中地收到大量 ChunkLoadError 的上報。

本文記錄和分享我在 DevOps 平台專案(下文中簡稱「DevOps 平台」)中如何解決使用者遇到的 ChunkLoadError 錯誤。

Sentry 截圖

ChunkLoadError 的原因#

根據 Sentry 上記錄的 ChunkLoadError 出現時的使用者操作日誌,發現它主要發生在當使用者點擊連結跳轉到其他頁面時,可以分析其成因來自我們在構建單頁應用時採取的以下實踐:

  • 為了減少初次下載資源的體積,我們常常會對專案進行代碼分割(通過 React.lazyimport() 函數)。在 DevOps 平台的例子中,所有的功能頁面都被分割到了分包中。
  • 構建應用時,為了更好地利用瀏覽器緩存,將輸出腳本的內容雜湊作為腳本文件名稱的一部分。以 webpack 為例,可能設置 filename[name].[contenthash:8].js

每次更新部署時,在新一次的構建中,由於代碼發生了變化,因此構建出的分包文件名稱也會發生了變化。
例如,在上一次的構建中,某功能頁面 A 構建出的分包腳本名稱為 A.oldhash.js,而在新一次的構建中,其構建出的分包腳本名稱變成了 A.newhash.js

如果使用者在更新部署前訪問了你的應用,他的瀏覽器所運行的就是舊版的應用代碼,代碼中包含的分包信息也是舊的,連結 A 會請求 A.oldhash.js
當部署更新後,伺服器上不再存在 A.oldhash.js,此時如果使用者點擊連結 A,就會因為請求的分包文件不存在而產生錯誤。

ChunkLoadError 界面截圖

基於此產生原因,本文針對三種引起 ChunkLoadError 錯誤的具體情形,逐步解決此問題。

解決 ChunkLoadError#

這個問題的核心在於,應用已經更新了部署,但使用者端運行的仍然是舊的代碼。
因此,解決問題的關鍵就是,在更新了部署後,讓使用者端盡可能快地更新到新的代碼。為了做到這一點,有以下方法。

避免入口緩存#

通常,我們會為了節省客戶端流量,而對大部分的資源,特別是體積較大的資源進行緩存。這使得在更新部署後,當使用者再次訪問資源,如果緩存尚未過期,客戶端就會使用緩存的資源,從而運行舊的代碼。
例如,我們的入口腳本名為 app.js,當更新了部署後,使用者再次請求 app.js 檔案時,如果緩存仍未過期,則使用者仍會得到舊的 app.js,也即運行舊版應用,導致加載分包時出現 ChunkLoadError

針對這個問題,我們可以在構建階段,將腳本內容的雜湊值作為輸出檔案名的一部分。這樣一來,在腳本內容未發生改變時,瀏覽器使用緩存的腳本是沒有問題的,而當腳本內容發生改變,腳本名稱也會改變,也就不存在緩存問題。
例如,舊版本的入口腳本名為 app.oldhash.js,而新版本的叫做 app.newhash.js,就能避免緩存問題。

但是,如果我們對入口 HTML 檔案也進行了緩存,那麼在更新部署後,如果緩存尚未過期,客戶端獲得的就仍是舊的 HTML 內容,包括其中引用的腳本名,這樣可能會引起錯誤(但是這裡不是 ChunkLoadError 而是直接入口腳本 404)。因此,我們可以對入口 HTML 檔案設置為不緩存,即可解決此問題。並且,因為入口 HTML 檔案的體積本身並不大(通常只有幾 kB),即使每次都下載,也並沒有太大的影響。

示例 Nginx 配置如下:

server {
    location / {
        if ($request_filename ~ .*\.(htm|html)$) {
            add_header Cache-Control no-cache;
        }
    }
}

主動刷新客戶端應用#

第二種情況是,使用者在更新部署前就訪問了應用,一直沒有退出,因此部署更新後仍然處於舊版的應用中,這種情況上面的方法就不適用,因為使用者沒有去再次請求入口 HTML 檔案。
這時我們可以想辦法讓使用者主動進行刷新從而獲得新的應用。例如,我們可以在檢測到應用更新時,向使用者顯示一個浮窗提示,讓使用者主動點擊刷新。

更新提示截圖

那麼,如何檢測應用更新呢?這裡介紹一種我在掘金上的一篇文章中學到的思路。上文中講到,入口 HTML 檔案不進行緩存,並且體積也不大,所以其實輪詢入口 HTML 檔案,判斷是否與當前 HTML 檔案不同,就可以檢測到應用的更新。
那如何判斷入口 HTML 檔案是否有不同呢?一種方法是,通過 document.currentScript 拿到當前腳本所在的 <script> 元素,並取得其 src 屬性,即為當前腳本的檔案名。
如果在輪詢到的 HTML 檔案文本中,不存在這個檔案名,說明當前的腳本在新的 HTML 檔案中不再被引用了,可以判斷為應用有更新。

const currentScriptSrc = document.currentScript.src
let hasUpdate = false

setInterval(() => {
  if (!hasUpdate) {
    fetch("/")
      .then(response => response.text())
      .then(html => {
        if (!html.includes(currentScriptSrc)) {
          hasUpdate = true
          // 彈窗通知使用者有更新
        }
      })
  }
}, 10000)

邊緣情況#

在進行了上述改進之後,我仍然會在更新部署的第二天集中地收到 ChunkLoadError 的上報。
我在 Sentry 上查看這些記錄,發現它們的使用者流程有一個共同的特徵,就是某請求響應了 401 狀態碼,然後發生 ChunkLoadError

使用者流程截圖

可以推斷場景如下:使用者在頭一天下午訪問了應用,並且沒有退出,隨後應用更新了部署。到了第二天,使用者再次訪問時,因為他的登錄態失效,應用自動地跳轉到登錄頁(應用中的邏輯),而因為登錄頁的分包腳本的 hash 已經改變,從而引起 ChunkLoadError

這與上一種情況的區別是,使用者還來不及看到更新通知並做出反應,應用自己就跳轉了。這種情況下,只要將登錄頁這類被動跳轉的頁面打在主包裡,不要分包,就能避免這個問題。這樣的頁面並不多,

代碼截圖

部署了登錄頁不分割的改動兩週後,再去 Sentry 查看時,發現自更新的下一週起,之後的更新部署果然沒有再引起 ChunkLoadError 了。
至此可以認為 ChunkLoadError 問題被完全解決了。

參考連結#


如果你願意閱讀更多我的文章,請關注我的博客以及我的 xLog。

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。