React 挂载阶段的副作用清理:源码解析卸载 Fiber 节点时对所有的 ref 与 timer 的自动化清理
各位好!欢迎来到今天的“React 内部架构深度挖掘研讨会”。
今天我们不聊怎么写 useEffect 的依赖数组,也不聊 memo 的渲染性能优化。今天我们要聊点更“灰暗”、更“沉重”,但也更“必要”的话题——告别。
在 React 的世界里,组件是有寿命的。它们出生(挂载),它们成长(更新),然后它们死去(卸载)。这听起来很残酷,但在计算机世界里,这是必须的。如果不让组件死去,我们的内存早就被撑爆了,就像一个永远不关水龙头的浴缸。
当组件死去的时候,会发生什么?React 会怎么收拾它的“烂摊子”?
特别是当组件在它短暂的一生中,偷偷摸摸地藏了一些“私房钱”(DOM 引用)或者“定时炸弹”(setTimeout)时,React 是如何确保在它离开时,把这些东西统统清理干净的?
这就涉及到了 React 源码中一个非常核心但又常被忽视的阶段——卸载阶段。
今天,我们将化身 React 的“清道夫”,深入源码,看看 React 是如何在卸载 Fiber 节点时,优雅地处理 ref 和 timer 的。
第一部分:告别前的“私房钱”——DOM Refs
1.1 什么是 Ref?它是你的“外挂”
在 React 开发中,我们经常用到 ref。它就像是组件的一根触角,伸到了 React 的虚拟 DOM 之外,直接抓住了真实的 DOM 节点。
比如:
function TextInput() {
const inputRef = useRef(null);
return <input ref={inputRef} />;
}
在这个瞬间,inputRef.current 就指向了那个 <input> 标签。
1.2 闭包的诅咒
这时候,React 的源码机制就介入了。当组件卸载时,inputRef.current 指向的那个 DOM 节点会被从真实 DOM 树中剪掉。
但是,问题来了。假设你的组件里有个 useEffect,它依赖这个 inputRef:
function TextInput() {
const inputRef = useRef(null);
useEffect(() => {
// 这是一个“自杀式”的定时器,组件卸载时清理
const timer = setTimeout(() => {
console.log(inputRef.current.value); // 这里可能是个空壳
}, 1000);
return () => {
clearTimeout(timer);
};
}, []); // 依赖为空,意味着这个 effect 只运行一次
return <input ref={inputRef} />;
}
如果你在组件卸载后,这个 useEffect 的清理函数还没来得及执行(或者执行了但闭包里的 inputRef 还没被置空),那么 inputRef.current 就会变成一个悬垂指针。
这就像你把旧房子拆了,但你的钥匙还在口袋里,而且你还记得钥匙插在哪个已经不存在的锁孔里。这在编程里是大忌,会导致内存泄漏,或者更糟,报错。
1.3 源码探秘:commitDetachRef
React 怎么防止这种事情发生?答案是:在组件卸载的那一刻,React 会自动把所有指向该组件 DOM 节点的 ref 设为 null。
这个动作发生在 commit 阶段,具体来说是在 commitBeforeMutationEffects 阶段。
在 React 的源码(ReactFiberCommitWork.js)中,有这么一段逻辑:
// 伪代码演示
function commitDetachRef(fiber) {
const ref = fiber.ref;
if (ref !== null) {
if (typeof ref === 'function') {
// 如果 ref 是函数,调用它,传入 null
ref(null);
} else {
// 如果 ref 是对象({ current: node }),把 current 设为 null
ref.current = null;
}
}
}
这是多么体贴的举动啊!
当你点击按钮,组件被卸载,React 走到这个节点:
- 它检查这个
Fiber节点有没有ref属性。 - 如果有,它调用你的 ref 函数,或者把你的
ref.current设为null。 - 这就断开了组件与真实 DOM 的最后一丝联系,防止了闭包陷阱。
专家提示: 这也是为什么我们在开发中,有时候需要手动清理 ref。比如你用 ref 做了一个全屏遮罩,组件卸载时,React 会自动把遮罩的 display 改回 none(通过 ref.current),但你可能还需要手动调用一下 遮罩组件实例.close(),因为 React 只管 DOM,不管业务逻辑。
第二部分:告别前的“定时炸弹”——Timers
如果说 ref 是内存里的指针,那么 timer 就是时间轴上的刺客。setTimeout、setInterval、requestAnimationFrame,它们一旦启动,就不管组件死活了,直到时间到它们才会执行。
React 拥有控制权吗?拥有!React 是调度器,调度器就是上帝。
2.1 Passive Effects:被动清理
React 为什么要特意把 useEffect 的清理叫作 Passive Cleanup?因为它的执行时机非常特殊,它发生在浏览器绘制之后。
当组件卸载时,React 不会在渲染阶段(Render Phase)就立马把 timer 清掉。Render 阶段只是计算“我们要变成什么样”,而 Commit 阶段才是“我们要变成什么样”并落地。
在卸载流程中,React 会执行 commitPassiveUnmountEffects。这是处理 useEffect 清理函数的核心舞台。
2.2 源码探秘:commitPassiveUnmountEffects
让我们看看这段代码的逻辑流。在 React 源码中,commitPassiveUnmountEffects 会遍历所有需要卸载的 Fiber 节点,并检查它们是否有 Passive 类型的 effect。
// ReactFiberCommitWork.js 的逻辑简化版
function commitPassiveUnmountEffects(fiber) {
if (fiber.effectTag & Passive) {
// 1. 处理 ref 清理
commitDetachRef(fiber);
// 2. 处理副作用清理
// useEffect 返回的清理函数在这里执行
commitCleanupEffectList(fiber);
}
// 递归处理子节点
commitPassiveUnmountEffects(fiber.child);
commitPassiveUnmountEffects(fiber.sibling);
commitPassiveUnmountEffects(fiber.return);
}
这里有一个非常关键的细节:
在 commitCleanupEffectList 里,React 会取出 fiber.memoizedState(这里存放着 effect 的队列),然后执行每一个清理函数。
function commitCleanupEffectList(fiber) {
let firstEffect = fiber.firstEffect; // 获取第一个 effect
while (firstEffect !== null) {
const destroy = firstEffect.destroy;
if (destroy !== undefined) {
// 执行清理函数!这是最关键的一步。
// 比如 clearTimeout(timer), removeEventListener 等
destroy();
}
firstEffect = firstEffect.nextEffect;
}
}
这意味着什么?
这意味着,如果你写了:
useEffect(() => {
const id = setInterval(() => console.log('活着...'), 1000);
return () => clearInterval(id); // 你的清理函数
}, []);
React 在组件卸载时,会自动找到这个清理函数,并执行它。
2.3 为什么是“Passive”?为什么不早点清理?
你可能会问:“为什么不在渲染阶段就清掉?渲染阶段不是很快吗?”
这就是 React 设计的精妙之处。
useEffect 的清理函数通常包含一些异步操作或者副作用。比如,你可能在清理函数里调用了 dispatch 更新状态,或者在清理函数里做了很多 DOM 操作。
如果 React 在渲染阶段就执行这些清理逻辑,那么它就破坏了“渲染阶段是纯函数”的原则。渲染阶段必须快、必须无副作用、必须可预测。React 把清理工作推迟到了 commit 阶段,具体来说是 Passive 阶段(在浏览器绘制之后),这样既保证了渲染性能,又保证了副作用能被正确处理。
第三部分:深度剖析——Fiber 树的生死轮回
为了彻底搞懂这个流程,我们必须理解 Fiber 节点的结构。Fiber 是 React 的物理载体。
3.1 Current Fiber vs WorkInProgress Fiber
React 的工作模式是“双缓冲”。
- Current Fiber Tree(当前树): 这是用户当前看到的 DOM 树。它是稳定的。
- WorkInProgress Fiber Tree(工作树): React 正在构建的新树。它是临时的。
当组件更新时,React 会克隆 Current Fiber,修改它的属性,变成 WorkInProgress Fiber,然后提交给浏览器。
当组件卸载时,React 会把 WorkInProgress 树中的某个节点标记为 Deletion(删除),然后把这个节点从 Current 树中移除。
3.2 EffectTag:组件的“行为日志”
每个 Fiber 节点都有一个 effectTag 属性。这个属性就像一个标签,记录了节点需要做什么。
在卸载过程中,我们关心的标签主要有:
- Deletion (0x0008): 标记该节点需要被删除。
- Passive (0x0040): 标记该节点有
useEffect。 - Layout (0x0020): 标记该节点有
useLayoutEffect。
3.2.1 卸载 Ref 的逻辑流
在 commitBeforeMutationEffects 阶段,React 会遍历 Deletion 标记的节点。
// ReactFiberCommitWork.js
function commitBeforeMutationEffects() {
// 递归遍历
commitBeforeMutationEffects_begin(root, firstChild);
commitBeforeMutationEffects_complete(root, firstChild);
}
function commitBeforeMutationEffects_begin(returnFiber, firstChild) {
while (firstChild !== null) {
const primaryEffectTag = firstChild.effectTag;
// 如果有 Deletion 标记,说明这个子节点要被删了
if ((primaryEffectTag & Deletion) !== NoEffect) {
// 递归处理子节点(先处理子节点,确保 DOM 先被移除)
commitBeforeMutationEffects_begin(firstChild, firstChild.child);
// 关键!在这里处理 Ref 的卸载
// 注意:Ref 的卸载是在 DOM mutation 之前,但布局效应之后
if (primaryEffectTag & Ref) {
commitDetachRef(firstChild);
}
}
// 继续下一个兄弟节点
firstChild = firstChild.sibling;
}
}
为什么 Ref 卸载在 DOM 移除之前?
因为 commitDetachRef 需要访问 firstChild.ref。虽然 firstChild 还在 Fiber 树结构中,但它的 stateNode(指向真实 DOM 的指针)可能已经被移除了。React 必须在彻底切断联系之前,先执行清理逻辑。
3.2.2 卸载 Effect 的逻辑流
Ref 清理完了,接下来是 DOM 移除(commitMutationEffects)。
DOM 移除完了,最后才是 useEffect 的清理(commitPassiveUnmountEffects)。
// commitRoot 的流程
function commitRoot(root) {
const finishedWork = root.finishedWork;
// 1. 重置 flags
root.finishedWork = null;
root.nextPendingRoot = null;
// 2. 开始 Commit 阶段
// 这是一个巨大的 switch case,根据 effectTag 决定做什么
const commitWork = commitWork; // 默认是 commitMutationEffects
// ...
// 3. 处理 Ref 和 Layout Effects (DOM 变更前)
commitBeforeMutationEffects();
// 4. 处理 DOM 变更 (Mutation)
commitMutationEffects(root, finishedWork);
// 5. 处理 Layout Effects (DOM 变更后,但在绘制前)
commitLayoutEffects(root, finishedWork);
// 6. 处理 Passive Effects (绘制后,副作用)
commitPassiveEffects(root, finishedWork);
// ...
}
执行顺序总结:
- Render (计算 Diff)
- Commit Before Mutation (执行 Ref 清理, DOM 移除)
- Commit Mutation (插入 DOM, 更新 DOM 样式)
- Commit Layout (执行
useLayoutEffect的 setup 和 cleanup) - Commit Passive (执行
useEffect的 setup 和 cleanup)
第四部分:实战演练——模拟一个组件的死亡
为了让你更直观地理解,我们来手写一个简化版的 React 卸载流程。
假设我们有一个组件 UserProfile,它里面有一个 useEffect 启动了定时器,还有一个 useRef 指向一个文本框。
// 假设的 Fiber 节点结构
const fiber = {
type: 'UserProfile',
stateNode: { /* 真实 DOM 节点 */ },
return: parentFiber,
child: childFiber,
sibling: null,
// 关键属性
effectTag: Deletion, // 标记为删除
ref: { current: null }, // 假设 ref 是个对象
memoizedState: {
queue: {
lastEffect: {
destroy: () => console.log('>>> 清理:Timer 停止了!'),
nextEffect: null
}
}
}
};
当 React 走到这个节点时,发生了什么?
- React 拿到了
fiber。 - 检查 Ref:
fiber.ref.current = null;-> 你的 ref 对象被清空了。 - 检查 Effect:
React 拿到了fiber.memoizedState.queue.lastEffect.destroy。
它调用了这个函数:() => console.log('>>> 清理:Timer 停止了!')。 - 检查 DOM:
React 找到了fiber.stateNode,然后把它从真实的 DOM 树中removeChild掉。
结果:
用户的文本框消失了,定时器停止了,你的 ref 也变空了。干干净净。
第五部分:那些被遗忘的角落——其他清理工作
除了 Ref 和 Timer,React 在卸载时还会做很多隐形的清理工作。
5.1 useLayoutEffect 的清理
useLayoutEffect 的清理函数是在 commitLayoutEffects 阶段执行的。
function commitLayoutUnmountEffects(fiber) {
if (fiber.effectTag & Layout) {
// 执行 useLayoutEffect 的清理
commitCleanupEffectList(fiber);
// 执行 ref 清理
commitDetachRef(fiber);
}
}
注意顺序:Layout 效果的清理在 Ref 清理之前。为什么?
因为 useLayoutEffect 的清理函数通常需要操作 DOM(比如滚动到顶部,或者强制重排)。如果先清空了 Ref(断开了 DOM 引用),再执行清理函数,可能会导致清理函数无法操作 DOM。所以 React 优先保证清理函数能拿到 DOM。
5.2 组件实例的清理
如果组件类使用了 componentWillUnmount(虽然不推荐),React 也会在卸载阶段调用它。
对于函数组件,React 会在 fiber.memoizedState 中找到 useRef 创建的 fiber 实例引用,并将其设为 null。这主要是为了防止循环引用导致的内存泄漏。
第六部分:为什么我们需要这种自动化清理?(深度思考)
很多初学者会问:“我自己在 useEffect 里写 return () => ... 不就行了吗?为什么还要懂 React 内部怎么清理 Ref 和 Timer?”
这是一个非常好的问题。理解底层机制能帮你写出更健壮的代码。
6.1 闭包陷阱的防御
React 自动清理 Ref,是防御性编程的极致体现。
想象一下,如果你在一个 useEffect 里保存了 ref.current 的值:
useEffect(() => {
let val = inputRef.current.value; // 保存了当时的值
const timer = setTimeout(() => {
console.log(val); // 即使组件卸载了,这个闭包里的 val 还是旧的
}, 2000);
return () => clearTimeout(timer);
}, []);
如果 React 不自动清理 ref,那么即使组件销毁了,这个闭包里的 inputRef.current 可能还指向一个已经被删除的 DOM 节点。当你 2 秒后打印 val 时,浏览器可能会报错:“Accessing a dead object”(访问了一个死亡对象)。
React 的自动清理,确保了在清理函数执行时,DOM 节点还是活着的,Ref 也是有效的。
6.2 性能优化的边界
React 的清理逻辑是自动的,但也意味着它有成本。
每次组件卸载,React 都要遍历 Fiber 树,检查 effectTag,执行清理函数。这是一个 O(N) 的操作,其中 N 是卸载的组件数量。
虽然 React 已经优化得很好了(只遍历发生变化的节点),但如果你有成千上万个组件同时卸载,React 也会卡顿一下。这就是为什么在 React 18 中,如果父组件卸载了,子组件会立即停止工作,不会等到 React 走完整个卸载流程。这种“快速失败”的设计,也是为了减少不必要的清理计算。
第七部分:源码细节大赏——Effect List 的链表结构
为了更深入,我们得看看 memoizedState 到底长什么样。它不仅仅是一个函数,它是一个链表。
当你在组件中写多个 useEffect 时,React 会把它们串成一个链表:
useEffect(() => { ... }, []);
useEffect(() => { ... }, []);
useEffect(() => { ... }, []);
在 Fiber 节点中,memoizedState 可能长这样:
{
queue: {
lastEffect: {
nextEffect: { // 第二个 effect
nextEffect: { // 第三个 effect
nextEffect: null
}
},
destroy: () => console.log('清理 3')
}
}
}
在 commitCleanupEffectList 中,React 会从 lastEffect 开始遍历这个链表,依次执行所有的 destroy 函数。
这意味着,如果你在一个组件里写了 10 个 useEffect,那么当你卸载这个组件时,React 会依次执行这 10 个清理函数。
专家建议:
虽然 React 做了自动化清理,但在清理函数内部,尽量不要做特别重的计算。比如,不要在清理函数里发网络请求(除非你是为了取消请求)。因为 React 可能会在清理函数还没执行完的时候,就开始渲染下一个页面了。
第八部分:手动清理与自动化清理的博弈
虽然 React 会自动清理 ref 和 timer,但有时候我们需要“手动干预”。
比如,你有一个全局的 eventBus。
useEffect(() => {
const unsubscribe = eventBus.on('data', handleData);
return () => unsubscribe(); // 你必须手动返回这个清理函数
}, []);
React 不会自动帮你取消订阅。因为 React 不知道你的 eventBus 是怎么实现的,也不知道 unsubscribe 函数长什么样。
React 只能帮你清理它“创造”的东西:
- 它创造的 DOM 引用。
- 它创造的 Timer。
- 它创造的
useEffect清理函数。
而对于你自己注册的监听器、全局变量、第三方库的实例,React 只能指望你在 useEffect 的清理函数里去处理。
第九部分:总结——当组件死去的那一刻
让我们把镜头拉远,回顾一下整个卸载过程。
当用户点击了“返回”按钮,或者路由发生了变化:
- React 的 Fiber 调度器 决定卸载
UserProfile组件。 - Render 阶段 结束,React 拿到了标记为
Deletion的Fiber节点。 - Commit Before Mutation:React 走到那个节点,执行 Ref 清理。它把你的
ref.current设为null,切断了你与 DOM 的最后一丝联系。这是为了保护你的代码,防止访问死亡对象。 - Commit Mutation:React 从真实 DOM 树中移除了那个
<div>。 - Commit Layout:React 执行
useLayoutEffect的清理函数。这时候 DOM 还在,你可以安全地操作它。 - Commit Passive:React 执行
useEffect的清理函数。这时候浏览器可能已经画好下一帧了,但你的清理函数依然被准时召唤,把你的定时器停掉,把你的监听器取消。
这就是 React 的自动化清理机制。它就像一个尽职尽责的管家,在你离开房间之前,它帮你关灯、关水、锁门,并且把钥匙交还给房东。
作为开发者,我们不需要每天去写 clearInterval,因为 React 已经帮我们做了。但是,了解这些机制,能让我们在面对内存泄漏、闭包陷阱时,不再感到迷茫。
记住:
- Ref 是物理连接,React 会亲手剪断。
- Timer 是时间连接,React 会亲手掐断。
- Effect 是逻辑连接,React 会亲手执行清理函数。
希望今天的讲座能让你对 React 的卸载阶段有一个全新的认识。下次当你点击返回键,看着页面飞快地切换时,你可以在心里默默为那些被清理的 Ref 和 Timer 点个赞。
谢谢大家!