通俗易懂的React原理(番外篇之三):又遇组件卸载
代码以React v19.1.0为例,https://github.com/facebook/react/tree/v19.1.0 。此版本React的RSC功能近期被发现有致命漏洞,我们研究源码不涉及相关功能,但此版本应该严禁在生产使用。
这一篇写得随意一点,不打算贴太多源码。与其说是技术文章,更像是一个排坑故事,记录我为了查清这个 bug 花了整整两天时间,最后终于定位的过程。
没想到吧,这次遇到的 bug,和《番外篇二》居然是同样的现象,只是原因完全不同。还是商品卡片,还是点击之后不能展开。
事情的起因:换项目、同一个问题再次出现
写《番外篇二》时,我在开发我们的一个 npm 组件包。第一个项目接入后运行正常,上线也一切顺利。
这段时间我在做第二个项目的技改,也接入了这个 npm 包。结果测试突然告诉我:
“那个商品卡片又打不开了。”
我心里立刻咯噔一下:八成又是组件被卸载了。
可奇怪的是,那段逻辑在旧项目已经修过了,为什么在新项目又出现?更离谱的是,旧项目在生产环境正常运行,新项目却出问题?
我先去测试环境确认了问题,再把接口数据复制到本地跑一遍——本地竟然完全正常。
那一刻我真的想骂人:这 bug 居然只在生产复现,开发环境完全没问题?这查起来简直折磨。
两条思路:代码 vs 差异
排查这种问题一般有两条路径:
- 纯从代码逻辑下手:排查组件被卸载的原因
- 从问题特征下手:旧项目正常、新项目异常;开发正常、生产异常。对比区别点,排查是什么区别导致了一侧有问题,一侧正常的特征
第二条路可行,但差异太多、不好判断,也不好验证。
我更倾向第一条,因为代码不会骗人。只要理解原理,抓住能确定的现象,迟早能逼出真正的矛盾点。抓着矛盾点不断深入,一定能找到真相
第一轮排查:console.log 也被工具坑
我照老办法,在组件里加 useEffect(() => { return () => console.log() }) 看哪些组件被卸载。
结果我们组件库的打包工具会把 console.log 全部删掉……
我当时真的懒得再去研究打包工具了,这玩意我半年前看过一次,当时改了点小问题。但现在打包工具又升级了,再啃一遍实在没精力。
于是用了一个小技巧:
1 | |
打包工具的插件一般不会删掉这种写法。
简化问题规模:专注核心链路
排查后发现项目里用了很多层 ContextProvider,外面一堆组件都会被卸载,看得人头大。
于是我开始“瘦身”组件链路:
- 把外层无关组件全部注释掉
- 只保留核心结构:A → B(HOC)→ C(真正内容)
瘦身后现象变成:
- 核心组件 A 不会卸载
- C(A 的孙子组件)会卸载
- B 是一个 HOC,把 C 作为 children 渲染
React 里函数组件被卸载只有两个原因:
- key 变了
- 函数组件的引用变了
我们没用 key,那就是引用变了。
关键矛盾:模块导出的函数引用怎么可能变?
组件 C 是模块级别 export 出来的函数,按理说:
模块只初始化一次,函数引用应该永远不变。
但 React 的行为明确告诉我引用变了。
为了验证,我在 B 里记录上一次的 children.type,和新传入的比较。
结果永远是:
1 | |
说明:
这个模块导出的函数,每次访问拿到的都是“新函数”。
这就很反常识了。
AI 给的提示:让导出模块产生副作用试试
我问 AI,它建议:
- 在导出这个函数的模块里加一点副作用,比如把函数挂到 window 上;
- 然后在 B 里比较
children.type和 window 上的函数引用。
我觉得有道理,于是试了一下。
结果直接整蒙了:
bug 竟然消失了。组件不再卸载了。
我一脸问号:
我正准备继续深入分析,结果你告诉我 bug 自己没了?
进一步验证:真的是“副作用”导致修复吗?
冷静下来后,我严格保持增量,只保留加在 window 上的那一行,其余的 console 都删掉。
结果:
- 加“把组件挂到 window” → 不会卸载
- 不加 → 会卸载
但为了确认,我改成:
1 | |
然后再测:
- 挂无关变量 → 不行
- 挂组件本身 → 有效
说明不是随便加副作用,而是:
必须让“这个组件函数本身”有副作用,压缩器才不敢乱动它。
同事协助:真正的元凶浮出水面
我将我到此为止的发现告诉同事。同事过了一会说,我有问题和没问题的两版npm包,在项目分别安装,并build后没有什么显著差别,依旧只是查了那一行代码。不可能说差这一行代码无缘无故就产生问题,这逻辑连不起来。然后我问他,你看的时候,是把代码压缩关了吗,他说是的,不然不方便看。
这时我已经有了些眉目,如果没压缩的代码是对的,压缩后的代码是错的,那么一切就说得通了。开发环境不会用到build阶段的压缩插件,所以没问题。而生产环境就是错的,因为代码会被压缩。
我就想看看压缩后的代码是什么样的了。我此时在浏览器里找到那个模块的代码,截个图记录一下没问题的版本。再跑一遍有问题的代码,截个图记录下有问题的版本,我已经看出来明显的不同了。但是由于代码被压缩过,我真的看不懂,所以我发给AI来帮我看。
AI 的最终结论非常关键:
因为模块没有副作用,压缩器认为导出的函数是“纯的”,于是把它优化成了一个 getter。
压缩后结构大概类似:
1 | |
也就是说:
- 每次访问
exports.az都执行箭头函数 - 箭头函数每次都返回一个新 function
- → 引用永远不一样
- → React 判断组件变了 → 卸载
为了验证,我在控制台输入:
1 | |
$n就是这个模块的导出对象,而az就是我想要的函数组件。结果是 false,彻底坐实。
最终结论:uglifyjs全责,把导出的组件变成了 getter
只要模块里让该函数组件“带点副作用”,压缩器就不再把它改成 getter。
但更简单粗暴也更可靠的解决方案是:
直接关闭 uglifyjs 对模块的这类优化。
同事也说得很直白:
“关掉压缩就完了。”
确实,关掉后,bug 直接消失。
这次排查的感受
这次排查特别折磨,因为开发环境无法复现,每次测试都要打包、部署、刷新,成本巨大。
但也特别有意义,因为:
- 了解React 原理,让我能死死抓住正确方向
- 能够判断哪些猜测不可能、哪些值得验证
- 最终把问题一步一步推到根因层面解决
这种感觉真的很爽。
也让我再次确认:
理解原理真的不会让你吃亏。