React 源码漫游:从useState到 Fiber,我终于知道自己写的代码去哪了
直到某天我盯着 setState 发呆:我调用了它,然后呢?它到底把我的状态放哪了?又是谁把页面变了的?
引子:一个让我开始怀疑人生的疑问
事情是这样的。
某天我写了一段再普通不过的代码:
jsx
function Counter() { const [count, setCount] = useState(0) console.log('render') return <button onClick={() => setCount(count + 1)}>{count}</button> }每次点击,console.log('render')都会打印一次。我盯着这行日志,突然冒出一个问题:
"这个 count,每次都是新的变量,那 React 是怎么知道上一次的值是多少的?"
换句话说:useState 返回的 count,明明是个常量,为什么每次渲染都能拿到最新的值?
这个问题看起来简单,但它逼着我打开了 React 的源码仓库。而这一打开,就是一场从"会用"到"看懂"的漫长旅程。
第一章:从 JSX 到 ReactElement——代码的第一次变形
我写的 JSX:
jsx
<button onClick={handleClick}>点击</button>编译后变成了:
js
React.createElement('button', { onClick: handleClick }, '点击')再进一步,createElement返回的是一个普通对象,大概长这样:
js
{ type: 'button', props: { onClick: handleClick, children: '点击' }, key: null, ref: null, $$typeof: Symbol(react.element) }这个对象,就叫 ReactElement。
它是"描述 UI 应该长什么样"的数据结构,不是 DOM,也不是组件实例。
看到这里我才明白:React 里写的 JSX,本质上就是在"用对象描述界面"。
而$$typeof: Symbol(react.element)这个字段,是 React 用来防止 XSS 的——因为 Symbol 无法被 JSON 序列化,所以服务端返回的恶意 JSON 无法伪装成 ReactElement。
一个小小的 Symbol,背后是一个安全设计。
第二章:Fiber——React 的"虚拟任务调度系统"
理解了 ReactElement,我遇到了 React 16 之后最核心的概念:Fiber。
我一开始以为 Fiber 是"虚拟 DOM 的升级版",后来发现完全不是。
Fiber 的本质,是一个"可以被中断、可以被打断、可以被恢复"的执行单元。
为什么需要 Fiber?
在 React 15 之前,React 的更新是递归的:
js
function reconcile(element) { // 处理当前节点 // 递归处理所有子节点 // 递归处理所有兄弟节点 }递归的问题在于:一旦开始,就必须一口气执行完,不能中断。
如果组件树有几千个节点,一次更新可能要跑几百毫秒,期间浏览器完全没法响应输入——页面直接卡死。
React 16 引入 Fiber,把"递归"改成了"链表遍历":
js
{ type: 'div', child: ..., // 第一个子节点 sibling: ..., // 下一个兄弟节点 return: ..., // 父节点 ... }这样,React 就可以"走一步、停一下",把控制权交还给浏览器,等有空了再继续。
这就是时间切片(Time Slicing)的基础。
Fiber 的两棵"树"
React 在内存里维护两棵 Fiber 树:
- current 树:当前屏幕上显示的 UI。
- workInProgress 树:正在构建的新 UI。
当 workInProgress 树构建完成,React 会把current指针指向它,完成一次"切换"。
这个过程,叫 Commit。
所以 React 的更新,本质上是"在内存里构建新树,然后一次性切换"。这也是为什么我们说 React 的渲染是"异步、可中断"的。
第三章:Render 与 Commit——更新的两个阶段
理解了 Fiber 后,React 的更新流程就清晰了:Render 阶段 + Commit 阶段。
Render 阶段:可中断的"计算"
Render 阶段做的事:
- 从根节点开始遍历 Fiber 树。
- 对比新旧 Fiber,标记出需要更新的节点(diff)。
- 执行组件的 render 函数,生成新的 ReactElement。
这个阶段是可中断的。如果浏览器需要响应高优先级任务(比如用户输入),React 会暂停 Render,先处理输入,再回来继续。
这也是为什么 render 函数必须是纯函数——因为它可能被多次调用、可能被中断、可能被丢弃。
Commit 阶段:不可中断的"落盘"
Commit 阶段做的事:
- 把 Render 阶段标记的变化,应用到真实 DOM。
- 执行生命周期函数(componentDidMount、useLayoutEffect 等)。
这个阶段是不可中断的。因为它直接操作 DOM,一旦开始就必须完成,否则用户会看到"半成品"。
这也是为什么 useEffect 是异步的、useLayoutEffect 是同步的——前者在 Commit 之后异步执行,不阻塞渲染;后者在 Commit 之后同步执行,会阻塞渲染。
第四章:Hooks 的秘密——链表
回到最初的问题:useState 是怎么记住状态的?
答案藏在 Hooks 的"链表"结构里。
每个函数组件对应一个 Fiber 节点,Fiber 节点上有一个memoizedState属性,它指向一个 Hooks 链表:
js
fiber.memoizedState = { memoizedState: 0, // useState 的值 queue: {...}, // 更新队列 next: { memoizedState: null, // useEffect 的依赖 next: { memoizedState: {...}, // useRef 的值 next: null } } }每次调用 useState,React 就从链表上取一个 Hook,按顺序对应。
这也解释了Hooks 的两条铁律:
- 只能在函数组件顶层调用——因为 Hooks 是按顺序存储的,放到条件语句里会导致顺序错乱。
- 不能放到循环里——循环次数变化,Hooks 数量就变了,链表就对不上了。
我之前一直觉得这些规则"很烦",理解了链表结构后才发现:它们不是规定,是物理约束。
第五章:setState 之后发生了什么?
现在我可以回答最初那个问题了:setCount 之后发生了什么?
jsx
setCount(count + 1)- 创建 Update 对象:{ action: count + 1, next: … }。
- 加入更新队列:这个队列挂在 Hook 的 queue 上。
- 调度更新:调用 scheduleUpdateOnFiber,从当前 Fiber 向上找到根节点,标记需要更新。
- Render 阶段:遍历 Fiber 树,执行组件函数,处理更新队列,算出新的 state。
- Commit 阶段:把变化应用到 DOM。
最妙的是"更新队列"的设计:
jsx
setCount(c => c + 1) setCount(c => c + 1) setCount(c => c + 1)这三个调用不会立刻执行,而是被放进队列,等 Render 阶段一起处理。
这就是为什么函数式更新能拿到"最新值"——因为它们是按顺序在队列里执行的,而不是基于渲染时的快照。
第六章:diff 算法——React 的"最小改动"哲学
React 的 diff 算法,核心是三个假设:
- 只比较同层节点,不跨层比较。
- type 不同,直接替换。
- 用 key 标识同层节点,避免误判。
- jsx
// 旧 <ul> <li key="a">A</li> <li key="b">B</li> </ul> // 新 <ul> <li key="b">B</li> <li key="a">A</li> </ul>有了 key,React 知道这两个节点是"换了位置",而不是"内容变了"。
没有 key,React 会按顺序对比,导致大量不必要的销毁和重建。
这就是为什么"用 index 当 key"是危险的——列表顺序变化时,index 变了,React 会误以为节点变了。
第七章:并发特性——React 的"未来"
理解了 Fiber 之后,React 18 的并发特性就顺理成章了。
useTransition:把更新标记为"不紧急"
jsx
const [isPending, startTransition] = useTransition() startTransition(() => { setSearchQuery(input) // 这个更新可以被中断 })React 会先处理紧急更新(比如输入框),再处理过渡更新。
useDeferredValue:延迟某个值
jsx
const deferredQuery = useDeferredValue(query)它返回一个"延迟版"的值,让昂贵的渲染可以晚一点执行。
Suspense:等待异步数据
jsx
<Suspense fallback={<Loading />}> <Profile /> </Suspense>当 Profile 还没准备好时,先显示 Loading。
这些特性的共同基础,都是 Fiber 的"可中断、可调度"能力。
终章:读懂源码之后,我变了
理解 React 源码后,我并没有变成什么"源码大神",但我确实变了:
- 我不再害怕"为什么 render 了两次"——因为我知道 StrictMode 会故意双调用。
- 我不再乱用 useMemo——因为我知道它本身也有开销。
- 我理解了 Hooks 的规则从何而来——因为它们是链表结构的必然结果。
- 我不再觉得 React 是"魔法"——它只是一个精密的调度器 + 一个高效的 diff 算法 + 一个巧妙的链表结构。
最重要的是:我不再只是"会写 React",而是开始"理解 React 在做什么"。
P.S. 写完这篇文章后,我又在 useEffect 的依赖数组里漏了一个变量。看来,读懂源码和写好代码之间,还隔着 1000 个小时的实践。
P.P.S. React 源码很复杂,但它的核心思想其实很简单:UI 是状态的函数。其余的一切,都是为了让这句话在浏览器里高效地成立。