通俗易懂的React原理(番外篇之三):又遇组件卸载

代码以React v19.1.0为例,https://github.com/facebook/react/tree/v19.1.0 。此版本React的RSC功能近期被发现有致命漏洞,我们研究源码不涉及相关功能,但此版本应该严禁在生产使用。

这一篇写得随意一点,不打算贴太多源码。与其说是技术文章,更像是一个排坑故事,记录我为了查清这个 bug 花了整整两天时间,最后终于定位的过程。

没想到吧,这次遇到的 bug,和《番外篇二》居然是同样的现象,只是原因完全不同。还是商品卡片,还是点击之后不能展开。

事情的起因:换项目、同一个问题再次出现

写《番外篇二》时,我在开发我们的一个 npm 组件包。第一个项目接入后运行正常,上线也一切顺利。

这段时间我在做第二个项目的技改,也接入了这个 npm 包。结果测试突然告诉我:

“那个商品卡片又打不开了。”

我心里立刻咯噔一下:八成又是组件被卸载了。

可奇怪的是,那段逻辑在旧项目已经修过了,为什么在新项目又出现?更离谱的是,旧项目在生产环境正常运行,新项目却出问题?

我先去测试环境确认了问题,再把接口数据复制到本地跑一遍——本地竟然完全正常。

那一刻我真的想骂人:这 bug 居然只在生产复现,开发环境完全没问题?这查起来简直折磨。


两条思路:代码 vs 差异

排查这种问题一般有两条路径:

  1. 纯从代码逻辑下手:排查组件被卸载的原因
  2. 从问题特征下手:旧项目正常、新项目异常;开发正常、生产异常。对比区别点,排查是什么区别导致了一侧有问题,一侧正常的特征

第二条路可行,但差异太多、不好判断,也不好验证。
我更倾向第一条,因为代码不会骗人。只要理解原理,抓住能确定的现象,迟早能逼出真正的矛盾点。抓着矛盾点不断深入,一定能找到真相


第一轮排查:console.log 也被工具坑

我照老办法,在组件里加 useEffect(() => { return () => console.log() }) 看哪些组件被卸载。

结果我们组件库的打包工具会把 console.log 全部删掉……

我当时真的懒得再去研究打包工具了,这玩意我半年前看过一次,当时改了点小问题。但现在打包工具又升级了,再啃一遍实在没精力。

于是用了一个小技巧:

1
2
const myConsoleNoDrop = console;
myConsoleNoDrop.log("不会被删掉");

打包工具的插件一般不会删掉这种写法。


简化问题规模:专注核心链路

排查后发现项目里用了很多层 ContextProvider,外面一堆组件都会被卸载,看得人头大。

于是我开始“瘦身”组件链路:

  • 把外层无关组件全部注释掉
  • 只保留核心结构:A → B(HOC)→ C(真正内容)

瘦身后现象变成:

  • 核心组件 A 不会卸载
  • C(A 的孙子组件)会卸载
  • B 是一个 HOC,把 C 作为 children 渲染

React 里函数组件被卸载只有两个原因:

  1. key 变了
  2. 函数组件的引用变了

我们没用 key,那就是引用变了。

关键矛盾:模块导出的函数引用怎么可能变?

组件 C 是模块级别 export 出来的函数,按理说:

模块只初始化一次,函数引用应该永远不变。

但 React 的行为明确告诉我引用变了。
为了验证,我在 B 里记录上一次的 children.type,和新传入的比较。

结果永远是:

1
old === new → false

说明:

这个模块导出的函数,每次访问拿到的都是“新函数”。

这就很反常识了。


AI 给的提示:让导出模块产生副作用试试

我问 AI,它建议:

  • 在导出这个函数的模块里加一点副作用,比如把函数挂到 window 上;
  • 然后在 B 里比较 children.type 和 window 上的函数引用。

我觉得有道理,于是试了一下。
结果直接整蒙了:

bug 竟然消失了。组件不再卸载了。

我一脸问号:
我正准备继续深入分析,结果你告诉我 bug 自己没了?


进一步验证:真的是“副作用”导致修复吗?

冷静下来后,我严格保持增量,只保留加在 window 上的那一行,其余的 console 都删掉。

结果:

  • 加“把组件挂到 window” → 不会卸载
  • 不加 → 会卸载

但为了确认,我改成:

1
window.xxx = 123 // 与组件无关

然后再测:

  • 挂无关变量 → 不行
  • 挂组件本身 → 有效

说明不是随便加副作用,而是:

必须让“这个组件函数本身”有副作用,压缩器才不敢乱动它。

同事协助:真正的元凶浮出水面

我将我到此为止的发现告诉同事。同事过了一会说,我有问题和没问题的两版npm包,在项目分别安装,并build后没有什么显著差别,依旧只是查了那一行代码。不可能说差这一行代码无缘无故就产生问题,这逻辑连不起来。然后我问他,你看的时候,是把代码压缩关了吗,他说是的,不然不方便看。

这时我已经有了些眉目,如果没压缩的代码是对的,压缩后的代码是错的,那么一切就说得通了。开发环境不会用到build阶段的压缩插件,所以没问题。而生产环境就是错的,因为代码会被压缩。

我就想看看压缩后的代码是什么样的了。我此时在浏览器里找到那个模块的代码,截个图记录一下没问题的版本。再跑一遍有问题的代码,截个图记录下有问题的版本,我已经看出来明显的不同了。但是由于代码被压缩过,我真的看不懂,所以我发给AI来帮我看。

AI 的最终结论非常关键:

因为模块没有副作用,压缩器认为导出的函数是“纯的”,于是把它优化成了一个 getter。

压缩后结构大概类似:

1
exports.az = () => function() { ... };

也就是说:

  • 每次访问 exports.az 都执行箭头函数
  • 箭头函数每次都返回一个新 function
  • → 引用永远不一样
  • → React 判断组件变了 → 卸载

为了验证,我在控制台输入:

1
$n.az === $n.az

$n就是这个模块的导出对象,而az就是我想要的函数组件。结果是 false,彻底坐实。

最终结论:uglifyjs全责,把导出的组件变成了 getter

只要模块里让该函数组件“带点副作用”,压缩器就不再把它改成 getter。
但更简单粗暴也更可靠的解决方案是:

直接关闭 uglifyjs 对模块的这类优化。

同事也说得很直白:

“关掉压缩就完了。”

确实,关掉后,bug 直接消失。

这次排查的感受

这次排查特别折磨,因为开发环境无法复现,每次测试都要打包、部署、刷新,成本巨大。

但也特别有意义,因为:

  • 了解React 原理,让我能死死抓住正确方向
  • 能够判断哪些猜测不可能、哪些值得验证
  • 最终把问题一步一步推到根因层面解决

这种感觉真的很爽。

也让我再次确认:

理解原理真的不会让你吃亏。


通俗易懂的React原理(番外篇之三):又遇组件卸载
https://miku03090831.github.io/2025/12/10/通俗易懂的React原理(番外篇之三):又遇组件卸载/
作者
qh_meng
发布于
2025年12月10日
许可协议