这一串事故的共同特征是:服务器一切正常,用户看到的页面是坏的。
01起点:为了“干净”删掉了旧产物
发布时习惯性地 rm -rf 掉静态目录再传新的。逻辑上讲得通——避免残留。
但它立刻制造了一个故障:那些手里还握着旧 index.html(在无 Cache-Control 时期被存下来的)的浏览器,会去请求旧的 hash chunk。旧文件被我删了,请求 404,入口 JS 不执行,页面永远停在 loader。
关键在于 hash 命名的含义:**文件名即内容地址,是不可变的。**老的 index-abc123.js 存在的唯一理由,就是可能还有浏览器要用它。删掉它等于主动切断了老入口的依赖链。
规范第一条:**禁止删除旧的 hash 产物。**改为叠加式发布(不覆盖同名之外的文件,旧 hash 全部保留),index.html 靠 no-cache 自然回源。
02叠加发布自己的坑
cp -rn(不覆盖已存在文件)执行时,会把同名的 index.html 也一起跳过。于是新 chunk 传上去了,入口还是旧的,线上看起来“发布了但没变化”。
所以发布动作必须拆成两步,且第二步不可省略:
① 叠加式上传新产物(旧文件保留)
② 单独强制覆盖 index.html,并校验它引用的每个 chunk 都在位“引用完整性校验”这一步是事后加的:入口指着的文件必须都存在,否则宁可不发。
03两天里的所有误判
现象是“用户登录不进去”。我按顺序怀疑过:
- 登录页预填了模板默认口令 → 确实是问题,改掉;
- 后台模板的密码格式校验在前端就把合法长口令挡掉了 → 请求根本没发出去,也确实;
- 页面卡在 loader → 后来定位到外部字体;
- chunk 404 → 上面那条。
每一个都是真问题,但它们互相掩盖:修掉一个,暴露下一个。中间还反复做过 curl 验证,全绿。
**教训:服务端全绿 ≠ 用户能登录。**用户端多了一个“他手里那份 HTML”的变量,而这个变量在 curl 里永远复现不出来。
其中第 3 条很值得单独讲:<link rel="stylesheet"> 引外部 CDN 字体是渲染阻塞资源,而 module script 必须等它前面的样式表加载完毕才执行。CDN 一旦挂起,入口 JS 永远不会执行——页面卡在 loader,控制台干干净净。改成 media="print" + onload 异步加载,并在代码里注释禁止改回去。
04反复清缓存为什么无效
给 HTML 的 URL 加 query 参数试图强制回源,是无效的:query 只让 HTML 本身回源,HTML 里面引用的 chunk 路径没变,而静态目录配了 immutable 强缓存,浏览器根本不发这个请求。清缓存也救不了,因为问题在缓存策略与文件生命周期的组合上。
05真因:混搭缓存破坏的是模块图
为了补入口,我用了“同名换内容”。这一步制造了最难查的那个故障。
Vite 产物里 chunk 之间是通过稳定 URL互相 import 的。同一个 URL 在不同时刻返回不同内容,就意味着浏览器可能把不同轮次部署的碎片拼在一起:入口是新的、某个异步 chunk 是旧的、另一个是上上轮的。任意两个版本的片段相拼,符号就对不上,于是运行时出现未定义变量、nextSibling 为 null 之类的崩溃。
这就是“混搭缓存”。它的可怕之处在于:
- 服务端单独修改任何文件,都无法拼齐用户手里那份组合;
- 崩溃栈在压缩产物上,几乎没有可读性;
- 不同用户表现不同,取决于各自缓存了什么。
跨 chunk 的模块图是一份整体,不能局部修补。
06二次事故:加 query 让入口执行两次
在改用“全新目录”的方向上,我又犯了一次更隐蔽的错:给 HTML 里的资源引用逐个加了版本 query 参数,以为这样能彻底绕开缓存。
但 chunk 之间是相对路径互相 import。带 query 和不带 query 的同一文件,在浏览器模块系统里被视为两个不同模块。结果同一份入口代码被执行两次:两个 Vue 运行时、两次挂载,重复 patch 同一个容器 —— 页面崩溃。
结论很硬:**cache-busting 必须在目录 / base 级别整体统一,不能在 HTML 上逐个打补丁。**给资源 URL 加参数,会同时改变“模块身份”。
07最终方案
① 取一版"用户浏览器实测能渲染"的原始产物,发布到全新的 base 目录
② 站点配置里把根路径 rewrite 到新目录的 index.html
—— 地址栏不变,但资源 URL 全新,缓存等于零
③ 去掉逐个 query,保证入口模块图的 URL 唯一第 ① 步里“排除构建环境差异”很重要:新构建的产物即使逻辑相同,也可能因为打包环境不同而不可比对。出问题时先拿已验证可用的原始产物恢复服务,再单独分析新构建。
动态 import 是相对路径,所以整体换目录后自动跟随,不需要改内部引用——这也是为什么“整目录级”方案是干净的。
08顺手记两条静默失败
- 组件漏登记:可视化组件按需在入口注册,新面板用了没注册过的标签 → 面板位置空白、没有任何报错。曾经靠“回滚到全量注册版”掩盖了这个欠账。防回归手段是发布前扫源码里所有相关标签,与注册清单比对。
- SPA history 刷新 404:后台站点没配
try_files,直接访问子路由就 404。这类问题的表现是“链接分享出去打不开”,很容易被人误当成缓存问题。
09拓扑带来的排障限制
后端只监听应用机的 127.0.0.1,公网入口在另一台机器上经 HTTPS 反代 + SSH 反向隧道转发。两个副作用:
- 公网访问日志里的来源 IP 全是
127.0.0.1(隧道转发),所以“同一 IP 失败次数”这类防爆破阈值实际上是全局共享的,误锁时得清登录日志或等窗口滑过; - 验证公网 URL 只能在那台公网机上 curl,或者用浏览器——集群内部发起的公网请求必然失败,这会让人误判成服务挂了。
10相关文章
- 静态资源怎么和博客共存:把大屏静态部署到子域名:Caddy 与博客共存
- 项目全貌:HWLens:硬件行情湖仓数据平台复盘