这是一篇给"小白也能看懂"的实践文:讲清现象、根因、方案选择与我们的落地实现。
1. 现象:为什么发布新版本后会出现 404?
一个真实场景:
- 10:00 用户打开了你的网页(加载的是 v1.0.4 的 HTML)
- 10:10 你发布了 v1.0.5
- 用户没有刷新页面,继续点击某个功能
- 页面尝试按旧 HTML 里的地址加载某个 chunk:/assets/pages-about-about.DK5VADjQ.js
- 服务器上只剩 v1.0.5 的文件,旧的被删了 → 直接 404
关键点:
- HTML 决定了要加载哪些 JS/CSS(包含具体 hash 文件名)
- 只要用户手上是旧 HTML,就会请求旧版 hash 的文件
- 如果服务器把旧文件删了,用户就会 404
2. 根因:不是缓存,而是"源站有没有旧文件"
很多文章只讲"内容哈希 + immutable 缓存",默认隐含"旧文件在源站/存储里还存在"。
- 有缓存:命中本地/中间缓存,自然不 404
- 没缓存:会去源站拉,如果源站还保留旧文件,也不会 404
- 真正的 404 出在"把旧资源从源站删了",而不是"浏览器没缓存"
结论:避免 404 的关键不是浏览器缓存,而是"源站/存储上保留旧文件一段时间"。
3. 方案总览(从易到难)
- A. 接受小概率 404 → 弹窗提示"有新版本,请刷新"
- 简单,易落地;体验打断、不优雅
 
- B. Service Worker 强制刷新
- 简化实现;用户会被强制 reload,可能丢失上下文
 
- C. 不删旧资源(推荐)
- 源站/存储保留最近 N 个版本的资源;HTML 短缓存
- 旧 HTML 始终能拿到对应 js/css,零 404,体验最佳
 
- D. 网关/Node 层按 manifest 做 chunk 兜底
- 复杂度高;适合更重的后端网关治理
 
本文主讲 C 方案。
4. 我们的落地:/assets 聚合 + /versions 备份 + HTML 短缓存
目标:
- 页面永远从 /assets加载资源(统一入口,不改路径)
- 保留最近 3~5 个版本的所有资源在 /assets,旧 HTML 永远能命中
- 每次发布只切换入口 HTML(dist/current),不动/assets
目录结构:
dist/
├─ current/                # 当前入口(index.html、manifest.json 等)
├─ assets/                 # 资源聚合池(最近N个版本的 js/css/png/...)
└─ versions/               # 版本档案(每次发布的完整备份)
   ├─ v1.0.5/
   │   ├─ index.html
   │   └─ assets/*
   ├─ v1.0.4/
   └─ ...发布流程(关键规则):
- 先 build(产物在 dist/build/h5)
- deploy: 将 dist/build/h5复制到dist/current(并写入manifest.json)
- 同步最近 N 个版本的 assets/*到dist/assets(追加,不覆盖已存在同名 hash 文件)
- 清理 dist/assets中"超出 N 版本范围"的多余文件(可控)
为什么这样零 404:
- 旧 HTML 会请求旧 hash 文件 → dist/assets中还在 → 命中成功
- 新 HTML 请求新 hash → 也在 → 命中成功
- 版本过渡期内(N版本窗口)不再出现 404
5. 关键实现点(我们做了什么)
- Vite 输出使用内容哈希:
- entry/chunk/asset 文件名:assets/[name]-[hash].js|css|...
- 不再在文件名中带版本号目录(避免同内容不同路径导致缓存失效)
 
- entry/chunk/asset 文件名:
- 资源聚合器(脚本):
- 扫描 dist/versions/最近 N 个版本的assets
- 统一拷贝(或硬链接/软链接)到 dist/assets
- 清理 dist/assets中超出窗口的旧文件
 
- 扫描 
- 入口与资源分离:
- dist/current只放 HTML 与轻量入口文件,允许替换
- dist/assets仅追加,不随发布清空
 
- 预览/线上服务:
- /→- dist/current(HTML 短缓存)
- /assets/*→- dist/assets(long cache + immutable)
- 可选兜底:未命中再从 dist/build/h5/assets查找(用于本地预览与排查)
 
6. Service Worker 要点(有用但非必须)
- SW 对 /assets/* 使用"缓存优先 + 网络兜底"或"网络优先 + 缓存兜底",保证 miss 时回源
- sw.js、index.html 设短缓存(或 no-cache),SW 能及时更新
- 若暂不使用 SW,本方案也能零 404;SW 仅作为进一步优化
7. 为什么很多文章不强调"多版本零404"?
- 默认"旧文件不删":使用 OSS/CDN/对象存储,资源是追加上传,旧对象长期存在
- 痛点阈值:发版不频繁、用户会刷新、会话短;小概率 404 用"刷新提示"即可
- 文档聚焦"原则":内容哈希 + immutable + HTML 短缓存,被当作默认前置
当你遇到高频发版、长会话、动态导入较多、不能打断用户的场景时,就必须系统性解决"过渡期 404"。
8. 其他常见做法(简短说法,便于对比)
- 对象存储/CDN是追加写:
- 带 hash 的静态资源上传到固定前缀,永不覆盖、不清理
- 每次发版只上传新增;HTML 短缓存可覆盖
 
- 原子部署但"只切入口,不清资源":
- /releases/<time_hash>,- /current指向最新 release;assets 不删
- 回滚只切回旧 /current
 
- Web 服务器层"只换指针,不清资产":
- Nginx/OpenResty:/alias 到当前 HTML 目录;/assetsalias 到公共资产池
 
- Nginx/OpenResty:
- 构建/同步策略"追加而非覆盖":
- rsync/脚本:跳过已存在同名文件,只追加;不执行 rm -rf assets
 
- rsync/脚本:跳过已存在同名文件,只追加;不执行 
- CDN 层"天然长留":
- 源站是 OSS,CDN 默认不删历史对象;只刷新 HTML
 
一句话:避免 404 依赖"源站/存储保留旧文件",不是依赖"浏览器缓存"。
9. 我们的流程清单
- 统一路径:页面永远从 /assets加载资源
- 发布顺序:build→deploy:sync(聚合)→ 切换current
- 保留策略:versions保留 10 个档案;assets保留最近 3~5 个版本资源
- 清理策略:仅清理超出窗口的文件;不清空 assets目录
- 观察/回滚:问题时只切换 HTML(current),资源无需变动
 -(可选)SW:/assets 有兜底;HTML/ sw.js 短缓存
10. FAQ(你可能会问)
- 
Q:为什么不用把版本号加进路径(如 /v1.0.5/assets/...)? - A:这样会让相同内容的 URL 不同,缓存无法复用。内容哈希已能区分新旧;版本应体现在"保留与聚合"的策略,不应体现在资源 URL。
 
- 
Q:把聚合池直接放到 dist/build/h5/assets可以吗?- A:可以,但构建时常会清空该目录,易被覆盖。更稳妥是用 dist/assets做聚合池;若坚持放 build 目录,务必在 build 之后再做聚合,并避免之后再执行 build。
 
- A:可以,但构建时常会清空该目录,易被覆盖。更稳妥是用 
- 
Q:磁盘会不会涨? - A:带 hash 的资源相同内容只存一份;保留 3~5 个版本通常增长有限。也可配置自动清理超出窗口的文件。
 
11. 总结
- 真因:404 不是"没缓存",而是"源站删了旧文件"
- 原则:内容哈希 + immutable + HTML 短缓存
- 方法:/assets 聚合最近 N 个版本资源 + /versions 备份入口
- 效果:零 404、缓存最优、秒级回滚、可观测、易维护
当发布频繁、会话长、需要极致稳定体验时,这套工程化方案能显著提升质量与口碑。