解决单页应用中的 ChunkLoadError

公司内部的 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 配置如下：

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

主动刷新客户端应用

第二种情况是，用户在更新部署前就访问了应用，一直没有退出，因此部署更新后仍然处于旧版的应用中，这种情况上面的方法就不适用，因为用户没有去再次请求入口 HTML 文件。
这时我们可以想办法让用户主动进行刷新从而获得新的应用。例如，我们可以在检测到应用更新时，向用户显示一个浮窗提示，让用户主动点击刷新。

那么，如何检测应用更新呢？这里介绍一种我在掘金上的一篇文章中学到的思路。上文中讲到，入口 HTML 文件不进行缓存，并且体积也不大，所以其实轮询入口 HTML 文件，判断是否与当前 HTML 文件不同，就可以检测到应用的更新。
那如何判断入口 HTML 文件是否有不同呢？一种方法是，通过 document.currentScript 拿到当前脚本所在的 元素，并取得其 src 属性，即为当前脚本的文件名。
如果在轮询到的 HTML 文件文本中，不存在这个文件名，说明当前的脚本在新的 HTML 文件中不再被引用了，可以判断为应用有更新。

Copy
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 问题被完全解决了。

参考链接

https://juejin.cn/post/7185451392994115645

如果你愿意阅读更多我的文章，请关注我的博客以及我的 xLog。