消失的 body | 诡异 BUG 侦查纪实

468 阅读19分钟

上穷碧落下黄泉,两处茫茫皆不见。 ——白居易《长恨歌》

楔子

历时一个多月,前端团队完成了新版 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 发生变动时,才重新运行内部计算逻辑。如同基于变量的值,拍下一张“快照”;依赖不变,快照也不会更新。

这有两点优化效果:

  1. compute 可能存在的复杂计算开销,复用快照就能节省计算资源;
  2. 维护了计算结果 result 稳定,当它是引用类型时尤为有用,避免了消费它的副作用方法或子组件出现潜在性能问题。

然而这种使用范式,可能导致一种“闭包陷阱”问题。

所谓“闭包陷阱”,是指由于 useMemo 这类 API 在声明时,依赖数组内的元素不够完整,导致内部计算时未能使用某个最新的值,也就是快照停留在了一个过时的版本。

现在,目光聚焦在 Floating 内的案发现场:

useMemo(() => {
  return createPortal(<div role="float"></div>, document.body) // 引用了 document.body
}, [...]) // 依赖数组中并不包含它

从代码看,createPortal 的调用依赖于 document.body。按照”闭包陷阱“的逻辑,如果 body 某一时刻不存在,而这个 useMemo 又因为依赖项没变而没有重新执行,那它是否会一直使用最初捕获的(已消失的)body 引用呢?

但经过推演,我们很快排除了这种可能:

  1. 时序问题:js 脚本是 defer 的,意味着首次执行时 body 必然存在。因此 useMemo 首次捕获的 body 引用是正确的。
  2. 无效响应:即便将 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.removeChildinnerHTML 等关键字进行审查,很快,内部代码的嫌疑被排除。

如果不是我们自己的代码,难道是某个依赖导致的?但是在茫茫多的 node_modules 目录中搜寻,无异于大海捞针。

我们一时陷入僵局,看来需要换个策略。

由于缺少证据,我们决定对 document.body 添加监控,上报它被移除时的事件埋点。在搜集到更多相关信息后,进行进一步排查。

考虑可能移除掉 body 的方法,我们 patch 了全局的 API,如 Element.prototype.removeElement.prototype.replaceWith,劫持 Element.prototypeinnerHTML 属性修改方法等。

虽然可能导致应用性能的部分下降,但这是必要的取舍。

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: observedbodyRemoved 事件,没有任何“行凶痕迹”。

有人在我们眼皮底下,用一种未知的方式移走了 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 + Icmd + 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 皆诡秘,慧眼尚需用心坚。