MMHXYZ 技术笔记

用体积和类名计数做发布门禁:一次"能打开但全错"的事故复盘

2026-10-26 · 工程实践 · 约 4 分钟

这次事故的结局是线上界面全部退回原始样式,持续了大概半小时。但真正值得记的不是故障本身,而是我用来"修复"的那一步,让问题从一个明显故障变成了一个隐蔽故障。

时间线

18:41 推了一个版本。加载白屏。定位到 JS 里第 2819 行有语法错误——一个多余的括号。

18:46 为了尽快恢复"能打开",我把 JS 换成了安装包里自带的那一版。语法没问题,应用正常启动,页面正常渲染,看起来修好了。

18:50 收到反馈:阵容、仓库、商城的样式全变了。

原因:安装包里自带的那版 JS 是另一套代码——一个功能精简的旧分支,体积只有正常版本的三分之一。它里面根本没有 ui-sec、ui-card 这些类名,而外部有一份 22KB 的 CSS 专门给这些类名做皮肤。类名不存在,CSS 全部落空,界面自然回原始样式。

语法检查只能证明"这不是坏文件",不能证明"这是对的文件"。

为什么常规手段没拦住

  • node --check 通过 → 因为那确实是一份合法 JS
  • 应用能启动 → 因为它确实能跑
  • 页面能渲染 → 因为它确实渲染了,只是渲染的不是我的界面

三个检查全部通过,全部没用。

门禁应该盯结构,不盯"能不能跑"

出事后我把发布前的检查改成下面几项,全部是几秒钟内能跑完的廉价检查:

1. 语法

// 用 child_process 跑,不 eval —— eval 会真的执行代码
execFileSync('node', ['--check', file]);

2. 体积阈值

我的主 JS 正常在 350KB 上下。精简版是 103KB。所以门禁里写:

size = os.path.getsize(js)
if size < 350 * 1024:
    fail("game.js 只有 %d B,低于 350KB —— 极可能是被替换成了精简版" % size)

阈值不是猜的,是取正常版本的体积再打个折。它不精确,但足够区分"我的代码"和"别人的代码"。

3. 结构指纹

比体积更可靠的是数关键标识符出现的次数:

for token in ("ui-sec", "ui-card", "team-presets"):
    n = content.count(token)
    if n == 0:
        fail("缺少 %s —— 这份文件不是当前分支的产物" % token)

这三项在正常版本里都有几十次,在被误替换的那个版本里全是 0。

关键点:指纹要选"只在正确版本里存在"的东西。 选 function、var 这种到处都有的词没有意义;选具体到你这套 UI 的类名才有区分度。

4. 文件大小对不上也要拦

打包流程里最容易出的事是"根本没打进去"。所以在构建脚本末尾加一条:源文件和目标文件的字节数必须完全相等,不等就退出。

事故的真正教训

不是"要写好代码",而是:

救火的时候,人会本能地选"最快能让它动起来"的方案。这时候如果没有机械化的门禁,你一定会把 A 故障换成 B 故障。

我当时的选择在紧急情况下是合理的——线上白屏,任何能启动的版本都比白屏强。但正因为知道自己在那种状态下会做什么,才需要把检查写在脚本里,让它在人判断力最差的时候强制生效。

所以门禁的价值不在于"发现 bug",而在于在你最想抄近路的那一刻挡住你。

顺带一条:只留一个能跑的版本

那次之后我还做了一件事——把发布目录里所有历史包全部移到归档目录,只留当前线上那一个。

理由很直接:目录里堆着五个版本,救火时随手拿了哪个,自己都不一定记得。版本一多,快就等于错。

现在我的发布目录长这样:

deploy/updates/
├── pack-current.zip        # 只有这一个
├── manifest.json
└── _archive-20261026/      # 历史包都在这儿,不在发布路径上

小结

检查项写成三个层次:

层次检查能拦住什么
语法node --check坏文件
体积字节数阈值被替换成别的版本
结构关键标识符计数被替换成同语言的其他分支

第三层才是这次真正缺的那一环。

← 返回全部笔记