解决单页应用中的 ChunkLoadError

April 19, 2023

公司内部的 DevOps 平台在每周一下班后会进行一次部署更新。在 Sentry 上,我观察到在更新部署的第二天,常常会集中地收到大量 ChunkLoadError 的上报。

Sentry 截图

本文记录和分享我在 DevOps 平台项目(下文中简称“DevOps 平台”)中如何解决用户遇到的 ChunkLoadError 错误。

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

参考链接


Profile picture

Written by Doma who just migrated his blog to Gatsby.js. You should follow him on Twitter and GitHub.