Failed to fetch dynamically imported module 这句报错卡了前端十年,今天 Chrome 154 终于把根因修了
Failed to fetch dynamically imported module。这行红字,凡是做过 SPA、用过 React.lazy() 或者 import() 按需加载的人,十有八九在 Sentry 里见过它。更气人的是:用户刷新页面没用,甚至重启浏览器都没用——因为按 HTML 规范,动态导入的失败会被写进浏览器的 module map 缓存,一旦失败就”粘”住了,重试请求会立刻返回同一个错误。
十年了,这是前端最常见、也最无解的一类线上报错。今天它终于有了原生解法:WHATWG 的 HTML 规范改了,Chrome 154 起不再缓存模块加载失败,import() 失败之后你可以直接再调一次,它会真的重新发网络请求。
先说清楚:这不是”网络抖一下”那么简单
很多人第一次遇到这个错,第一反应是”用户网络差”,于是在代码里套一层 try/catch 重试:
async function loadWidget() {
for (let i = 0; i < 3; i++) {
try {
return await import('./widget.js');
} catch (e) {
await new Promise(r => setTimeout(r, 1000));
}
}
throw new Error('widget load failed');
}
这段代码在旧浏览器里完全没用。因为第一次 import() 失败时,浏览器已经把”这个模块 URL 加载失败”这件事缓存进了 module map。你第二次、第三次调用 import(),浏览器根本不会走网络,直接同步抛出一模一样的错误。你等了 3 秒、重试 3 次,实际上一次真实请求都没发出去。
这个行为不是 bug,是规范。最早在 2021 年就被开发者提出来了,Shopify 内部多个团队都撞上过这个问题,issue 一直躺在 WHATWG 仓库里没动。所以过去几年,大家的绕法只有一个:cache busting——在 URL 后面手动加时间戳,骗浏览器这是一个”新资源”:
const url = new URL('./widget.js', import.meta.url);
url.searchParams.set('t', Date.now()); // 强制换一个 URL
await import(url.href);
社区甚至专门有库干这件事(@fatso83/retry-dynamic-import),内部逻辑就是”从错误信息或 importer 函数体里抠出模块路径 → 加时间戳 → 指数退避重试”。React 项目里最常见的写法是把 React.lazy() 包一层:
const lazyWithRetries = (importer) => React.lazy(async () => {
try {
return await importer();
} catch {
const url = new URL(/* 解析出来的路径 */, location.origin);
url.searchParams.set('t', Date.now());
return await import(url.href);
}
});
能跑,但很脏:要解析错误信息里的 URL(Chromium 的报错格式还和 Firefox 不一样)、要自己写退避、还要小心别把一次性 404(发版后旧 chunk 被删了)也当成网络问题反复重试。
现在原生怎么改的
WHATWG 的 PR(Don't cache HTTP errors in the module map)在 2026 年 7 月正式合入。核心就一句话:HTTP 错误(404/500 这类)不再写进 module map 缓存。这意味着一次失败的 import() 之后,你再调一次,浏览器会老老实实重新发起网络请求。
配套的落地情况:
- Chrome / Edge 154 起默认启用(chromestatus 里明确的 shipping milestone 是 154,桌面、Android、iOS WebView 同步)
- Firefox 与 WebKit 已经实现(Firefox 的 bug 2055211 状态为已解决)
- 规范本身已是 WHATWG Living Standard 的一部分
所以那个朋友圈里流传了十年的”重试没用”,从今天起在主流浏览器里正式失效了。
但别高兴太早,有两个坑
第一,重试逻辑还是要你写。 原生改的是”允不允许重试”,不是”自动帮你重试”。浏览器不会自己再发一次请求,你得自己捕获错误、自己再 import() 一次。好消息是现在你可以写干净版本了:
async function importWithRetry(path, { retries = 3, delay = 300 } = {}) {
for (let i = 0; i <= retries; i++) {
try {
return await import(path);
} catch (err) {
// 404 = 发版后旧 chunk 被删,重试也没意义,直接抛
if (/404|Failed to fetch dynamically imported module/.test(err.message) && i === retries) {
throw err;
}
if (i < retries) await new Promise(r => setTimeout(r, delay * 2 ** i));
}
}
}
注意:不要再加时间戳了。加了反而会绕过 CDN/HTTP 缓存,让每次真正需要重试时都多拉一次全量 chunk。原生修好后,直接 import(path) 用原 URL 就是最优解。
第二,”旧 chunk 404″这类失败重试救不了。 发版之后,用户手里还是旧版 HTML,它引用的 app-a1b2c3.js 已经被新版本覆盖删除了,请求必然 404。这种情况正确做法不是重试,而是引导用户刷新页面:
window.addEventListener('vite:preloadError', (e) => {
// 一次性标记,防止刷新死循环
if (sessionStorage.getItem('chunk-reload')) return;
sessionStorage.setItem('chunk-reload', '1');
location.reload();
});
Vite、webpack 生态里都有对应的事件(webpack 是 window.onerror 里匹配 ChunkLoadError)。判断标准很简单:网络类失败 → 退避重试;版本类失败(404、chunk 缺失)→ 刷新一次,且必须加防循环标记。
结论和下一步
这次规范改动看起来很小——”HTTP 错误不进 module map”——但它把前端十来年最顽固的一类线上报错,从”只能靠 cache busting 硬绕”变成了”可以正常重试”。对架构层面的意义是:动态导入的可靠性,终于可以像 fetch 一样被工程化治理,而不是当成玄学。
可落地的三步:
- 升级判断标准:先在代码里区分”网络失败”和”chunk 404″,前者重试、后者刷新,别再一个 catch 全按网络问题处理。
- 删掉时间戳 hack:等你的目标浏览器基线覆盖到 Chrome/Edge 154+、Firefox 与 Safari 新版之后,把
?t=Date.now()这类 cache busting 逻辑清掉,减少无谓的全量拉取。 - 加监控指标:给动态导入失败打点,按错误类型(网络 / 404 / 超时)分桶统计,才能看出升级后到底有没有真的变好。
规范给了工具,剩下的是工程判断。这句”以前重试没用”,从今往后可以放心地从团队知识里删掉了。
评论区
登录后可评论。