版本更新导致前端网站资源加载失败:Failed to fetch dynamically imported module
前端网站在维护过程中经常有版本更新和重新部署,而这会导致一些问题,其中某些问题会导致更新时,正在网站中的用户无法正常使用。
异常 Failed to fetch dynamically imported module
的诱发原因之一就是版本更新:在用户访问网站的过程中,项目更新,打包重新部署,其中含有修改内容的组件对应的打包文件的哈希值将发生变化,例如文件名从 chunk-123 变成了 chunk-321,这将导致资源文件的路由错误。当然该异常的诱发原因还有别的场景,这里只解决这一个场景的问题。
Failed to fetch dynamically imported module
的浏览器基础是现代浏览器支持动态引入 js 模块,在 import 触发时,发送 http 请求动态加载所需模块。假如用户正在使用某个页面,并需要导入另一模块(如切换页面),若恰恰此时新版本部署,导致浏览器请求模块 chunk-123,而在服务器上该模块已经被替换成了 chunk-321,浏览器未找到目标文件,也就触发了 “不能动态导入模块” 的错误。
要解决此问题,有几种通用方法:
1. 打包时主动生成版本标识文件
该方法适用性最广泛,不管是什么技术架构都能用。
在项目构建时,可以配置脚本,主动在例如 public
目录下生成一标识文件,如 version.json
:
// version.json
{
"version": "fasd0wenr12"
}
每次写入一随机字符串,最后在项目入口文件中配置一个轮询器,主动定期获取该文件内容,比对目标字段,若修改则提示用户刷新或直接主动刷新。若不想占用太多资源,并且有一些别的计算逻辑,可以把这块放到 web worker。
2. 通过脚本哈希值判断
该方法适用于,单页面应用,如 vue
、react
等等,这类框架打包后,是通过 js 控制页面和渲染的,对于每个页面,其实都是请求同一个 html 文件并通过 <script />
载入控制脚本,动态修改页面中绑定的元素内容。
而每次打包以后,如果没有自己手动配置规则,<script />
引入的 js 文件后缀将修改,因为后缀是通过内容哈希得到的,所以我们可以主动校验引入脚本的链接是否和原来的链接一致来判断是否有新版本:
- 加载页面,启动轮询器。
- 轮询器发送 get 请求获取页面内容(html),筛选出其中所有
<script />
标签的src
,与上一次轮询到的对比。 - 若对比发现不一样,重新加载页面; 若一样,休眠一段时间后继续。
示例代码如下:
/**
* 原理:
* 单页面应用打包后, 通过 script 引入 js, 文件后缀是内容的哈希
* 所以当服务更新后, 可以通过 script 的 src 的哈希值来判断是否需要更新
* 逻辑:
* 请求当前页面, 获取其中 script 的 src, 然后和上一次的 src 进行比较
* 若不一致, 则说明版本更新, 需要刷新页面
*/
let preSource: string[]
const scriptReg = /<script.*src=["'](?<src>[^"']+)/gm
// 检测间隔
const gapTime = 20000 as const
// 获取最新页面中的 script 链接地址
async function extractNewScript() {
// 带上时间戳防止 fetch 缓存
const uri = window.location.pathname + '?_timestamp=' + Date.now()
// 页面 html 字符串, 从中提取 script 标签的 src
const html = await fetch(uri).then((resp) => resp.text())
return Array.from(html.matchAll(scriptReg), (match) => match.groups?.src ?? '')
}
// 检查是否需要更新
async function needUpdate(): Promise<boolean> {
const currentSource = await extractNewScript()
// 初始化, 并不判断
if (!preSource) {
preSource = currentSource
return false
}
// 比较之前的地址和新的地址是否完全一样,只要有差异就需要更新
if (preSource.length !== currentSource.length) return true
for (let i = 0; i < preSource.length; i++) {
if (preSource[i] !== currentSource[i]) return true
}
preSource = currentSource
return false
}
export default function intervalVersionCheck() {
setInterval(async () => {
if (await needUpdate()) {
const result = confirm('Find new version, please reload now')
if (result) location.reload()
}
}, gapTime)
}
3. 通过路由捕获异常
还有一种方法,就是直接捕获这个异常,对比其中的字符串是否含有 Failed to fetch dynamically imported module
,如果有则提示刷新,一般这个异常都是在跳转页面的时候发生,所以可以写在路由的错误捕获里面。
虽然这个方法是最简单的,但是有一定的局限性,比如 vue
项目需要在开发环境将这个错误捕获关闭,因为语法或者什么错误会让项目编译不通过,导致每次尝试更新本地的项目都会触发捕获,一直刷新页面; 还有比如 safari 浏览器可能报错的错误信息稍有不同,对浏览器的适配性上可能有一定问题。