理解 React 的宏观架构,就像是在观察一个高效运行的微型操作系统。它不仅仅是把 UI 渲染出来,更是在毫秒级的时间里管理着资源的分配和任务的优先级。

本文是 React 源码系列的第一篇,我们从浏览器的帧预算讲起,搞清楚 React 为什么要做 Fiber 这件事,以及 Fiber 到底是什么。


1. 核心挑战:浏览器的”16.6ms”军令状

显示器的刷新率通常是 60Hz,这意味着浏览器每秒需要刷新 60 次画面。留给每一帧的时间只有:

1
1000ms / 60 ≈ 16.6ms

在这一帧内,浏览器需要完成以下工作:

  1. 处理用户输入(点击、滚动、按键)
  2. 执行 JavaScript
  3. 计算样式和布局(Style & Layout)
  4. 绘制画面(Paint & Composite)

这里的要害在于:JavaScript 执行和 UI 渲染共享同一个主线程。如果 React 的 Diff 算法跑了 100ms,浏览器在这期间无法响应用户输入,也无法绘制下一帧——用户看到的就是画面”卡死”了。

为了直观理解,可以想象一条流水线:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
一帧 16.6ms 的预算分配(理想情况):

┌──────────┬──────────┬──────────┬──────────┐
│ Input │ JS │ Layout │ Paint │
│ 处理 │ 执行 │ 计算 │ 绘制 │
│ ~1ms │ ~10ms │ ~3ms │ ~2ms │
└──────────┴──────────┴──────────┴──────────┘

如果 JS 占了 100ms:

┌──────────┬────────────────────────────────────────────────────────────┐
│ Input │ JS 执行(100ms) │
│ ~1ms │ ← 后面全部被阻塞,用户看到 6 帧空白 → │
└──────────┴────────────────────────────────────────────────────────────┘

2. React 15 的困境:一干到底的递归

在 React 16 之前,使用的是 Stack Reconciler(栈协调器)。

工作模式

Stack Reconciler 利用 JavaScript 原生的函数调用栈进行递归。一旦开始更新,React 会从根节点一直比对到叶子节点,整个过程同步且不可中断

打个比方:Stack Reconciler 就像一个”一根筋的跳水员”——跳入泳池后必须摸到池底才能浮上来,在水下的时候外界发生什么他都听不见、管不着。

简化的伪代码

Stack Reconciler 的递归大概长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
// React 15 的递归协调(简化)
function reconcileChildren(parentFiber, newChildren) {
// 遍历新的子元素
for (let i = 0; i < newChildren.length; i++) {
const child = newChildren[i];

// 递归比较——一旦进入无法中断
if (hasChanged(child)) {
updateComponent(child); // 更新该节点
reconcileChildren(child, child.children); // 继续向下递归
}
}
}

当组件树很小、Diff 只需要几毫秒时,同步递归完全没问题。但组件一旦上千,递归的调用栈会变得很深,主线程被长期霸占,用户点击按钮没反应,输入框打字出不来字。

问题的本质

问题不在于”算得慢”,而在于”不能停”:

  • JavaScript 原生调用栈是一次性的,你无法在递归中途暂停、保存现场、下次恢复
  • 浏览器没有提供”暂停 JS 执行,先刷新一帧”的能力
  • 只要递归没结束,主线程就被独占

3. Fiber 的革新:可中断的”纤程”

React 团队为了打破”一干到底”的僵局,在 React 16 中引入了 Fiber(纤维 / 纤程)。它将”一长条”的递归任务拆成了无数个”微型工作单元”。

核心思维转变

递归(Stack) 转换到了循环(Loop)

这个转变看起来简单,背后的意义却很大:

  • 递归依赖原生调用栈,无法暂停
  • 循环可以在任意迭代后 break,下次从断点继续
1
2
3
4
5
6
7
8
9
10
11
12
// 递归:无法中断
function walkTree(node) {
process(node);
node.children.forEach(walkTree); // 调用栈层层嵌套
}

// 循环:可以随时暂停
let current = rootNode;
while (current !== null) {
current = processAndReturnNext(current); // 处理一个,返回下一个
if (shouldYield()) break; // 时间到了就停
}

协作式调度(Cooperative Scheduling)

React 不再霸道地独占主线程,它学会了”看脸色”——每处理完一个微任务,就停下来问浏览器:”现在有更紧急的事吗?没有我再接着干。”

这种模式在操作系统领域有一个专门的术语叫协作式调度,和抢占式调度不同的是,任务必须主动”让出”CPU。React 的 Fiber 就是在 JavaScript 层面实现了这套协作机制。


4. Fiber 节点的数据结构

为了让任务能随时停下来,React 必须弃用原生的函数调用栈,转而自己实现一套数据结构来记录”走到哪了”。这就是 Fiber 节点。

每个 Fiber 节点本质上就是组件在内存中的”工作单元”,它既是虚拟 DOM 的升级版,也是调度系统的基本粒度。

核心字段

一个 Fiber 节点(简化后)的结构大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
interface FiberNode {
// === 节点身份 ===
tag: number; // 节点类型:函数组件、类组件、原生 DOM...
type: any; // 对应的组件函数或标签名(如 'div')
key: string | null; // 列表 Diff 用的唯一标识

// === 树结构指针(链表化的关键)===
child: FiberNode | null; // 第一个子节点
sibling: FiberNode | null; // 下一个兄弟节点
return: FiberNode | null; // 父节点(处理完子节点后返回的路径)

// === 状态与副作用 ===
memoizedState: any; // Hooks 链表 / Class 组件的 state
memoizedProps: any; // 上一次渲染的 props
pendingProps: any; // 本次待处理的 props
flags: number; // 副作用标记(Placement / Update / Deletion)
subtreeFlags: number; // 子树的副作用聚合

// === 双缓存 ===
alternate: FiberNode | null; // 指向另一棵树上的对应节点

// === 调度 ===
lanes: number; // 优先级(Lane 模型)
childLanes: number; // 子树中待处理的优先级
}

三个关键指针:child / sibling / return

传统的树结构通常用 children 数组来存储子节点,但这样就回到了”递归遍历”的老路。Fiber 选择了链表化——每个节点只需要记住三个方向:

1
2
3
4
5
6
7
8
9
10
11
12
13
      App (root)
/
child
/
Header ——sibling——> Main ——sibling——> Footer
| |
child child
| |
Nav ArticleList
|
child
|
Article
  1. child:指向第一个子节点
  2. sibling:指向下一个兄弟节点
  3. return:指向父节点(处理完后沿着这条路返回)

这种设计的精妙之处在于:React 只需要维护一个 workInProgress 指针,就能在任意时刻暂停遍历,下次恢复时从指针位置继续——因为所有”接下来该去哪”的信息都已经编码在节点的指针里了。


5. Fiber 的遍历算法:深度优先 + 链表回溯

理解了数据结构,再来看 React 怎么遍历这棵 Fiber 树。核心逻辑可以用一段伪代码概括:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
function workLoop() {
while (workInProgress !== null && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress);
}
}

function performUnitOfWork(fiber) {
// 第一步:beginWork —— 处理当前节点,生成子 Fiber
const next = beginWork(fiber);

if (next !== null) {
// 有子节点 → 继续向下
return next;
}

// 没有子节点了 → 开始"回溯"
return completeUnitOfWork(fiber);
}

function completeUnitOfWork(fiber) {
let node = fiber;

while (node !== null) {
// 完成当前节点的收尾工作
completeWork(node);

// 有兄弟节点 → 转向兄弟
if (node.sibling !== null) {
return node.sibling;
}

// 没有兄弟 → 回到父节点,继续回溯
node = node.return;
}

return null; // 整棵树处理完毕
}

用上面那棵示例树来走一遍:

1
2
3
4
5
6
7
8
9
10
11
12
遍历顺序:
1. App (beginWork)
2. Header (beginWork)
3. Nav (beginWork → completeWork)
4. Header (completeWork → 找 sibling)
5. Main (beginWork)
6. ArticleList (beginWork)
7. Article (beginWork → completeWork)
8. ArticleList (completeWork)
9. Main (completeWork → 找 sibling)
10. Footer (beginWork → completeWork)
11. App (completeWork → 全部完成)

整个过程就是:先一路向下(child),走到底了就完成当前节点,然后找兄弟(sibling),兄弟也没了就回父节点(return)。每一步都是通过指针跳转——不是递归调用——所以可以在任意节点暂停。


6. Scheduler(调度器):React 内部的”包工头”

有了可中断的 Fiber 结构,还需要一个聪明的指挥官来决定”什么时候干活、什么时候休息”。这就是 Scheduler

时间切片(Time Slicing)

Scheduler 给每个工作循环分配约 5ms 的时间片(不是 16.6ms,因为要给浏览器留余量做布局和绘制)。

1
2
3
4
5
6
7
8
9
10
11
12
// Scheduler 的核心时间判断(简化)
const FRAME_YIELD_INTERVAL = 5; // ms
let deadline = 0;

function shouldYield() {
return performance.now() >= deadline;
}

function startWorkLoop() {
deadline = performance.now() + FRAME_YIELD_INTERVAL;
// 开始处理任务...
}

基于 MessageChannel 的调度

你可能会问:React 是怎么在”让出主线程”之后重新拿回执行权的?

答案是 MessageChannel。React 不使用 setTimeout(最小延迟 4ms,太慢)也不使用 requestAnimationFrame(和帧率绑定,不够灵活),而是用 MessageChannel 创建一个宏任务,让浏览器在处理完当前帧的渲染工作后,尽快回到 React 的工作循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Scheduler 内部使用 MessageChannel(简化)
const channel = new MessageChannel();
const port = channel.port2;

channel.port1.onmessage = () => {
// 浏览器空闲了,继续处理 React 任务
if (hasWork()) {
deadline = performance.now() + FRAME_YIELD_INTERVAL;
workLoop();
}
};

function scheduleWork() {
port.postMessage(null); // 发消息,安排下一次执行
}

这样的调度节奏大概是:

1
2
3
时间线:
│ React工作5ms │ 浏览器渲染 │ React工作5ms │ 浏览器渲染 │ ...
│←── 一帧 ────→│ │←── 一帧 ────→│ │

优先级队列

Scheduler 内部维护了一个**最小堆(min-heap)**来管理任务队列,每个任务都有一个过期时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 任务优先级对应的超时时间
const IMMEDIATE_PRIORITY = -1; // 立即执行
const USER_BLOCKING_PRIORITY = 250; // 250ms 内必须执行
const NORMAL_PRIORITY = 5000; // 5s 内执行
const LOW_PRIORITY = 10000; // 10s 内执行
const IDLE_PRIORITY = Infinity; // 空闲时再执行

// 任务入队时的过期时间 = 当前时间 + 超时时间
function scheduleCallback(priority, callback) {
const startTime = performance.now();
const expirationTime = startTime + timeout[priority];

const newTask = {
callback,
expirationTime,
sortIndex: expirationTime, // 用于最小堆排序
};

push(taskQueue, newTask); // 插入最小堆
requestHostCallback(); // 安排执行
}

过期时间越早的任务排在堆顶,优先被执行。如果一个低优先级任务等了太久(超过了它的过期时间),它会”升级”成紧急任务被优先处理——这就防止了低优先级任务被无限饿死的问题。


7. shouldYield():那个决定一切的判断

把前面的内容串起来,React 的工作循环核心就是这个 while 条件:

1
2
3
while (workInProgress !== null && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress);
}

shouldYield() 做的事情很简单:检查当前时间片是否已经用完。但它的影响是巨大的——它是 React 从”同步阻塞”走向”协作调度”的关键转折点

每处理完一个 Fiber 节点(一个工作单元),React 都会调用一次 shouldYield()

  • 返回 false:时间还够,继续处理下一个节点
  • 返回 true:时间到了,记住当前的 workInProgress 指针,让出主线程

等浏览器做完渲染工作,MessageChannel 的回调触发,React 从上次暂停的 workInProgress 位置继续——无缝衔接。


8. 第一阶段总结

React 16 的这场架构重构,本质上是将同步的 UI 更新转变为异步的、可预排序的任务调度

概念 解决的问题
Fiber 节点 虚拟 DOM 的升级版,携带调度信息和副作用标记
链表化树结构 用 child/sibling/return 指针替代递归,实现可中断遍历
时间切片 每次只工作 5ms,留时间给浏览器渲染
Scheduler 优先级队列 + MessageChannel,决定什么时候做什么
shouldYield() 每个工作单元后检查,实现协作式让出

这套机制给 React 带来了两个关键能力:时间切片并发渲染。它们是后续双缓存、Hooks、并发模式等一切高级特性的基石。

下一篇,我们进入 React 更新的核心地带:Render 阶段Commit 阶段——看看 React 是如何通过”双缓存”技术把变更安全地从内存搬到屏幕上的。

Happy Coding!