公司內部的 DevOps 平台在每週一下班後會進行一次部署更新。在 Sentry 上,我觀察到在更新部署的第二天,常常會集中地收到大量 ChunkLoadError
的上報。
本文記錄和分享我在 DevOps 平台專案(下文中簡稱「DevOps 平台」)中如何解決使用者遇到的 ChunkLoadError
錯誤。
ChunkLoadError 的原因#
根據 Sentry 上記錄的 ChunkLoadError
出現時的使用者操作日誌,發現它主要發生在當使用者點擊連結跳轉到其他頁面時,可以分析其成因來自我們在構建單頁應用時採取的以下實踐:
- 為了減少初次下載資源的體積,我們常常會對專案進行代碼分割(通過
React.lazy
和import()
函數)。在 DevOps 平台的例子中,所有的功能頁面都被分割到了分包中。 - 構建應用時,為了更好地利用瀏覽器緩存,將輸出腳本的內容雜湊作為腳本文件名稱的一部分。以 webpack 為例,可能設置
filename
為[name].[contenthash:8].js
。
每次更新部署時,在新一次的構建中,由於代碼發生了變化,因此構建出的分包文件名稱也會發生了變化。
例如,在上一次的構建中,某功能頁面 A 構建出的分包腳本名稱為 A.oldhash.js
,而在新一次的構建中,其構建出的分包腳本名稱變成了 A.newhash.js
。
如果使用者在更新部署前訪問了你的應用,他的瀏覽器所運行的就是舊版的應用代碼,代碼中包含的分包信息也是舊的,連結 A 會請求 A.oldhash.js
。
當部署更新後,伺服器上不再存在 A.oldhash.js
,此時如果使用者點擊連結 A,就會因為請求的分包文件不存在而產生錯誤。
基於此產生原因,本文針對三種引起 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。