本文是「前端工程现场」系列的第 10 篇。
下面使用一个便于理解的 Vue 3 与 Vite 单页项目作为假设场景。构建文件名、请求日志、缓存响应头和发布流程均为简化示例,不对应具体线上项目。
设想一个后台系统使用路由懒加载。用户上午打开首页后一直没有刷新,下午项目完成了一次发布。用户随后点击订单详情,页面没有正常打开,控制台出现了下面的错误:
text
TypeError: Failed to fetch dynamically imported module:
https://static.example.com/assets/OrderDetail-a81f3.js
浏览器 Network 中还能看到:
text
GET /assets/OrderDetail-a81f3.js 404
奇怪的是,用户刷新页面后再次进入订单详情,功能恢复正常。
如果只看刷新后的结果,很容易把这次问题归到浏览器缓存,或者直接在路由错误回调里执行刷新。这样能够让部分用户继续操作,却没有解释旧文件名从哪里来,也没有说明下一次发布时是否还会发生。
这次排查需要把页面运行和发布过程放到同一条时间线上:
text
用户打开旧版本
→ 浏览器运行旧入口脚本
→ 项目发布新版本
→ 旧构建产物被删除
→ 用户触发尚未加载的路由
→ 旧脚本继续请求旧文件
→ 服务器返回 404
这次错误横跨了用户页面、构建产物和发布目录,只看浏览器或服务器其中一侧都不完整。下面把旧页面和新版本的变化放到同一条时间线上。

本文主要分析旧页面为什么会保留旧资源地址,发布流程怎样让旧资源失效,缓存和构建产物应该如何配合,以及客户端刷新恢复还会留下哪些问题。
一、刷新后恢复为什么值得先检查版本差异
订单详情路由采用动态导入:
ts
const routes = [
{
path: '/orders/:id',
component: () =>
import('@/views/orders/OrderDetail.vue')
}
]
这段代码位于路由配置阶段。订单详情模块不会和首页代码采用完全相同的加载时机,浏览器通常会在用户进入该路由时再请求对应构建产物。
源码里没有出现下面这个文件名:
text
OrderDetail-a81f3.js
它是在生产构建阶段生成的。文件名中的哈希会随着对应内容和依赖关系变化,因此两次构建可能得到不同结果。
假设旧版本构建产物为:
text
assets/index-3c71e.js
assets/OrderDetail-a81f3.js
新版本构建产物为:
text
assets/index-8f20d.js
assets/OrderDetail-f92c7.js
旧标签页已经加载并执行了 index-3c71e.js。这份入口脚本包含旧版本的模块加载关系,后续进入订单详情时,它仍然会请求:
text
/assets/OrderDetail-a81f3.js
刷新页面后,浏览器重新获取当前 HTML 和新入口脚本。新入口记录的地址已经变成:
text
/assets/OrderDetail-f92c7.js
因此,刷新后恢复是判断版本差异的重要线索。
它还不能单独证明问题一定来自旧版本。动态模块加载失败也可能与网络中断、资源路径错误、服务器响应异常或浏览器扩展拦截有关。下一步仍然要检查失败请求的 URL、状态码和响应内容。
二、动态导入在运行阶段怎样触发资源请求
静态导入通常会在当前模块加载时参与依赖加载:
ts
import OrderDetail from './OrderDetail.vue'
动态导入则在代码执行到对应位置时返回一个 Promise:
ts
const loadOrderDetail = () =>
import('./OrderDetail.vue')
路由懒加载使用的就是后一种形式。
当用户打开首页时,订单详情模块可能尚未请求。只有用户进入相应路由,动态导入才会开始加载对应模块。
如果资源获取或模块加载失败,这次动态导入返回的 Promise 会进入失败状态。路由框架随后接收到错误,页面可能停留在原路由、进入错误边界,或者出现空白区域,具体结果取决于项目的错误处理代码。
本次假设场景的时间关系如下:
| 时间点 | 浏览器中的代码 | 订单详情文件是否请求 |
|---|---|---|
| 上午打开首页 | 旧入口脚本 | 否 |
| 下午项目发布 | 旧入口仍在标签页运行 | 否 |
| 发布后点击订单详情 | 旧入口触发动态导入 | 是,请求旧文件 |
| 手动刷新页面 | 获取新入口脚本 | 之后请求新文件 |
最容易误解的地方在第三步。项目已经发布新版本,不代表旧标签页中的 JavaScript 会自动替换成新代码。只要页面没有刷新,旧入口脚本仍然继续运行。
动态导入解决了初始包体积和按需加载问题,同时增加了一个发布期间需要处理的状态:用户可能在旧代码仍然运行时,请求尚未加载的旧模块。
三、发布流程怎样把旧文件变成 404
假设发布过程采用清空静态目录,再上传新构建产物的方式:
text
清理当前 assets 目录
→ 上传新 index.html
→ 上传新 JavaScript 和 CSS
→ 发布完成
如果旧版本中的文件被立即删除,服务器最终只保留:
text
assets/index-8f20d.js
assets/OrderDetail-f92c7.js
旧标签页请求 OrderDetail-a81f3.js 时,服务器已经无法返回该资源,因此出现 404。
这项发布方式解决的是旧文件持续堆积和目录内容混杂的问题。它的代价是切断旧页面对历史构建产物的访问。
可以对比两种简化方案:
| 发布方式 | 新用户 | 已打开的旧页面 | 维护成本 |
|---|---|---|---|
| 直接替换并删除旧资源 | 获取新版本 | 旧模块可能返回 404 | 目录简单,但版本切换风险较高 |
| 保留带哈希的旧资源一段时间 | 获取新版本 | 仍有机会加载旧模块 | 占用更多存储,需要清理策略 |
保留旧资源的思路不是让所有历史版本永久存在,而是给仍在运行的旧页面留出过渡时间。
另一种常见思路是按版本发布到独立目录,再通过入口切换当前版本:
text
/releases/20260729-1400/
/releases/20260729-1730/
入口切换后,新访问者获取新版本,旧版本资源仍然位于原目录。等过渡期结束,再清理较早版本。
它增加了版本目录、存储占用、回滚和清理任务。项目还要确认 HTML 中的资源路径能够指向正确版本,不能只创建目录却继续让不同版本共用容易被覆盖的资源地址。
实际采用对象存储、CDN、容器镜像还是服务器目录,应以项目现有发布平台为准。本文只说明版本替换与旧资源可用性之间的关系。
四、Network 中哪些信息能区分版本问题和路径问题
看到 Loading chunk failed 或 Failed to fetch dynamically imported module 时,第一步可以打开 Network,找到失败请求。
本次简化日志为:
text
Request URL:
https://static.example.com/assets/OrderDetail-a81f3.js
Status Code:
404 Not Found
Initiator:
index-3c71e.js
这段记录位于浏览器运行旧入口并触发动态导入的阶段。
三个字段分别提供了不同线索:
| 字段 | 能帮助判断什么 |
|---|---|
| Request URL | 浏览器实际请求了哪个文件 |
| Status Code | 是文件不存在、权限问题还是服务异常 |
| Initiator | 哪份脚本触发了这次请求 |
如果服务器当前只有 OrderDetail-f92c7.js,而请求发起者仍是 index-3c71e.js,旧页面与新产物不匹配的可能性很高。
其他表现需要采用不同排查方向:
| 请求表现 | 优先检查 |
|---|---|
| 请求旧哈希文件并返回 404 | 发布时是否删除旧资源 |
| 所有异步文件都请求到错误目录 | 构建基础路径和部署路径 |
| 返回 200,但内容是 HTML | 服务器回退规则或代理配置 |
| 状态码为 5xx | 静态服务、CDN 或源站异常 |
| 请求被取消或长时间挂起 | 网络状况和客户端环境 |
| 文件存在但浏览器拒绝执行 | MIME 类型、跨域或安全策略 |
因此,客户端错误文本只能说明动态模块没有成功加载,无法单独说明具体原因。
若刷新后仍然请求错误目录,问题通常不止是旧版本。此时应继续检查构建路径、代理转发和静态服务配置,而不是反复刷新页面。
五、HTML 和带哈希资源为什么需要不同缓存策略
页面加载新旧版本时,HTML 和带哈希资源承担的职责不同。
HTML 中通常保存当前入口文件地址:
html
<script
type="module"
src="/assets/index-8f20d.js"
></script>
带哈希的 JavaScript 和 CSS 则对应具体构建内容。内容变化后,文件名也会变化。
因此,简化后的缓存目标可以写成:
| 资源 | 缓存目标 |
|---|---|
| HTML | 使用前重新确认当前版本 |
| 带内容哈希的 JS/CSS | 在有效期内长期复用 |
| 未带版本标识的配置文件 | 根据更新频率单独设置 |
以下响应头只用于说明机制,不代表所有部署平台都应直接照搬。
HTML 可以采用:
http
Cache-Control: no-cache
这里的 no-cache 不等于浏览器完全不能保存响应。它表示缓存内容在再次使用前需要向服务器重新验证。这样用户刷新或重新访问时,更容易拿到当前入口脚本地址。
带内容哈希的静态资源可以采用:
http
Cache-Control: public, max-age=31536000, immutable
只要相同 URL 对应的内容不再被覆盖,较长缓存时间可以减少重复请求。内容变化时生成新文件名,HTML 再引用新地址。
缓存配置写反后可能出现两类问题:
| 配置问题 | 可能结果 |
|---|---|
| HTML 长期直接使用旧缓存 | 用户持续拿到旧入口地址 |
| 同一个哈希资源 URL 被覆盖 | 缓存中的内容与发布内容不稳定 |
| 静态资源不允许缓存 | 重复下载,失去哈希文件的复用价值 |
| 旧资源被立即删除 | 已打开页面的按需模块可能 404 |
即使 HTML 每次重新验证,也不能更新已经打开标签页里的入口脚本。缓存策略主要影响刷新和新访问,旧页面是否还能加载模块仍然取决于旧资源是否保留以及客户端是否提供恢复流程。
六、客户端自动刷新能解决什么问题
Vite 在动态导入加载失败时会触发 vite:preloadError 事件。项目可以监听该事件,并读取原始错误。
下面是一个简化的恢复示例:
ts
window.addEventListener(
'vite:preloadError',
event => {
event.preventDefault()
console.error(event.payload)
window.location.reload()
}
)
这段代码位于客户端错误恢复阶段。
如果问题来自旧页面请求已删除的旧文件,刷新后浏览器可能获取新 HTML 和新入口脚本,从而恢复到当前版本。
event.preventDefault() 会阻止该错误继续按默认方式抛出。event.payload 可以用于上报原始异常,帮助区分资源地址、网络失败和其他模块加载问题。
这段代码只是机制示例,直接放入生产项目还存在几个问题:
- 网络临时失败也可能触发刷新;
- 构建路径持续错误时,刷新后仍然失败;
- 没有限制次数时,可能形成刷新循环;
- 用户未保存的表单和编辑内容可能丢失;
- 仅刷新无法处理旧前端与新接口不兼容;
- 自动刷新前缺少错误上报,问题可能被隐藏。
更稳妥的恢复流程通常需要包含:
text
记录错误和当前版本
→ 判断是否已经尝试恢复
→ 检查页面是否存在未保存内容
→ 自动刷新或提示用户刷新
→ 再次失败时停止循环并展示错误页
页面端恢复解决的是用户已经遇到错误后怎样继续操作 。发布端保留旧资源解决的是旧页面能否继续完成原来的加载。两者处理的阶段不同,不能互相替代。
七、保留旧资源为什么仍然不能处理所有版本冲突
假设旧订单详情脚本被保留,旧标签页能够继续加载页面。但新版本后端接口已经删除了旧字段,旧页面仍可能在请求数据时失败。
例如旧版本期待:
json
{
"orderStatus": "paid"
}
新接口只返回:
json
{
"status": "paid"
}
静态资源能够加载,并不代表旧前端和新接口仍然兼容。
因此,发布策略还要考虑:
- 后端接口是否允许新旧前端同时调用;
- 数据结构变更是否有过渡期;
- 权限和路由规则是否仍兼容;
- 旧页面提交的数据是否会被新接口接受;
- 回滚时数据库和接口是否能回到对应状态。
可以把问题分成两层:
| 层面 | 解决的问题 |
|---|---|
| 保留旧构建产物 | 旧页面能否加载旧 JavaScript |
| 接口兼容与版本治理 | 旧代码能否继续和当前服务通信 |
保留旧 chunk 只能处理第一层。
如果项目发布频率很高,用户可能打开页面数小时甚至数天。旧资源保留多久,需要结合平均会话时长、存储成本和接口兼容周期判断,没有统一天数可以直接套用。
八、发布后怎样复现和验证旧页面场景
这个问题有一个容易漏掉的验证条件:测试页面不能刷新。
如果发布后只打开新页面验证,浏览器一开始就会获取新入口脚本,自然无法复现旧页面请求旧 chunk 的过程。
可以按下面的步骤进行预发布验证:
text
1. 发布版本 A
2. 打开首页,但不要进入订单详情
3. 保持标签页不刷新
4. 发布版本 B,并修改订单详情相关依赖
5. 回到版本 A 的旧标签页
6. 点击订单详情
7. 检查异步资源请求和页面恢复行为
验证时至少记录:
| 检查项 | 需要确认的结果 |
|---|---|
| 旧标签页请求的文件名 | 是否仍为版本 A 的文件 |
| 旧资源是否存在 | 返回 200 还是 404 |
| 客户端恢复逻辑 | 是否上报、提示或刷新 |
| 刷新后的入口文件 | 是否切换到版本 B |
| 用户未保存内容 | 是否被保留或明确提示 |
| 连续失败 | 是否停止自动刷新循环 |
还应分别验证缓存命中和未命中的情况。CDN 或浏览器缓存中仍保留旧文件时,问题可能暂时不出现,但源站文件已经删除。缓存过期后,旧页面仍可能失败。
发布检查不能只看构建命令是否成功。构建成功说明产物已经生成,无法说明版本切换期间旧页面仍然可用。
九、当前处理方案还缺少哪些保护
本文围绕旧页面和新构建产物错位展开,但同一种错误文本还可能来自其他原因。
第一,资源基础路径配置错误。动态文件全部请求到错误目录时,刷新通常不会恢复,需要检查构建路径和部署目录。
第二,服务器对未知静态资源统一返回 index.html。请求状态可能是 200,但浏览器拿到的是 HTML,模块仍然无法执行。此时需要查看响应内容和 MIME 类型。
第三,CDN 发布和失效存在传播时间。部分节点已经是新版本,部分节点仍返回旧内容,可能形成短时间的不一致。
第四,Service Worker 可能缓存 HTML、入口脚本或异步模块。项目使用 PWA 时,还要检查 Service Worker 的更新、激活和旧缓存清理流程。
第五,网络失败和浏览器扩展也可能阻止动态模块请求。只有请求旧哈希文件并且发布后集中出现,才更符合版本错位特征。
项目可以建立一条较短的排查路径:
text
确认失败请求 URL
→ 查看状态码和响应内容
→ 找到请求发起脚本
→ 对比当前构建产物
→ 检查发布和缓存策略
→ 再决定刷新、提示或修复配置
这条流程减少的是看到错误后直接刷新、关闭懒加载或修改构建工具的试错。最终方案仍然取决于项目的部署平台、缓存层、接口兼容和用户会话长度。
十、小结
这次问题表面上是用户进入懒加载页面时出现 Loading chunk failed,刷新后又能恢复。旧标签页一直运行旧入口脚本,新版本发布时旧 chunk 被删除,用户之后触发动态导入,旧脚本继续请求旧文件,服务器因此返回 404。
项目可以从三个阶段处理:发布端为旧构建产物保留过渡时间,缓存端让 HTML 及时确认当前入口并长期复用带哈希资源,客户端在模块加载失败后记录错误并提供有限的恢复方式。每一层解决的环节不同,也会增加存储清理、缓存配置、刷新保护和错误上报成本。
这些措施仍然不能处理所有问题。旧前端与新接口不兼容、资源路径错误、CDN 不一致、Service Worker 缓存和临时网络失败,都可能产生相似现象。
下次遇到动态模块加载失败,可以先在 Network 中确认浏览器请求了哪个文件、由哪份入口脚本发起、服务器返回了什么。刷新只是恢复动作,失败请求和构建产物之间的差异,才是判断发布链路是否错位的主要依据。下一篇会讨论前端错误监控应该记录哪些信息,重点分析资源加载错误、接口错误和代码异常分别需要保留什么上下文。