各位下午好,请把手机调至静音。今天我们不聊业务需求,也不聊怎么把那个难搞的 Bug 变成 Feature,我们来聊聊 React 的“灵魂”——useState。
如果你是初学者,你会觉得 const [count, setCount] = useState(0) 简单得就像在便利店买瓶可乐。但如果我是资深专家,我会告诉你:这根本不是可乐,这是核反应堆的控制棒。
今天我们要深入 React 源码的底层,去窥探那个被称为 dispatchAction 的函数,以及它如何维护一个神秘的“环形链表”来管理状态更新。我们要搞清楚,为什么这个链表不会让你的内存泄漏成一片沼泽,为什么它能处理并发渲染,以及为什么你的闭包总是慢半拍。
准备好了吗?系好安全带,我们开始。
第一部分:打破“变量”的幻觉
首先,我们要摒弃一个极其顽固的误解:useState 返回的那个 count,根本不是一个普通的 JavaScript 变量。
如果你写 let x = 1,内存里就有一个 x。如果你写 const [count, setCount] = useState(0),你以为内存里也有一个 count?错。
React 做的事情是:它把你的组件挂载到了一个巨大的、复杂的树状结构上,这棵树叫 Fiber。每一个组件都是一个节点,而每一个节点里,都有一个指针,叫 memoizedState。
在 React 内部,memoizedState 指向了两个东西:
- 当前最新的状态值(比如
count = 1)。 - 一个更新队列(Update Queue)。
这个更新队列,就是我们今天的主角。
当你调用 setCount(1) 时,React 并没有直接把 1 写进 memoizedState。它只是把 1 打包成了一个“动作”,扔进了这个队列里。
那么,这个队列长什么样?为什么说是“环形链表”?这就要请出我们的男主角 —— dispatchAction。
第二部分:DispatchAction 入口
让我们看看 dispatchAction 到底干了什么。为了方便理解,我稍微剥离了一些复杂的调度逻辑,只保留核心的数据结构操作。代码大概长这样(伪代码):
function dispatchAction(fiber, queue, action) {
// 1. 创建一个新的更新节点
const update = {
action: action, // 这是你传进来的函数或值
next: null, // 指针,用于连成链表
priority: ... // 优先级,React 18 的秘密武器
};
// 2. 如果队列是空的,初始化它
if (queue.last === null) {
// 第一个更新,从头开始,也结束于它自己
queue.first = queue.last = update;
} else {
// 3. 如果队列已经有东西了,把它插进去
// 注意这里的逻辑,它是往尾部插
queue.last.next = update;
queue.last = update;
}
// 4. 关键的一步:启动调度
// 告诉 React:“嘿,有人往队列里塞东西了,赶紧干活!”
scheduleWork(fiber);
}
看到了吗?这就是 dispatchAction 的全部精髓。它负责创建一个节点,把它挂在链表上,然后喊一声“开工”。
第三部分:环形链表的数据结构
现在,让我们来看看那个神秘的“环形链表”。
通常,我们说的链表是单向的:A 指向 B,B 指向 C。但 React 的更新队列是环形的。为什么?为了方便。
想象一下,你正在维护一个队伍。现在来了个新队员(Update Node),你把他插在队尾。如果队伍是单向的,你每次要处理下一个队员,都得从头开始遍历,直到找到队尾。这太慢了!
如果队伍是环形的呢?
队尾的队员手里握着队长的电话号码(指向 queue 本身)。
当 dispatchAction 把新队员插在队尾时,它会顺手把新队员的 next 指针指向队长的电话号码。这样,无论队伍多长,新队员都知道“谁是队长”。
代码示例:环形链表的插入
class UpdateQueue {
constructor() {
this.first = null; // 队首
this.last = null; // 队尾
}
enqueue(action) {
const update = { action, next: null };
if (this.last === null) {
// 队列空了,新队员既是首也是尾
this.first = update;
this.last = update;
} else {
// 队列不空,新队员接在队尾
this.last.next = update;
this.last = update;
}
// 【关键点】环形魔法:新队员的下一个,指向整个队列
update.next = this;
}
}
这个结构极其精妙。它保证了:
- O(1) 的时间复杂度插入:不管队列里有多少个
setState,你只需要修改最后一个节点的指针,不需要遍历。 - O(N) 的时间复杂度遍历:渲染的时候,我们需要把队列里的所有动作都执行一遍。因为是环形的,我们只需要从
first开始,顺着next走,走到next指向first的时候,说明走了一圈回来了,结束。
第四部分:渲染循环与状态合并
现在,队列里塞满了更新,React 该怎么处理呢?这就涉及到了渲染循环。
每当调度器决定要渲染组件时,它会调用 processUpdateQueue。这个函数的任务只有一个:把队列里的动作都执行了,算出新状态,然后更新 memoizedState。
function processUpdateQueue(queue, prevState) {
let newState = prevState;
let firstUpdate = queue.first; // 获取队首
// 只要还没转圈圈(没回到 first),就继续走
while (firstUpdate !== queue) {
const action = firstUpdate.action;
// 如果 action 是函数,就执行函数;如果是值,就直接赋值
newState = typeof action === 'function'
? action(newState)
: action;
// 移动指针,准备处理下一个
firstUpdate = firstUpdate.next;
}
// 更新完成,清空队列(可选,React 有时会保留用于重渲染,但通常是清空)
// 这里为了演示内存清理,我们清空
queue.first = null;
queue.last = null;
return newState;
}
这里有个逻辑陷阱。注意看 newState 的计算方式。
假设你有 count 是 0。
- 你调了
setCount(1)。队列里有一个动作{ action: 1 }。 - 你紧接着调了
setCount(c => c + 1)。队列里又有一个动作{ action: (prev) => prev + 1 }。
在渲染的那一刻,processUpdateQueue 会:
- 拿到
prev(0)。 - 执行第一个动作
1->newState = 1。 - 执行第二个动作
(prev) => prev + 1->newState = 1 + 1 = 2。
这就是 React 的“批处理”特性。 即使你在同一个事件循环里调用了十次 setState,React 也会等到下一次渲染前,把所有动作串起来,一次性计算出一个最终结果。
第五部分:内存泄漏?不,那是“生命周期”
好,现在我们回到最核心的问题:内存。
很多人担心:“如果我在一个组件里疯狂 setState,会不会导致内存泄漏?”
让我们来算一笔账。
1. 链表节点的生命周期
每当 dispatchAction 被调用,一个 update 对象就会被创建。它被挂载到 fiberNode.updateQueue 上。
这个 fiberNode 属于哪个组件?属于哪个组件树?
只要你的组件没有被卸载(unmount),这个 Fiber 节点就存在于内存中。
只要 Fiber 节点存在,它的 updateQueue 就存在,里面的链表节点也就存在。
这听起来像泄漏,对吧?
不,这不是泄漏,这是缓存。
React 为什么要保留这些更新?为了防止在渲染过程中,新的更新被覆盖了,导致状态丢失。如果你在渲染过程中又调用了 setState,React 需要重新计算。如果没有旧的队列,它就不知道怎么计算。
2. 组件卸载时的清理
真正的“泄漏”杀手,不是队列本身,而是组件被卸载了,但队列还在。
React 是怎么防止这个的?答案很简单:组件卸载时,Fiber 节点也被卸载了。
当 React 执行 unmountComponentAtNode 时,它会从 DOM 树上摘除节点,然后从 Fiber 树上摘除节点,最后把整个 Fiber 节点交给垃圾回收器(GC)。
因为更新队列只是 Fiber 节点的一个属性(memoizedState),当 Fiber 节点被销毁,整个链表自然也就烟消云散了。
代码模拟:卸载时的销毁
function unmountComponent(fiber) {
// 1. 清空 DOM
if (fiber.dom) {
fiber.dom.remove();
}
// 2. 递归卸载子节点
if (fiber.child) {
unmountComponent(fiber.child);
}
// 3. 【关键】清理更新队列
// 这一步其实不需要显式写,因为 fiber 对象本身要被回收了
// 但为了演示,我们可以把指针置空
fiber.memoizedState = null; // 断开引用
fiber.updateQueue = null; // 断开引用
// 4. 回收
fiber = null;
}
所以,React 的内存管理策略是:不要试图手动清理队列,只要确保组件树被正确卸载即可。
第六部分:并发模式与取消更新
到了 React 18,事情变得更复杂了。引入了并发渲染(Concurrent Rendering),dispatchAction 的逻辑发生了质变。
现在,当你调用 setState 时,React 并不一定会立刻执行。它可能会把这个更新标记为“高优先级”或者“低优先级”,把它放在调度器里排队。
这时候,环形链表的威力再次体现。
如果你在组件渲染过程中(比如在 useEffect 里),或者在一个低优先级的更新正在处理时,你又调用了 setState,新的更新会被追加到链表的尾部。
React 的调度器会根据优先级决定:
- 高优先级更新:打断当前的渲染,直接处理新的更新。
- 低优先级更新:等待当前渲染完成,或者在下一个时间片处理。
如何防止内存泄漏?
如果组件在更新过程中被卸载了怎么办?
React 引入了 AbortController 类似的机制(虽然源码里更复杂,叫 interruptedWork)。
当组件卸载时,React 会检查当前正在处理的更新队列。如果发现队列里有更新正在执行,React 会中断这些更新,并丢弃它们。
这意味着,即使你在组件卸载的瞬间往队列里塞了一个超级紧急的更新,这个更新也会被无情地抛弃。这就是 React 的“冷酷”之处——为了性能和正确性,旧组件的更新必须被杀掉。
第七部分:实战中的坑——闭包陷阱
既然聊到了 dispatchAction 和链表,我们不得不提一下由此引发的最经典的 bug:闭包陷阱。
为什么你的 useEffect 或者 useCallback 里的函数,拿到的永远是旧的状态?
让我们回到 dispatchAction。
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
console.log(count); // 这里打印的永远是 0
}, 1000);
return () => clearInterval(interval);
}, []); // 依赖项是空数组
}
当你调用 setCount(1) 时,dispatchAction 创建了一个更新节点,把它扔进了队列。
但是!memoizedState 的值并没有立刻改变。
在 React 渲染完成之前,memoizedState 依然指向旧的值。而你的 useEffect 是在渲染阶段挂载的。此时,它捕获的 count 变量,就是渲染那一刻 memoizedState 指向的那个旧值。
链表在这里起了什么作用?
链表里虽然有了新的更新,但 React 还没来得及“遍历链表”并更新 memoizedState。在那一刻,时间线是这样的:
dispatchAction执行:队列里有了新节点。memoizedState还指着旧值。useEffect执行:闭包捕获了memoizedState(旧值)。- 渲染结束,
memoizedState更新为 1。
所以,闭包陷阱的本质,不是内存泄漏,而是“快照机制”。链表里的更新是未来的事,而闭包是过去的快照。
怎么解决?
要么加 count 到依赖项(这会频繁触发 effect)。
要么使用 useRef。
const countRef = useRef(0);
useEffect(() => {
const interval = setInterval(() => {
console.log(countRef.current); // 总是最新
}, 1000);
}, []);
useRef 的本质,就是利用了 React 的 Fiber 结构,让你能拿到 memoizedState 的最新值,绕过闭包的快照限制。
第八部分:总结——链表的哲学
好了,我们终于讲完了。
让我们回顾一下 useState 的深度之旅:
- 表象:
setCount改变了变量。 - 真相:
setCount实际上是dispatchAction,它往一个环形链表里塞了一个节点。 - 机制:环形链表保证了 O(1) 的插入效率,让 React 能在毫秒级处理成百上千个状态更新。
- 内存:内存不是靠“清理”来管理的,而是靠“生命周期”。组件卸载,Fiber 消失,链表也就随之烟消云散。
- 并发:在 React 18 中,链表变成了调度器手中的筹码,高优先级可以打断低优先级,卸载时可以中断更新。
React 的设计哲学在这里体现得淋漓尽致:看似简单的 API,背后支撑着极其复杂的工程架构。
那个小小的 useState,就像一个精密的齿轮。它通过环形链表连接着过去(旧状态)和未来(新状态),在内存的海洋中游刃有余,既不堆积垃圾,也不丢失数据。
所以,下次当你再写 setCount 的时候,不要只把它当成一个简单的赋值。你要知道,你手里握着的,是一个被精心设计的、环环相扣的数据结构,正在等待着下一次渲染的召唤。
现在,去拥抱你的 Fiber 节点吧,它们比你想的更爱你。
(完)