React 源码深度剖析:组件卸载时的“尸体清理”艺术
各位老铁,大家好!
欢迎来到今天的“源码解剖室”。我是你们的带刀侍卫,或者说,是你们那个总是对“后台运行”感到焦虑的代码审查员。
今天我们不讲 useEffect 怎么写,也不讲 Diff 算法多高效。我们要聊一个稍微有点阴间,但极其重要的话题——组件卸载。
想象一下,你在一个派对上认识了一个帅哥/美女,聊得很开心。但是,你的房东突然催房租了,或者你发现他其实是个诈骗犯。于是,你决定断绝关系。
在 React 里,这叫 unmount。但在源码的世界里,这叫 “原子弹爆炸式清理”。
当组件决定“不干了”的时候,React 会做什么?它就像个强迫症晚期的管家,要递归地搜查这个组件的每一个角落,把所有的“尾巴”——Refs、Timers、Portal、Context 订阅——全部斩断。如果留下半点垃圾,你的应用就会变成内存泄漏的温床,最终卡死。
今天,我们就拿着手术刀,深入 React 源码,看看这团名为“组件卸载”的乱麻,到底是怎么被理顺的。
第一部分:死亡判决书 —— 调度与调度
一切的开始,都在调度器那里。当 React 决定要把某个组件扔进垃圾桶时,它不会手抖,只会冷静地敲下回车键。
在 ReactFiberWorkLoop.js 中,有一个非常著名的函数叫 performUnitOfWork。它的职责是遍历 Fiber 树,找到那些该死的节点。
当一个组件的 alternate(即旧 Fiber)节点被发现需要卸载时,React 会做两件事:
- 把它从渲染树(渲染队列)里剔除。
- 标记为
unmount状态。
具体来说,在 completeUnitOfWork 的逻辑中,如果遇到 return 为 null 的节点,意味着这一路分支走完了。这时候,如果这个节点有子节点,React 会检查这些子节点是不是需要被销毁。
源码逻辑示意:
// 这是一个极度简化的调度逻辑,不要在面试里背这个,懂个意思就行
function completeUnitOfWork(unitOfWork) {
const current = unitOfWork;
const next = unitOfWork.return; // 往上走,回到父节点
if (next !== null) {
// 如果父节点还在,我们可能只是把子组件的 DOM 移除了,但父组件还在
// 如果 next.stateNode 指向了 current.stateNode(旧 Fiber 的 DOM 节点)
// 那就把它从 DOM 树里拔掉
if (current.stateNode) {
commitBeforeMutationEffects(); // DOM 树操作前的钩子
commitMutationEffects(); // DOM 树操作(移除节点)
}
// ...
}
}
这就像是收拾行李。调度器说:“把箱子里的东西清空。”
第二部分:切断“跟踪狂” —— Ref 的递归清理
这是 React 源码里最微妙,也最容易让人掉坑里的部分。
Ref 是什么?Ref 是 React 和 DOM 之间的秘密通道。它可以是 useRef 返回的静态变量,也可以是传给组件的 ref 属性。在组件挂载时,React 会把 DOM 节点塞进去;在卸载时,React 必须把门关死。
为什么这很难?因为 Ref 很狡猾。它不像 props,props 会随着组件卸载而消失。Ref 可以是一个函数,也可以是一个对象。更麻烦的是,Ref 是可以“寄生”的。
你可以在子组件的 ref 上挂载一个父组件的 Ref。这意味着,清理子组件时,必须递归清理子组件身上的所有 Ref,直到树根。
核心函数:commitDetachRef
当组件开始卸载,React 会调用 commitDetachRef。这个函数干了一件事:把 DOM 节点的引用置为 null。
注意,它并没有调用你的 ref 回调函数传 null(虽然通常 useEffect 的清理函数会做这个,但 Ref 的清理是更底层的)。它的目的是为了帮助垃圾回收。
如果 DOM 节点的 current 属性还指着一个元素,而那个 Fiber 节点已经被标记为删除了,那内存就会泄漏。这就像是尸体还没埋,里面的钱包还在晃悠。
源码深度解析:
function commitDetachRef(fiber) {
const ref = fiber.ref;
if (ref !== null) {
if (typeof ref === 'function') {
// 情况 1:这是函数式 Ref
// React 不会自动调用这个函数传 null,而是直接把引用关系切断
// 但是!如果用户在 cleanup 里手动调用了 ref,那就看用户心情了
// 这里我们只负责物理层面的切断
try {
ref(null);
} catch (refError) {
captureCommitPhaseError(fiber, refError);
}
} else {
// 情况 2:这是对象式 Ref
ref.current = null;
}
}
}
但是!这里有个递归陷阱。
上面的代码只处理了当前 Fiber 节点的 Ref。如果这个 Fiber 节点下面还有子节点呢?
React 在 commitUnmount 的逻辑里,会遍历这个 Fiber 节点的 child 链表。
// commitUnmount 的逻辑骨架
function commitUnmount(fiber) {
// 1. 先处理这个节点自己的 Ref
commitDetachRef(fiber);
// 2. 如果有 Portal,把 Portal 断开(后面细说)
commitPortalDetach(fiber);
// 3. 递归处理子节点
if (fiber.child) {
commitUnmount(fiber.child);
}
// 4. 还有侧边的兄弟节点呢?
if (fiber.sibling) {
commitUnmount(fiber.sibling);
}
}
这就是为什么我说 Ref 清理是“递归”的。React 就像个地毯吸尘器,吸头必须走到地毯的每一个角落。
第三部分:驱散“僵尸” —— Timers 的清理
Timer(setTimeout, setInterval, requestAnimationFrame)是前端开发者的恶梦。因为它们无视组件的生命周期。你写了 setTimeout(() => { ... }, 1000),就算你删了组件,那个 Timer 还会在后台傻傻地等 1 秒钟,然后触发回调。
如果回调里访问了组件的状态(比如 this.setState),恭喜你,你遇到了经典的大坑——“野指针”。组件早就灰飞烟灭了,但你还在往它的坟头填土。
React 知道这个痛点,所以它需要清理 Timer。
React 是怎么知道你有 Timer 的?
React 并不会替你自动清理 Timer。它只提供工具。React 内部有一个 Scheduler 模块(其实是用 setTimeout 实现的),所有的异步任务都通过它调度。
当你调用 setTimeout 时,React 实际上是调用了 Scheduler.scheduleCallback。这个函数会返回一个 Task ID。
当组件卸载时,React 会遍历这个组件 Fiber 节点的副作用列表,找到所有标记为 HasEffect 或 Snapshot 的副作用。
关键代码逻辑:
React 源码里并没有直接叫 clearTimeout 的全局调用,而是通过 Scheduler.cancelCallback 来实现的。
// 这是一个概念性的伪代码,展示 React 如何在卸载时寻找并清理任务
function commitBeforeMutationEffectsOnFiber(finishedWork) {
// ...省略前文...
// 检查是否有副作用
switch (finishedWork.effectTag) {
case Update:
case Placement:
case PlacementAndUpdate:
// ...
break;
case Deletion: // 关键!如果是删除节点
// React 会去检查这个 Fiber 节点是否“订阅”了任何任务
// 比如,某些 hook 内部可能会把任务存入 fiber.memoizedState
// 我们来看看 useEffect 的清理函数是怎么工作的
commitWork(finishedWork);
// 清理返回的函数
const destroy = finishedWork.updateQueue.destroy;
if (typeof destroy === 'function') {
destroy(); // 呼!把你的定时器关掉
}
commitDetachRef(finishedWork);
break;
}
}
所以,Timer 的清理主要发生在 useEffect 的清理函数中。React 只是把你的清理函数“扔”给了你,让你自己调用 clearTimeout。
但是,如果你手动调用了 setTimeout 并且没有保存 ID,React 是无能为力的。这也是为什么老前辈总是说:“千万别手写 Timer,要用 useEffect 的清理逻辑!”
第四部分:时空穿越者的断连 —— Portal 的清理
Portal 是 React 里最“贱”的一个特性。它允许你把子组件渲染到 DOM 树的另一个地方——比如一个全屏的模态框容器里,而不是当前的 div 下。
这导致了一个非常棘手的问题:父子节点在 DOM 结构上已经解绑了,但在逻辑上还是一家人。
当父组件卸载时,Portal 渲染的孩子怎么办?React 必须把那个被“绑架”出去的孩子,强行拉回原来的家,然后执行正常的卸载流程。
核心函数:commitPortalDetach
这个函数的逻辑非常清晰:在父组件的容器里,把那个被 Portal 丢进去的 DOM 节点找出来,删掉。
function commitPortalDetach(finishedPortal) {
const portal = finishedPortal;
const container = portal.containerInfo;
// 1. 找到 Portal 挂载的那个节点
// Portal 内部有一个叫 renderNode 的东西,存储了当前的 DOM 节点
const hostParent = portal.pendingPortalNode;
if (hostParent) {
// 2. 把它从容器里拔出来
// 这就像是从一个长串项链上把一颗珍珠抠下来
container.removeChild(hostParent);
// 3. 把这个节点挂载到 Portal 的 renderNode 上,为下一次渲染做准备
portal.renderNode = hostParent;
}
// 4. 递归清理这个子树!
// 这是最重要的一步。Portal 只是改变了挂载点,并没有改变组件树结构
// 所以 Portal 里的子组件,依然要执行 componentUnmount, cleanup Refs, etc.
commitUnmount(finishedPortal.child);
}
这个 commitPortalDetach 执行完之后,逻辑就回到了我们之前讲的普通组件卸载流程。React 会递归地遍历 Portal 的子树,调用 commitDetachRef,调用 useEffect 清理函数。
总结一下 Portal 清理的顺序:
- 把被绑架的 DOM 节点从“绑架犯”(Portal 容器)手里抢回来。
- 把这个节点挂载回 Portal 的临时缓存区(避免 DOM 挂载时的闪烁)。
- 立刻在这个节点上执行标准的卸载流程(清理 Ref、清理 Timer、清理子节点)。
第五部分:最后的告别信 —— Context 与 Effect 的清理
除了 Ref 和 Timer,组件卸载时还有两件大事:Context 订阅 和 Effect 清理。
1. Context 的解绑
Context 就像是订阅了全服公告。子组件通过 Context.Consumer 或 useContext 来接收数据。
当一个组件卸载时,它必须通知它所有的子组件:“亲,我不订阅这个 Context 了,你们也别听了,省点电吧。”
React 是怎么做的?每个组件在渲染时,都会创建一个 ContextProvider 的层级。卸载时,React 会顺着 Fiber 树往上找,把所有的 Provider 断开。
具体来说,React 会遍历父级链,找到所有标记了 context.Provider 的节点,然后调用 dispatchContextDisappears,断开它们的订阅关系。这就好比断开了一根根电话线。
2. Effect 的终极清理
这是所有“副作用”的归宿。useEffect 返回的那个函数,就是你的“遗嘱”。
在 commitUnmount 流程中,React 会检查当前 Fiber 节点的 updateQueue。如果这个节点在挂载时生成了一个 Effect(即执行了 useEffect),那么在卸载时,React 会把那个 Effect 对象里的 destroy 函数拿出来执行。
代码示例:
// 比如你写了这样的代码
function MyComponent() {
useEffect(() => {
const id = setTimeout(() => console.log("Hello"), 1000);
// 清理函数:这相当于你的遗书
return () => {
console.log("我要挂了,先把 Timer 关了吧");
clearTimeout(id);
};
}, []);
return <div>我即将消失</div>;
}
React 执行流程:
- 检测到
MyComponent卸载。 - 发现它有一个
destroy函数。 - 调用
destroy()。 console.log("我要挂了...")执行。clearTimeout(id)执行。- 组件销毁。
第六部分:终极思考 —— 为什么要这么麻烦?
看到这里,你可能会问:“React 为什么不直接 document.body.removeChild 了事?为什么还要搞什么 Ref、Timer、Portal 这一套复杂的递归逻辑?”
这就是资深工程师和老手之间的区别。
1. 内存管理的尊严
如果我们不手动断开 Ref,那个 DOM 节点就会在内存里苟延残喘。假设你做了一个复杂的 3D 渲染库,组件卸载了,但 WebGL 上下文(Context)和 GPU 资源(Buffer)没有被释放,这会导致显卡爆炸。
2. 安全性
Timer 回调如果还在跑,万一它访问了组件里的敏感数据(比如用户密码),那就完了。React 的清理机制是最后一道防线。
3. 逻辑的完整性
Portal 的例子告诉我们,DOM 结构不代表逻辑结构。React 必须维护一个“真相之源”,这个源就是 Fiber 树。卸载的本质,就是递归地把 Fiber 树上不再需要的节点清理掉,并把它们在物理世界(DOM)上的痕迹抹去。
结语:余音绕梁
好了,各位听众,今天的“源码解剖室”之旅就到这里。
我们穿越了 React 的渲染循环,目睹了组件死亡的全过程:从 performUnitOfWork 的调度,到 commitDetachRef 对 Ref 的无情斩断,再到 commitPortalDetach 对时空错位的修正,最后由 useEffect 的清理函数为 Timer 守灵。
React 的设计哲学在这里体现得淋漓尽致:一切副作用,皆需偿还。 挂载时有副作用,卸载时就要有清理。
下次当你看到控制台里报错说“Accessing a destroyed component”时,别急着骂 React。请深吸一口气,想一想:是不是我那个 setTimeout 没关?是不是我那个 ref 没置空?
希望这篇文章能让你在深夜写代码时,多一份淡定,少一份惊慌。毕竟,只有当你理解了组件是如何死去的,你才能真正理解它如何活着。
下课!