你写的 import 重试,从来没生效过

产品上线三个月,每次发布之后的一小时里,错误追踪都会冒出几百条 Failed to fetch dynamically imported module。

用户那边的表现是:点支付按钮,没反应。

你刷新一下页面,好了。让用户刷新一下,也好了。

所以这个问题一直没被当成 bug。日志里躺着,标成弱网抖动,加个标签就过去了。

但如果你真的在代码里写过重试,会发现更奇怪的事:重试也没用。

这个问题难查,是因为表现和原因错位。

第一次失败的概率不高。弱网、CDN 抖动、部署窗口,加起来可能只有百分之几的会话会撞上。撞上之后用户刷新一下就好了,所以客服那边听到的反馈是"偶尔点不动"。

但从失败那一刻起,这个页面就已经坏了。不是这一次请求失败,是之后每一次都失败。用户多试几次,看起来像"这个功能时好时坏",实际上它再也不会好了。

重试为什么没用

每个 document 都有一张 module map,记录它已经加载过的模块。HTML 规范里,这张表的 key 是两部分:模块解析后的绝对 URL,和它的类型(JavaScript、JSON 或 CSS)。

调用 import("./dialog.js") 的时候,浏览器做这几件事:

先把 specifier 解析成绝对 URL,然后查这张表。表里没有,就下载、解析、链接依赖、求值,再存进去。表里已经有了,就直接 resolve,一个请求都不发。如果正在下载,第二次调用会等同一个下载,不会另起一个。

看起来是个纯粹的成功缓存。问题在于,规范把失败也存了进去。

第一次下载失败之后,任何对同一个 URL 的 import() 都会立刻失败,不回网络,直到页面刷新。

js 复制代码
try {
  await import("./dialog.js");
} catch {
  // 你以为这里在重试
  await import("./dialog.js");
  // 实际是:立刻抛同一个错误,一个请求都没发
}

这不是浏览器偷懒,是规范就这么写的。

那个 catch 从来没生效过

很多团队在 catch 里写了重试、退避、上报。这些逻辑在第一次失败之后就变成死代码,因为第二次调用根本走不到网络。

于是大家开始加参数绕:

js 复制代码
await import(`./dialog.js?v=${Date.now()}`);

这确实能让重试生效。URL 变了,module map 里查不到,浏览器只能重新下载。

但代价是,这变成了另一个模块。有自己的求值,自己的模块级状态。如果模块在求值时注册了事件监听,每重试一次就多一份。如果里面有计数器或者缓存,每一份都是独立的。

MDN 把这个技巧记成"强制二次求值的唯一办法",因为没有任何 API 能清空 module map。它适合在开发环境里折腾,放到生产环境的交互里,几乎总是错的。

框架层还会再叠一层。React 的 lazy 也记住失败,就像记住成功一样。它内部有个状态位,第一次 load 失败之后就被设上,之后永远不再调用 load。

所以你在 error boundary 里放个"重试"按钮,点下去只是重新渲染。那个按钮从第一次点就是假的。

浏览器在 module map 里记一次,框架在组件层再记一次。两层叠在一起,任何一层的重试都到不了网络。

规范改了一行,但只改了一半

2026 年 7 月,WHATWG 合并了一个 PR,标题很直白:Don't cache HTTP errors in the module map。

这个改动关闭了一个从 2021 年开到现在的讨论。

内容是:网络错误和 HTTP 错误不再存进 module map,下一次 import() 会重新尝试下载。

但只改了一半。

求值错误仍然存着。如果模块下载成功、解析成功,但执行的时候抛了异常,它会被标记成失败,之后任何 import() 都返回同一个错误,不会重新跑一遍。

这个区分本身是合理的。下载失败是环境问题,重试有意义;求值失败是代码问题,重试一百次结果一样。麻烦的地方在于,这两种失败在 catch 里长得一模一样,你得自己分辨。

顺便说一个更早的坑。WebKit 有个 2024 年 3 月报的 bug:rel="modulepreload" 的加载失败会卡在资源缓存里,页面陷入一个毒化的状态。典型 SPA 的表现是关键模块加载不上,整个页面不能用,而且刷新也修不好。报告人是在弱移动网络下真实撞上的。这个 bug 到 2026 年 10 月 10 日才修好。

也就是说,过去两年里,"刷新一下试试"这个建议在 Safari 上不一定成立。

各浏览器不同步

2026 年 10 月的情况:

浏览器 下载失败后会不会重试
Firefox 155
Safari 2026 年 8 月进 WebKit,稳定版未确认
Chrome / Edge 156

这个不同步意味着你不能只写一份代码。现在部署,你面对的是三种行为。

现在能用的写法

跨浏览器的兼容写法,核心是存 promise 而不是存模块:

js 复制代码
class FileUploader {
  #dialogModulePromise;

  loadDialogModule() {
    this.#dialogModulePromise ??= import("./dialog.js")
      .catch(() => import(`./dialog.js?retry=${Date.now()}`))
      .catch(error => {
        this.#dialogModulePromise = undefined;
        throw error;
      });
    return this.#dialogModulePromise;
  }
}

几个细节值得说。

存 promise 而不是存模块,是为了让加载过程中的多次调用共享同一次尝试。如果存的是模块,第一次还没加载完时进来的调用会再发起一次。

重试换 URL,是为了兼容还在缓存失败的浏览器。等所有浏览器都跟上了,同一个 URL 重试就够了。

重试也失败的时候,把 promise 清掉,让下一次调用重新来。不然这个实例就永远坏着了。

打包器是另一回事。webpack 把 import() 转成自己的 chunk 加载,runtime 记录已安装的 chunk,正在下载时复用 promise。Vite 在生产环境保留原生 import(),包在 __vitePreload 里并行下载依赖,缓存还是浏览器的 module map。

但重试这个动作,打包器帮不了你。因为运行时拼出来的 URL 指向不了任何 chunk,你只能用打包器自己的工具去处理。

但线上炸的往往不是网络

前面那些都是浏览器的事。真到线上,更常见的原因是部署把文件删了。

有个团队在 DEV 上写过他们的经历,值得完整讲一遍。

他们的产品是个 Vite 做的 SPA,每个路由是独立 chunk,文件名带内容 hash。部署到 S3,前面挂 CloudFront,部署完成后删掉所有不属于新构建的文件,为了保持 bucket 干净。

部署的时候开着页面的用户,浏览器里跑的仍然是旧构建。他去点支付,他的那份代码按旧名字去要 chunk,而那个文件已经不存在了。

本来应该是个 404。但他们的 CloudFront 配了 SPA 的标准做法:403 和 404 都返回 index.html,状态码 200。于是浏览器要的是一个 JavaScript 模块,拿到的是一个 HTML 页面,MIME 类型不对,拒绝执行。按钮点了没反应。

三个月里,每次部署后一小时,错误追踪里全是 Failed to fetch dynamically imported module。

他们的修复方案可以直接抄:

构建产物当 immutable 处理,部署只加不删,另外跑一个每周的任务,清掉超过三十天不在任何构建里的资源。index.html 用 no-cache,assets 用一年 immutable。去掉用 index.html 应答错误的配置,换成只重写路由路径,缺失的资源返回诚实的 404。最后在 router 里捕获 chunk 加载失败,在同一 URL reload 一次,兜住那些页面开了超过三十天的用户。

那个作者写了一句总结,我觉得比技术细节更值得记住:我们以为发布替换了旧版本。它只替换了我们这边的文件,旧版本在生产上会活到最后一个标签页关闭为止。

现在该做什么

** 翻一遍你的 catch。** 如果有重试逻辑,看看它是不是在重试同一个 URL,如果是,那它一直没生效。换成上面那种写法。

** 查你的部署流程。** 会不会删旧文件,删之前有没有给旧版本留缓冲期。

** 查你的 SPA 回退配置。** 有没有把 404 变成 200 加 index.html,如果有,一个缺失的 JS 文件会被当成页面返回,错误信息会变成看不懂的 MIME 类型问题。

如果你用 React,检查一下 error boundary 里的"重试"按钮到底做了什么。如果只是重新渲染,它不会重新发请求,得配 location.reload()。Vite 有个 vite:preloadError 事件可以接,文档的示例是监听后 reload,但要注意两件事:服务器必须给 HTML 发 Cache-Control: no-cache,否则浏览器可能留着旧 HTML,重载之后还指向旧 chunk;而且只能 reload 一次,不然真的缺文件时会无限刷新。

规范改这一行,从开讨论到合并用了五年,从合并到主流浏览器跟进又用了几个月。

它修的东西很小:让一次失败只是失败,而不是一个永久状态。

但浏览器改完,你的代码不会自动变好。那些写在 catch 里、从来没被执行过的重试逻辑,还是死代码。


相关推荐
鶴哥只手遮天4 小时前
Angular 22 源码级深度解析-第 13 章 依赖注入(一):Injector 抽象、`R3Injector` 与 bloom filter
前端·开源·angular.js
青烟小生4 小时前
前端 AI Agent 编排引擎:从 ReAct 循环到生成式 UI(完整版)
前端
MetWeave4 小时前
一朵玫瑰,装下一年的风(《风不是一个数》系列第 5 篇
前端·数据可视化
UNIHHIOT4 小时前
开源一套工业物联网平台:驱动崩了不影响别的协议,64MB 堆跑边缘网关
前端·后端
deli0074 小时前
秒杀超卖实测:1000 个并发请求下 SQL 加锁前后差多少
前端
徐小夕4 小时前
JitKnow知识库4.8发布:本体+知识图谱检索引擎,让AI回答不再“一本正经地胡说八道”
前端·vue.js·github
智迁4 小时前
MySQL 到 Kingbase 实时同步:从 Docker 部署到增删改验证
前端·后端
tryCbest4 小时前
Vue3——第 11 篇:组合式 API(上)——setup 与响应式 API 深度解析
前端·javascript·vue.js
tryCbest5 小时前
Vue3——第 13 篇:计算属性与侦听器——computed 与 watch 的进化
前端·javascript·vue.js