~/blog/frontend-debugging-misdiagnosis
工程2026-10-13 · 9 min

「我这边是好的」:前端排障八次误诊与翻案

水合崩溃凶手是 HTML 压缩,随机 removeChild 是浏览器翻译,旧行为是端口被占——八个真实误诊,附识别误判的方法。

前端排障有一种特有的痛苦:报错信息和真实根因之间隔着一条因果链,而链条的第一环几乎总是错的。这篇笔记记我们在开发 ffmpegtool.com 时印象最深的八次误诊。每个案例都保留了"当时为什么信了错误答案"——识别自己的误判模式,比记住任何单个 bug 都值钱。

1. 全站水合崩溃:凶手是 HTML 压缩

构建产物在浏览器里报水合错误(React #418/#425),首页 12 个、博客页 12 个。先怀疑 react-markdown 的输出,再怀疑双语相邻文本节点,都不对。最后用 PNG diff 对比预渲染产物和客户端首帧,收窄到预渲染管线的 html-minifier:collapseWhitespace 会重排文本节点的空白(· 变成 ·),而 React 水合对文本节点做逐字节比较。这个选项现在在我们 prerender 脚本里永远关闭,注释就写着原因:

// 绝不开 collapseWhitespace。React 文本比对是逐字节的,任何空白折叠都会改动文本节点

SSR 管线的每一道后处理——压缩、转换、注入——都是水合保真的嫌疑人,它们排在业务代码之前,最难被怀疑。

2. 生产随机崩溃:真凶是浏览器翻译

用户报生产环境 removeChild 崩溃。我们当时的判断是 /en 双语站的相邻文本节点触发水合恢复,还写了回归脚本验证——脚本报告 28 个路由 0 错误。问题出在这个脚本有两处盲区:只监听 pageerror,漏掉通过 console.error 输出的可恢复水合错误;错误匹配的正则没覆盖 removeChild。是用户一句话点破的:"我用 Edge 的浏览器翻译功能时就会报错。"

翻译功能在运行时改写 DOM,React 持有的节点引用全部失效。修法是 <html translate="no"> 加 <meta name="google" content="notranslate">。

回归脚本要先拿已知能复现的用例证伪,再让它下"没问题"的结论。我们的探针报告了绿色,但探针本身没探到东西。

3. "我改好了"和用户测到的旧行为:5173 的幽灵

连续三次:我们端到端验证通过,用户本地测永远是旧行为,看起来像浏览器缓存。真因是旧 dev server 的孤儿进程占着 5173,重启时 Vite 发现端口被占就悄悄落到 5174,用户访问的 localhost:5173 一直由旧实例服务。旧实例的 HMR 甚至正常弹更新,迷惑性极强;curl 返回 200,更让人以为服务没问题。

后来"改了没生效"成为独立排查项:先 netstat -ano | grep :5173 看端口在谁手里,再谈别的。

4. WMV 预览"时好时坏":其实是文件不同

用户报视频预览变灰,时好时坏,怀疑我们改了什么。排查过 HMR 半更新、blob URL 被提前 revoke、HEVC 编码,全错。用户贴出文件名:..._原画.wmv。WMV/VC-1 是微软私有编码,Chrome 和 Edge 都没带解码器,灰块是必然结果。"时好时坏"是因为能播的测试文件是 H.264,灰的都是这个 WMV。

有意思的是这个文件的处理路径:预览灰块,但 ffmpeg.wasm 转码成功——wasm 引擎的解码器白名单和浏览器不是一套。预览不了不等于处理不了。后来 video 的 onError 会显示"浏览器无法预览此编码(常见 WMV/HEVC),不影响处理"。

5. favicon 永远刷不出新的

换品牌图标部署之后,用户刷新看到的还是旧的。线上文件和本地构建产物逐字节一致,响应头只有 ETag/Last-Modified,没有 Cache-Control——问题不在这里。favicon 是浏览器缓存里最顽固的一类:它的缓存独立于页面缓存,Ctrl+F5 也清不掉,因为 favicon 请求不随页面刷新重发。无痕窗口一开就是新版。

自然的修法是给 icon 的 href 加版本戳强制换 URL,这类静态资源通用的手段。

6. Bing 的 too-short 阈值不是显示截断宽

全站中文 meta description 按"不超过 160 显示宽,约 80 汉字"的显示截断规则写。Bing 站长工具点名 16 个页 description too short——它的阈值是 100 个字符(unicode 计数),5495 字符的描述全中招,没被点名的页其实也在阈值之下。25 条中文描述重写到 100115 字符(前 80 字保证自洽,尾部放长尾词),e2e 门禁同步加了 100 字符下限的断言。

SEO 工具的校验阈值和 SERP 的显示截断是两套规则,要分别满足。顺带发现百度对 description 的推荐更长(200 字节以上),同一套文案两头都照顾到。

7. nginx 少一行配置,后台页面 404 了很久

顺手核对线上时发现的:/admin/issues 直访 404,/admin/analytics 正常。后台是 CSR 路由,没有预渲染 HTML,依赖 nginx 的精确回退 try_files /index.html =404。配置里只写了 /login、/admin/feedback、/admin/analytics 三条——加新页面的时候没人想起同步 nginx。这个页在线上坏了一段时间,只是没人直接访问过它。

CSR 路由的 nginx 回退清单是新增页面 checklist 里最漏的一项:它不在代码里,code review 扫不到。

8. "自动安装"三个月,一次都没装过

部署脚本有一段增量逻辑:requirements.txt 变了才 pip install。比对方式是 md5——而 md5 的读取放在 tar 解压之后。解压已经把服务器上的 requirements.txt 覆盖成新版,md5 必然相同,懒装永远跳过。直到某天装 playwright 漏了、线上实况图静默降级才暴露。修复是把 md5 读取挪到上传之前。

"变了才做"的增量逻辑,要在第一次真的变化时验证一次,否则它可能从来没工作过。

附:两个复用性强的 headless 测试技巧

Chrome for Testing(headless Chromium)不带 H.264 解码器,mp4 测试视频会 videoWidth=0 卡死。测试视频一律用 VP8 WebM(-c:v libvpx)合成。

不造长素材也能测多窗逻辑:evaluateOnNewDocument 里 hook HTMLMediaElement.duration 把 2 秒视频骗成 12 秒,再 hook Worker 的 postMessage 数 EXEC 次数。12 秒除以 5 秒窗长得 3 次 exec——分段抽帧真的分了窗,这是决定性证据,不用等一个真的 5 分钟视频。

最后

回头看这八次,根因落在出错系统之外的占多数:压缩器、浏览器翻译、孤儿进程、nginx、部署脚本。报错在前端,因果链的第一环往往在构建管线、部署配置或浏览器自己的隐秘行为里。与其记这些 bug,不如带走两个动作:先验证探针,再相信它的结论;先枚举系统边界(构建、部署、浏览器、缓存),再怀疑业务代码。

RELATED TOOLS · 把文章里的东西用起来