上穷碧落下黄泉,两处茫茫皆不见。 ——白居易《长恨歌》
楔子
历时一个多月,前端团队完成了新版 Web 应用的重构与发布。然后是几日的大盘跟踪,解决各种 ErrorBoundary 收集到的问题。
处理完最后一个明显的 ResizeObserver 的兼容性问题后,我注意到了这条最近出现的高频报错:
Minified React error #200
这是 React.js 在生产环境的压缩错误代码,对应的原文是:
Target container is not a DOM element.
指的是 react-dom 进行元素挂载时,无法在页面上找到目标元素。
这类错误本身并非少见,但是由于它已经成为了大盘错误的榜首,我考虑着还是仔细追查一下为好。只是不想,这竟是一次离奇的 BUG 侦查之旅的开端。
奇案初现
看到这条报错,自然先查找它所在的业务源码。目前 sourcemap 未与大盘打通,不过也只是稍微多费功夫。
错误栈大致如下:
Error: Minified React error #200
at https://example.com/default-vendors.29a168ecb3.js:1:583624
at V (https://example.com/main.21210d41c1.js:1:15422)
at ...
基于项目的打包配置,主要的 node_modules 依赖被打包到 default-vendors.js 中,也包括 React 代码;而项目的主入口代码则被打包为 main.js。也就是说,这就是直接引发报错的业务代码,或者说,它位于业务调用栈的最后一层。
下载错误栈里的 main.js 文件,用 vscode 打开。使用跳转功能,来到错误栈中的行列号 :1:15422,代码如下:
..."undefined"==typeof document?null:(0,s.createPortal)(ex&&!S"keepIfIsOpen"===I&&K?(0,a.jsx)("div",{className:"".concat(h.F,"-floating-container")...
虽然被压缩过,但是也能看出,出错的,是 createPortal 方法。
而基于标志性的 -floating-container 字符串,立即可以在代码库中查找定位,这是 Floating 组件的编译产物。
Floating 组件相关源码如下:
const floatingNode = useMemo(() => {
if (typeof document === 'undefined') {
return null
}
return createPortal(<div role="float">...</div>, document.body)
}, [...])
结合 React 的报错信息,可以推断出:
在执行 createPortal 方法,将 Tooltip 等组件挂载到页面上时,document.body 竟然离奇消失了。
失踪的 body
对各种非标准写法的 HTML 页面结构,现代浏览器都做了极广泛的兼容。但是作为生产环境的项目,我们仍然遵循着普遍通用的方式构建 WEB 应用。
一个使用 React 构建出的 HTML 页面结构通常为:
<html>
<head>
<!-- 各类 meta, style,延迟执行的 script 标签等 -->
</head>
<body>
<div id="root">
<!-- 最初的 #root 元素作为 React 的挂载点,实际应用会被挂载至它内部,作为子元素 -->
</div>
</body>
</html>
而 Floating UI 这类工具库,会将自己创建出来的 DOM 元素挂载到 #root 元素之外,如:
<div id="root">...</div>
<div role="float">...</div>
为了实现绝对定位且跟随特定元素,且防止被应用主体的布局样式干扰,就需要使用额外的挂载点。
我们面临的,正是当 Floating 组件创建了 ReactNode,告知 React 要将对应真实 DOM 挂载至 body 时,却意外发现 document.body 已然不存在。
要知道,即使是业务代码中立即执行的 JavaScript,经过打包工具构建后,最后生成的脚本也会被添加 defer 属性:
<head>
<script defer src="https://example.com/main.js"></script>
</head>
<body>
...
</body>
defer 属性表示,代码需要等待文档解析完成后执行。即使尚有未完成加载的资源,但 body 元素一定是存在于页面上的。
对照埋点,细查出错用户的行为日志,也都是在页面加载并运行了一段时间后才遇到这个错误。这一切都说明,这似乎并非简单的时序问题。
低级错误?
由于 React 采用声明式函数组件架构,每次页面上出现数据状态变动,都依赖对应的组件树或局部、或全部地重新运行,获得渲染结果。而这些数据状态的维护,则与组件树的结构、组件内的声明顺序紧密绑定。
出于性能考虑,我们希望每次数据更新时,尽量只让真正状态变化的组件实例重新执行,进而避免产生额外计算开销。
React 提供了一系列针对性的优化函数,而 Floating 组件正使用了最广为人知的 API:useMemo。
const result = useMemo(() => compute(depA, depB), [depA, depB]);
这个方法让其中的计算过程可以忽略组件频繁的重新渲染,仅在依赖数组内的 depA, depB 发生变动时,才重新运行内部计算逻辑。如同基于变量的值,拍下一张“快照”;依赖不变,快照也不会更新。
这有两点优化效果:
compute可能存在的复杂计算开销,复用快照就能节省计算资源;- 维护了计算结果
result稳定,当它是引用类型时尤为有用,避免了消费它的副作用方法或子组件出现潜在性能问题。
然而这种使用范式,可能导致一种“闭包陷阱”问题。
所谓“闭包陷阱”,是指由于 useMemo 这类 API 在声明时,依赖数组内的元素不够完整,导致内部计算时未能使用某个最新的值,也就是快照停留在了一个过时的版本。
现在,目光聚焦在 Floating 内的案发现场:
useMemo(() => {
return createPortal(<div role="float"></div>, document.body) // 引用了 document.body
}, [...]) // 依赖数组中并不包含它
从代码看,createPortal 的调用依赖于 document.body。按照”闭包陷阱“的逻辑,如果 body 某一时刻不存在,而这个 useMemo 又因为依赖项没变而没有重新执行,那它是否会一直使用最初捕获的(已消失的)body 引用呢?
但经过推演,我们很快排除了这种可能:
- 时序问题:js 脚本是
defer的,意味着首次执行时 body 必然存在。因此useMemo首次捕获的 body 引用是正确的。 - 无效响应:即便将
document.body加入依赖数组,因为它是一个 DOM 节点引用,而非 React 内在数据链条上的一环,其本身的变化(比如被移除)也并不会触发 React 的重新渲染。useMemo的依赖对比感知不到这种变化,自然也不会重新执行。
不过这一切只是推理,我们决定添加埋点进行验证。
const body = document.body;
const finalPortalEl = body instanceof HTMLElement ? body : null;
if (finalPortalEl === null) {
report({
name: "floatingPortalFailed",
info: `${String(body)}, ${document.querySelector("body")}, ${
globalCount++ > 4
? "reported"
: document.documentElement.innerHTML.slice(-1900)
}`,
});
return null;
}
return createPortal(<></>, body);
之后查看线上获得的 floatingPortalFailed 事件。果然,info 里 body 的值是 null,而页面的 innerHTML 只剩下了 head 标签,没有 body 的部分。
这样看来,在用户使用时,真的发生了 body 先存在、后消失的过程。
广撒网
很显然,有“人”干掉了 document.body。
我们第一时间将目光聚焦到内部代码上。代码库是基于 rush.js 搭建的 monorepo,其中的主要代码、尤其是组件库,都是从零自行构建;而项目依赖,也秉持宁缺毋滥的原则,只挑高质量且必需的库。这使我们的系统构建完全自主可控,不存在太多质量无法保证的外部代码。
在代码库中搜索 .body、.remove、.removeChild、innerHTML 等关键字进行审查,很快,内部代码的嫌疑被排除。
如果不是我们自己的代码,难道是某个依赖导致的?但是在茫茫多的 node_modules 目录中搜寻,无异于大海捞针。
我们一时陷入僵局,看来需要换个策略。
由于缺少证据,我们决定对 document.body 添加监控,上报它被移除时的事件埋点。在搜集到更多相关信息后,进行进一步排查。
考虑可能移除掉 body 的方法,我们 patch 了全局的 API,如 Element.prototype.remove,Element.prototype.replaceWith,劫持 Element.prototype 的 innerHTML 属性修改方法等。
虽然可能导致应用性能的部分下降,但这是必要的取舍。
const originalRemove = Element.prototype.remove;
Element.prototype.remove = function (...args) {
if (this.tagName.toLowerCase() === "body") {
// 执行上报
report({ name: "bodyRemoved", from: "remove", info: new Error().stack });
}
return originalRemove.apply(this, args);
};
使用 new Error 构造出调用栈,来追踪行凶的嫌疑人。
同时,为 document.documentElement 也就是 html 元素,添加 MutationObserver,监听它的子元素事件,判定是否是 body 被移除。
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === "childList" && mutation.removedNodes.length > 0) {
mutation.removedNodes.forEach((node) => {
if (
node instanceof HTMLElement &&
node.tagName.toLowerCase() === "body"
) {
// 执行上报
report({
name: "bodyRemoved",
from: "observed",
info: new Error().stack,
});
}
});
}
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: false, // 不监听更深层的,因为 body 是 html 的直接子节点
});
监控部署完毕,只等异常再次出现。
疑云深重
很快,我们得到了新的证据。
MutationObserver 成功捕获到很多 bodyRemoved 事件。然而由于接口本身的限制,这个观察者并不能收集到移除 body 的代码调用栈。它只能预警,没有更多信息。
但令人困惑的是,基于代码调用的 body 移除,监控没有取得任何进展。
须知 JavaScript 代码,要移除页面元素,一定是通过特定 API 实现的。即使是浏览器插件,抑或油猴脚本,归根到底也都是通过向浏览器页面提交代码并执行,才能达到修改页面内容的目的。
而浏览器的 DOM 操作途径是有限的。我们基于经验,并咨询了主流 AI,已经将各种可能的移除 document.body 的 API 都添加了监控,竟无一所获。
但是这一步验证,已经很大程度上排除了页面自身代码的可能性。body 被莫名删除,更可能是外部因素导致。但目前错误列表内,仅有来自 MutationObserver 收集到的标注为 from: observed 的 bodyRemoved 事件,没有任何“行凶痕迹”。
有人在我们眼皮底下,用一种未知的方式移走了 body——宛如一桩“完美犯罪”!
但是 body 都被删除了,难道用户还能正常使用吗?重重疑惑之下,我们检查了用户的使用日志,发现果不其然,一旦出现了 bodyRemove,少则片刻,多则几分钟,用户就会重新刷新页面。这不是单纯的代码层面的错误,而是真实影响用户使用的问题。
再查。
基于最初的 #200 错误进行筛选,我们惊愕地发现:这个错误从网页上线、埋点系统开始上报数据以来,从未断绝,每天最多可能有几百个用户会遇到。
接近一年多的时间,每天几百人遇到这个异常的白屏问题,竟无一人反馈?
事情似乎在朝着我们从未想过的方向发展。
黑产?自动化?浏览器插件?
无数想法和猜测,让我们感觉迷失了方向。
苦思冥想
一时没有更多信息,我们还是需要不断翻查已经收集到的行为日志、出现异常的用户画像,希望能发现更多蛛丝马迹。
同事发现,这些出现异常的用户多是学生、教师,难道这群人有什么普遍使用的软件或浏览器插件,会导致 body 被无故删除?
不对,这实际上是我们主要用户群体的画像。即使是所有用户同等概率地出现问题,那出现问题的大多数人也是学生教师群体,而并非学生教师更容易出现问题。
会是灰产吗?我们的产品以提供信息为主,用户输入问题获得答案,也曾遇到过黑灰产逆向网页进行接口盗卖的情况。难道是黑灰产的自动化流程出现了 bug,不小心把 body 删除了?
日志系统的数据被保存在更底层的 ClickHouse 数据仓库中,我使用 SQL 查询了最近上报的所有 bodyRemoved 事件,以及相关的其他信息。通过数据处理,提取出去重了的 UserAgent 字段——浏览器的身份标识——然后提交给大模型进行分析,希望能有所启发。
然而分析结果表明,这些异常的分布非常“均匀”,涵盖了各种主流的浏览器品牌、操作系统,Chrome、Edge、Safari、Yandex 浏览器,Apple、Windows、Android 设备,浏览器版本号也跨度极大。甚至于,我们还看到了有用户在 iPhone 里,用欧路词典应用的内置网页浏览器打开页面。
这与通常的黑灰产特点背道而驰。毕竟黑灰产需要批量作业,让开发环境尽可能保持一致、降低不同环境带来的适配成本,才是更为明智的做法。
另一方面,我将这些用户的 IP 地址提交给了我们的运维同事。他使用 Kibana 系统查询了这些 IP 对应的后端系统日志,观察用户的行为是否符合正常用户的轨迹。
“是真人用户。”——他只看了十分钟就下定结论,这些 IP 对应的接口使用情况符合典型的用户使用场景。
交叉验证之下,黑灰产的可能性也基本被排除。
无奈,我们再一次反复翻找用户出问题的日志。
不久,同事又有了新的发现:“奇怪,他们的页面变窄了。”
页面的宽高信息是事件上报的公共属性,通常我仅用来看一下用户大概是在移动端还是桌面设备上操作。在一长串事件构成的行为流里,这个信息通常并不怎么被关注。
我连忙验证。果然,用户在发生 bodyRemoved 事件时,屏幕宽度比前序的其他操作时有明显变化,例如页面打开时明明是 1920px,但出问题时却是 1365px,宽度缩窄了 555px。一连查看多个出现该问题的用户,我们发现似乎所有人都有这个现象。
页面宽度变窄,这很像浏览器扩展或者开发者工具被打开时产生的行为。但是目前证据不足,我们还需要更多线索。
转机
一筹莫展之际,我的同事从故纸堆中翻出来一个已经废弃的 API:DOMNodeRemovedFromDocument。这是一个古老且已被现代标准废弃的 API,但它有一个特点:能提供移除操作的调用栈。
当下是 2025 年 9 月,Chrome 浏览器的版本号已来到了 140 之多,而这个 API 只在 126 以下的版本才生效。但经过在低版本的测试电脑上简单验证发现,它的有效性与 MutationObserver 一致,能检测到各种途径删除 body 的现象。而且最重要的,它确实支持了删除调用栈的追溯。
于此同时,我考虑到即使无法立即查明问题原因,也是时候尝试修复这个问题了。
做法很简单,提前保存 document.body 到全局变量上,一旦发现了 body 被神秘删除,就将它放回原处。
let body = document.body;
if (!body) {
const onLoad = () => {
body = document.body;
};
document.addEventListener("DOMContentLoaded", onLoad, { once: true });
}
// 在 MutationObserver 回调中的恢复代码
// 由于测试发现这个回调可能发生在实际 body 离开 html 前,因此通过 setTimeout 等待下一个宏任务回调
const onBodyRemoved = () => {
setTimeout(() => {
document.documentElement.appendChild(body);
report({ name: "bodyRestore" });
}, 0);
};
本地测试:通过控制台删除 body 元素,页面会闪动一下,滚动位置被重置了,但是好在页面内容还在,还算能正常使用。
上线了 body 恢复逻辑与新版上报代代码,我们在煎熬中等待着旧版浏览器用户的异常行为上报。
数据来了。
收集到了一些新增事件。果然,bodyRestore 紧跟着 bodyRemoved 出现了,而且用户没有后续刷新页面的迹象,看来抢救起到了一定效果。
另一方面,我们苦等的 DOMNodeRemovedFromDocument 事件捕捉到的 bodyRemoved 也出现了,然而结果不尽如人意。虽然这次确实收集到了一部分调用栈,但是奇怪的是,有的代码调用的源头来自 index.html 文件,有的则是 anonymous。
这种调用栈的形式,基本彻底否定了浏览器扩展的可能性,毕竟浏览器扩展也在操作页面 DOM 时,也必须通过注入的代码执行来实现,一定会留下指向特定代码的痕迹,而不会出现源头是 html 文件这种奇怪的记录。
那么,剩下的可能还有:自动化,以及通过浏览器的开发者工具操作。
但从之前的调查信息来看,自动化的可能性很低,那么当下的重点就聚焦在了控制台操作上。
此前我们发现,页面宽度会在发生 body 删除时诡异地减少 550 左右像素,而且很多情况下是 555px 这个非常规则的值。
我们通过网络搜索,果然发现了 Chrome 浏览器打开开发者工具时,默认宽度就是 555 像素。那么基本可以确定,body 被删除的源头是开发者工具。
但是为什么?
这些人看起来都是普通用户,主动打开控制台的可能非常低,我们百思不得其解。
虽然有了推论,但还是需要进一步验证。我们调研了流行的开发者工具探测库,最终选定了一个实现复杂但是相对可靠的工具 devtools-detector。
简单阅读了它的源码,存在多种判定方式。一个可靠的途径是依赖 console.table 输出大型的复杂对象,并计算耗时来判断。而且为了能及时检测到结果,这个工具每 500 毫秒就执行一次探测。
这种做法对页面的内存、性能都有不小的影响。但是为了探明真相,我们不得不在短时间内做出一定妥协。
接近真相
再次收集到新数据,我们迫不及待地查看。是真的,这些删除 document.body 的用户,果真都在在删除前打开了开发者工具!看来,很有可能是用户自己打开了开发者工具,然后删除了内容。
但是为什么,是什么原因导致了用户执行这种操作?从之后页面继续使用的情况来看,删除页面内容并非用户本意。
再仔细排查了几个用户的日志后,我们发现大部分用户都只是进行了单次的页面内容删除,然后被新加入的 body 恢复机制拯救回来。然而也出现了极个别情况:用户反复删除 document.body,即便页面内容恢复回来,还是会快速地进行删除操作,而且多次删除之间的间隔很短,可能只有不到一秒钟。
这种反常行为让我们困惑不已。
下一步,我们决定继续增加埋点:尝试确认用户是通过何种方式打开了开发者工具。
尽管仅凭我自己了解,打开开发者工具的方式就有不下六七种:如 Mac 上的 cmd + option + I、cmd + shift + C、右键上下文菜单里的“检查”等等,但是最广为人知的恐怕还是 F12 这个快捷键。
于是我们决定为按下 F12 和打开上下文菜单的操作添加埋点上报。
日志数据更新。果不其然,这些删除了页面元素的用户,无一例外,都是通过按下 F12 快捷键执行了操作。
但是为什么,究竟是什么在驱使他们这样做?难道有什么超自然的力量吗?一些滑稽的想法不由得浮现在我脑海中。
真相只有一个
这个问题,也被周围的同事关注到。
大部分人表示无法理解。然而,总有人因为独特的思路、或是奇特的经历,会帮你找到方向。
“应该是删除键。用户删除输入内容的时候,不小心按到了 F12。”一个同事给出了自己的推论。
就像阳光划破黑夜,我们突然感到豁然开朗。
多么自然的推论!一切都说得通了。
通用的 QWERTY 键盘布局上,F12 按键常与用于删除的“backspace”退格键挨在一起。如果用户快速多次按下删除,确实很容易误触到 F12。
───┬─────┬─────┬─────┐
F9 │ F10 │ F11 │ F12 │
───┴─────┴─────┴─────┘
─┬────┬────┬─────────┐
│ - │ = │Backspace│
─┴────┴────┴─────────┘
┬────┬────┬────┬─────┐
│ P │ [ │ ] │ \ │
┴────┴────┴────┴─────┘
为了验证这个猜想,我们迅速上线了一个按键序列记录功能,将 F12 按下前的几次按键、时间记录下来。
数据来了。
果不其然,大多数用户在按下 F12 之前,都有或多或少的删除按键操作。
这个困扰我们数日的悬疑 BUG 就此告破:用户在输入框中快速按下删除键时,不小心碰到了临近的 F12 按键,导致浏览器的开发者工具被打开;Chrome 的开发者工具默认会定位在“Element”标签页,用于审查页面元素,而且默认会选中 body 这个元素。此时用户可能尚未反应过来,习惯性地继续按下删除键,进而导致了 body 元素被删除。
这也解释了为什么一年多时间以来,成千上万个用户遇到了 body 元素删除,却无一反馈——可能在看到开发者工具后,用户立即反应过来是自己操作有误,因而后续的页面异常也能容忍。
善后
事情原因查明了,我们将中间加入的一系列临时代码进行了清理,来恢复页面的性能表现和纯净。
另外,为了防止用户把自己误伤,我们为输入框统一添加了按键拦截处理,让用户在输入框内无法通过按下 F12 按键打开开发者工具。虽然影响了部分使用场景,但对主流用户而言,利大于弊。
在最新的版本上线后几天,body 删除的错误大幅减少,相关的报错也降低到了历史低点。
这次事件,可以宣布圆满解决。
结尾
行文至此,通常需要再结尾提供一些总结和启示,但思来想去,未免流于说教。其中的曲折与顿悟,不如就留给各位读者自行品味。
但事已至此,我不由得想致敬“大爱仙尊”一书,留下一首打油诗,权当博君一笑。
这正是:
键盘轻点起祸端,F 十二隐玄关。
退格键旁藏杀意,控制台前现迷烟。
碧落黄泉苦寻迹,监听观测布网难。
莫言 BUG 皆诡秘,慧眼尚需用心坚。