React Fiber
一、危机起源 —— 为什么需要 Fiber
大多数屏幕刷新率都是 60Hz,这意味着浏览器必须在 16.6ms ( 1000ms / 60 ) 里完成这一帧的所有工作(JS 执行 + 样式计算 + 布局 + 绘制),否则用户会感觉掉帧卡顿。
JS 引擎线程和 GUI 渲染线程是互斥的。JS 又是单线程的,主线程阻塞的时候,界面交互将无响应。
在 React 16 之前,React 使用 Stack Reconciler /ˈrɛkənˌsɪlər/ 更新组件。
这种方式从根节点开始,递归的逐层向下 diff,一旦发现差异就标记更新。由于代码执行于“调用栈”(Call Stack),所以直到整棵树都遍历完成,调用栈才能清空。这个过程是 不可中断 的,在此期间,UI 将一直无响应。
既然原生 JS 调用栈无法中断,React 团队决定:我们自己用 JS 模拟一个“调用栈”!
我们需要一种新的数据结构,它可以:
-
把树变成链表(便于分段处理)。
-
记录“我现在干到哪了”(保存状态)。
-
随时可以停止,随时可以恢复。
这个新的数据结构,就是 Fiber。
-
英文原意:Fiber (纤维) —— 比 Thread (线程) 更细微的控制单元。
-
本质:Fiber 既是 虚拟 DOM 的一个节点,也是一个 “执行单元”(Unit of Work)。
本章关键点
-
Stack Reconciler(递归调和):React 15 使用递归更新虚拟 DOM。
-
同步阻塞:递归一旦开始无法中断。对于深层组件树,Diff 过程可能持续超过 16ms。
-
JS 与 GUI 互斥:长时间的 JS 执行会阻塞 GUI 渲染线程。
-
掉帧与卡顿:导致动画卡顿、用户交互无响应。
-
解决方案:引入 Fiber 架构,将同步递归改为异步可中断的更新。利用时间切片,将大任务拆解,在浏览器空闲时执行,优先响应高优先级任务。
二、深入 Fiber
-
架构层面:它是 React 16 的核心架构,实现了异步可中断的更新。
-
数据结构层面:它是一个 链表节点。
一个 Fiber 节点代表了 React 渲染过程中的一步工作。处理一个
<MyButton />组件的 Fiber,意味着 React 要去执行这个函数;处理一个<div>的 Fiber,意味着 React 可能需要去比对它的className或style是否发生了变化。
- 执行层面:它是一个 执行单元(Unit of Work)。每个 Fiber 节点代表了一个组件需要做的工作(比如更新 State、Diff Props、操作 DOM)。
数据结构
是一个链表,通常称为 LCRS 树(Left Child Right Sibling,左子右兄)
说是链表,是因为节点之间只有指针,遍历时依赖 循环(While Loop)。不同于用系统原生调用栈递归的遍历树,我们手动模拟一个栈,把树结构“拉平”成了链表式的遍历路径。
每个 Fiber 节点都有三个关键指针,用于指引遍历:
-
child:指向第一个子节点(大儿子)。
-
sibling:指向下一个兄弟节点(二弟)。
-
return:指向父节点(回头路)。
双缓存机制
React 在同一时间,内存中会存在两棵 Fiber 树:
-
current 树:屏幕上当前显示的内容对应的 Fiber 树。
-
workInProgress 树:正在内存中构建的、即将用来更新屏幕的 Fiber 树。
alternate 指针与复用
每个 Fiber 节点都有一个 alternate 属性,指向它在另一棵树上的对应节点
-
Fiber 对象(壳子):只要 key 和 type 相同,100% 复用 alternate 指向的那个对象。直接修改那个对象的属性(指针)。
-
Props/State(数据):0% 复用。永远创建新的数据对象,然后把 Fiber 壳子的指针指向新的数据对象。
-
安全性:
-
Current 树:指向旧数据。
-
WIP 树:指向新数据。
-
两者互不干扰,直到 Commit 阶段交换位置。
-
流程总结
当 React 决定更新时:
I. 构建开始
-
它不会直接改 current 树。
-
它会使用 WIP 作为草稿(复用 alternate 指向的旧节点作为基础)。
II. Work Loop(Render 阶段)
- 在草稿上进行各种修改(执行 Hooks、Diff、打标签)。注意:这个过程是在内存里的,用户看不见,所以可以被中断!
II. Commit 阶段
-
等草稿全部画完(render 阶段结束),React 只要做一个动作:把墙上的画换成桌子上的草稿。
-
也就是:root.current = workInProgress,一次性替换,瞬间完成。
精简源码:
1 | function FiberNode(tag, pendingProps, key, mode) { |
关于 React Hook
Hooks 的本质是赋予函数组件持久化记忆能力的数据结构。由于函数本身执行完即销毁,React 将这些状态和逻辑保存在了对应的 Fiber 节点上,也就是这里: fiber.memoizedState。
在上面的源码中,我们看到过这个片段:
1 | function FiberNode(...) { |
-
如果是类组件,它存的就是 this.state 这个对象(比如 { count: 0 })。
-
如果是函数组件,它存的是一条 Hook 链表的头指针!
假设你写了这样的组件:
1 | function MyComponent() { |
在 Fiber 的 memoizedState 上,它长这样:
1 | Fiber.memoizedState |
每一个 Hook 在 React 底层也是一个对象,大概长这样(精简版):
1 | const hook = { |
场景 A:首次渲染(Mount)
-
没有 Current 树。WIP Fiber 是崭新的。
-
函数执行到 useState(0):React 创建一个新的 Hook 对象,memoizedState = 0,挂到 WIP Fiber 上。
-
执行到 useState(‘React’):再创建一个 Hook 对象,连在第一个 Hook 的 next 后面。
-
执行完毕,WIP Fiber 上建好了一条完整的 Hook 链表。
场景 B:组件更新(Update)—— 替身术再现!
这里完美印证了我们上一节讲的“复用”概念。
-
此时,Current Fiber(旧相框)上挂着旧的 Hook 链表。WIP Fiber(新相框)准备执行。
-
函数再次执行到第一行 useState(0)(React 内部此时调用的是 updateWorkInProgressHook 函数)。
-
React 去 Current Fiber 上找到第一个旧 Hook。
-
克隆! React 根据旧 Hook 的状态,计算出最新的状态,然后创建一个新的 Hook 对象,挂在 WIP Fiber 上。(注意:这里再次体现了数据的不可变性,Current Hook 不会被修改)。
-
函数执行到第二行 useState(‘React’),React 去找 Current Fiber 上的第二个旧 Hook,克隆、计算、挂载到 WIP Fiber 上。
React 逐一比对 clone 出新的 Hook 对象,不影响 current hook
React 依赖链表顺序来维护状态。条件语句会导致执行时的 Hook 顺序与挂载时的链表顺序不一致,从而导致状态读取错位。
因为你修改的是 ref.current = newValue。你只是修改了这个对象内部的属性,你并没有调用可以触发调度器(Scheduler)更新的函数(如 setCount)。没有触发调度器,Fiber 就不会重新构建,也就不会重新渲染。它就像一个藏在 Fiber 节点里的“秘密储物箱”。
本章关键点
关于 Fiber
-
定义:Fiber 是 React 16 引入的一种数据结构,本质是一个对象,也是一个执行单元。
-
链表结构:它使用 child、sibling、return 字段构成链表树。
-
目的:为了支持异步可中断的更新。这种结构让 React 可以通过循环(而非递归)遍历组件树,配合全局变量记录当前处理节点,随时暂停、恢复。
-
双缓存:Fiber 使用 current 和 workInProgress 两棵树。更新时在内存中的 workInProgress 树上操作,完成后一次性替换,保证 UI 渲染的原子性。
关于 hook
-
宿主与本质:Hooks 的本质是赋予函数组件持久化记忆能力的数据结构。由于函数本身执行完即销毁,React 将这些状态和逻辑保存在了对应的 Fiber 节点上(fiber.memoizedState)。
-
数据结构:在函数组件内部调用的多个 Hooks,在底层会构成一个单向链表结构。
-
双缓存与克隆:在组件更新(Update)时,React 会顺着当前显示的 Current Fiber 上的旧 Hook 链表,逐一对比并克隆出新的 Hook 对象,挂载到正在构建的 WIP Fiber 上。这也体现了 React 数据的不可变性原则。
-
规则的底层原因:正因为状态的存取完全依赖于这条链表的顺序匹配,所以 React 严格要求 Hooks 不能包含在条件或循环中。一旦顺序错乱,正在执行的 Hook 就会拿到错误的旧 Hook 状态,导致严重的 Bug。
三、Scheduler 调度器 —— 时间切片 和 Lane 模型
实现原理
1. 为什么不用原生 requestIdleCallback?
浏览器有一个 API 叫 requestIdleCallback(简称 rIC),它的作用是:在浏览器空闲的时候执行回调。这听起来像是 React 梦寐以求的时间切片,早期 React 确实尝试过用它,但最终放弃了。原因有两个:
-
Apple Safari 至今都不支持这个 API(由于这个 API 本身也问题频出)
-
触发时机极不稳定:rIC 的最高执行频率大约是 20次/秒(也就是 50ms 执行一次)。而在极度繁忙的情况下,它甚至可能永远不会被触发(导致 React 渲染被“饿死”)
因此,React 利用浏览器的 Event Loop(事件循环),自己写了一个 调度器(Scheduler)
2. 为什么是 MessageChannel?
首先,调用栈和微任务肯定不行,两者都会卡死。
在宏任务里,setTimeout(fn, 0) 也不行。在嵌套调用的情况下,setTimeout 会有至少 4ms 的强制延迟。一帧总共才 16.6ms,白白浪费 4ms 是不可接受的,所以最后选择了 MessageChannel。
仅当 MessageChannel 不可用的时候,React 才会使用 setTimeOut(fn, 0) 作为 fallback 方案。
底层揭秘:
MessageChannel 会创建一个管道,包含两个端口 port1 和 port2。当 port1 发送消息时,port2 的 onmessage 回调会被放入宏任务队列。
关键在于,它是零延迟的!
React 的操作是:
-
干了 5ms 的活,发现该歇了。
-
React 通过 port2.postMessage(null) 发送一个消息。
-
JS 执行栈清空,浏览器立刻接管,去更新 DOM、画动画。
-
浏览器忙完了,从宏任务队列里拿出 port1.onmessage 回调,React 继续干下一截活。
时间切片
1 | function workLoopConcurrent(nonIdle: boolean) { |
这是 React 实现时间切片的核心源码。
在还有需要处理的 Fiber 节点时,会进入工作循环。之后每完成一个 Fiber 节点,React 都会去查看是否已经到了预期的分片时间(通过现在的时间 + 5ms 或者 25ms),如果到了,则把控制权交还给浏览器。
函数的参数 nonIdle 是一个布尔值,表示当前的工作是否是“非空闲(non-idle)”任务。在 React 中,像 Transition(过渡)或 Retries(重试)这类会阻塞新内容展示的任务被视为 nonIdle,而后台预渲染等任务被视为 Idle。
- 在遇到非空闲任务时,React 主动占用更多的时间(占用 25ms,即使会导致降帧到 30FPS)。
- 遇到空闲任务,比如预渲染不在视口内到数据,React 要保证绝不影响用户当前的操作和页面动画,所以在 16.6ms 里只占用 5ms,慷慨的把大量时间还给浏览器来处理 CSS 动画、滚动事件和页面重绘等。
React 团队在进行大量用户体验研究后得出结论:在页面过渡期间,尽快向用户展示新内容,比保持一个无足轻重的动画完美丝滑重要得多。 所以,主动降帧(占据更长的执行时间)是为了防止高频动画“饿死”了 React 的渲染任务,从而最大化“关键渲染”的吞吐量。
Lane 模型 (车道模型)
精简源码:
1 | // 数字越小(1所在的位置越靠右),优先级越高! |
我们有了时间切片,可以随时暂停。但暂停了之后,如果发生了更紧急的事情怎么办?
在 React 16 的时候,React 使用的是 ExpirationTime(过期时间)来表示任务的优先级,但这套机制很难处理多个任务的“批处理(Batching)”或者“挂起(Suspense)”等复杂并发场景。于是在 React 17 之后,引入了基于位运算(Bitmask)的 Lanes 模型,它使得优先级的表达、合并和分离变得极其高效。
当高优任务(如用户输入)触发时,调度器会检测到更高优先级的 Lane。此时,React 会废弃当前正在构建的 workInProgress 树(因为还没有 Commit 到屏幕上,所以无副作用),立刻去构建高优任务的树。高优任务完成后,再重新开始低优任务的构建。
借鉴了操作系统的概念,React 使用了一个 31 位的二进制数字来表示不同的优先级。每一位(Bit)代表一条“车道”。
为什么用二进制?性能爆炸!
位运算的执行速度是 CPU 级别的,比任何 JS 循环都要快。React 源码里到处都是这种极致的性能压榨。
当我们需要判断某个任务队列里有没有高优任务时,不需要遍历数组。
只需要做一个按位与(&) 或按位或(|) 运算:
-
合并任务:LaneA | LaneB,一瞬间就把两条车道占用了。
-
判断是否有交集:Lanes & LaneA !== 0,一瞬间就能判断出是否包含某个优先级。
-
找最高优先级:分离出最低位的 1(通过位运算 lanes & -lanes),直接找到当前最紧急的车道!
冒泡机制(childLanes)
组件产生更新时,会从当前 Fiber 一路向上遍历到 Root,给经过的每一个祖先节点打上 childLanes。在接下来的向下遍历(beginWork)过程中能够快速跳过不需要更新的整条分支。如果一个父组件的 childLanes 和当前的渲染优先级不匹配,React 就不必再进入它的子组件去寻找了。
这使得在重新渲染时的向下递归可以做极大的性能剪枝(Bailout)。
结合 React Hooks:实战中的 useTransition
使用这个 hook,我们可以手动分配车道优先级。
requestUpdateLane(fiber: Fiber): Lane
在源码中,会检查是否是通过 startTransition 发起的更新,如果是,会分配进 transition 车道;否则根据当前的调度器事件分配默认优先级。
1 | import { useState, useTransition } from 'react'; |
在这个代码中:
如果你连续输入 “A”、“B”、“C”:
-
setInputValue 会触发 3 次高优渲染,输入框瞬间变成 “ABC”。
-
setFilterQuery 也会被触发 3 次,产生 3 个低优任务。
-
React 绝不会把 “A”、“AB”、“ABC” 的列表老老实实算 3 遍。它会不断被打断,不断撕毁 “A” 和 “AB” 的草稿,直到最后用户停手了,它才会安安稳稳地拿着最新的 “ABC” 去把 SlowList 渲染出来。
-
在计算组件状态时,React 会处理 UpdateQueue 中的所有更新。由于高优任务已经更新了相关的 State,组件再次执行时会读取到最新的状态。
本章关键点
时间切片是怎么实现的?
React 的时间切片是在 Scheduler 调度器中实现的。它会在一个循环中不断处理 Fiber 节点,每次处理前判断当前切片执行时间是否超过了预期时间( 例如 5ms)。如果超过,就会中断执行,将控制权还给浏览器。
为什么不用 requestIdleCallback 或者 setTimeOut ?
前者兼容性差、触发不稳定;后者有 4 ms 的嵌套延迟,会浪费宝贵的帧时间。
React 是如何处理任务优先级的?低优先级任务被高优先级任务打断后会怎样?
React 采用了基于二进制位运算的 Lane 模型来管理优先级。不同的事件拥有不同的优先级车道(如点击事件属于最高的 SyncLane)。
当高优任务(如用户输入)触发时,调度器会检测到更高优先级的 Lane。此时,React 会废弃当前正在构建的 workInProgress 树(因为还没有 Commit 到屏幕上,所以无副作用),立刻去构建高优任务的树。高优任务完成后,再重新开始低优任务的构建。
四、工作循环 —— Render 与 Commit 的二重奏
在前面第二章,我们有提到过,创建 DOM 节点发生在时间切片里(Render 阶段),但把它们插入到页面上,是集中在最后一次性完成的(Commit 阶段)。
这是为了防止用户看到半成品的残缺页面。在这一章,我们更深入这两个过程。
Render 阶段:深度优先遍历的“递”与“归”
Render 阶段的核心任务就俩:
- 找出哪些组建需要更新,怎么更新
- 在内存中构建出 WIP 树
React 会从根节点开始,进行一次深度优先遍历(DFS)。在这个遍历过程中,有两个极其重要的函数,分别对应递过程和归过程:beginWork 和 completeWork。
整个 Render 阶段都在内存中进行,是可以被时间切片(5ms)打断的。
① beginWork(递阶段:向下走)
当 React 访问到一个 Fiber 节点时,会调用 beginWork。
-
它的工作:
-
判断这个节点是否需要更新(检查 props 和 state 有没有变)。
-
如果是函数组件,在这里执行你的函数(没错,你写的组件代码就是在这里被调用的)。
-
拿到函数返回的 JSX 后,和旧的 Fiber 节点做对比(这就是大名鼎鼎的 Diff 算法发生的地方)。
-
根据 Diff 结果,生成子节点的 Fiber,并给它们打上“副作用标签”(Flags)。
-
-
副作用标签(Flags):这也是用二进制表示的,比如这个节点是新增的(Placement)、更新的(Update)还是删除的(Deletion)。
② completeWork(归阶段:向上走)
当一个节点没有任何子节点,或者它的子节点都已经处理完了,React 就会调用 completeWork,然后顺着 return 指针回到父节点。
-
它的工作:
-
创建真实的 DOM 节点(只在内存中创建,比如调用
document.createElement)。 -
将子孙节点的 DOM 挂载到自己身上。
-
收集所有的 Flags:为了在 Commit 阶段不遍历整棵树,React 会在这个阶段把所有子节点的 Flags “冒泡”收集到父节点上。这样最终根节点就会知道整棵树哪里发生了改变。
-
举个极其直观的例子:
假设你有这样一个结构:App -> div -> span
遍历顺序是:
-
beginWork(App)
-
beginWork(div)
-
beginWork(span)
-
completeWork(span) -> 在内存中创建
<span>节点 -
completeWork(div) -> 在内存中创建
<div>,并把<span>塞进<div>里 -
completeWork(App)
Commit 阶段:一口气改变世界
当整棵 workInProgress 树都完成了 completeWork,Render 阶段宣告结束。React 拿着这棵打满标签(Flags)的新树,进入 Commit 阶段。
注意:Commit 阶段是完全同步的、不可中断的! 哪怕这棵树再大,也要一口气执行完,因为我们要操作真实的 DOM 了。
Commit 阶段分为三个子阶段:
① BeforeMutation 阶段(执行 DOM 操作前)
-
此时真实的 DOM 还没有被修改。
-
主要任务:读取当前 DOM 的状态。比如类组件的
getSnapshotBeforeUpdate生命周期就是在这里执行的。它可以帮你记住 DOM 改变前的滚动条位置。
② Mutation 阶段(执行 DOM 操作)
-
React 会根据 Render 阶段收集到的 Flags,真正地执行 DOM 的增、删、改。
-
在这个阶段的某一瞬间,React 会执行一行极其核心的代码:
root.current = finishedWork;
(这就是第二章讲的双缓存机制:瞬间将当前显示的树替换为新构建的树)
③ Layout 阶段(执行 DOM 操作后)
-
此时真实的 DOM 已经被修改完了,而且 current 树已经替换完毕。
-
主要任务:执行需要在 DOM 更新后立刻同步执行的操作。
-
重点:useLayoutEffect 的回调函数就是在这个阶段同步执行的。因为此时浏览器还没来得及把新 DOM 绘制(Paint)到屏幕上,如果你在 useLayoutEffect 里又修改了状态,React 会立刻重新安排一次更新,用户完全看不到屏幕闪烁。
本章关键点
useEffect 在哪里执行?
它会在整个 Commit 阶段完成,并且浏览器已经把画面绘制(Paint)到屏幕上之后,才由调度器(Scheduler)以一个普通的宏任务去异步执行。
useEffect被设计为 异步、非阻塞的
useEffect 里通常是发网络请求、绑事件监听等耗时操作。如果在 Commit 阶段同步执行,会卡住浏览器的渲染,导致页面出现卡顿。
useEffect 和 useLayoutEffect 有什么根本区别?
- useEffect 执行时机:
commit 阶段完成,浏览器绘制画面之后。
- useLayoutEffect 执行时机:
在 Commit 阶段的 Layout 子阶段执行的,此时 DOM 已经更新,但浏览器还没有进行绘制(Paint)。
useLayoutEffect被设计为同步、阻塞的
适合处理需要读取 DOM 尺寸并立刻触发重新渲染的逻辑,能防止页面闪烁。
React 16 废弃了 componentWillMount、componentWillReceiveProps 等生命周期,为什么?
因为这些生命周期是处在 Render 阶段的。在 Fiber 架构下,Render 阶段是可中断、可重新开始的。
如果一个低优任务被打断,它重新开始时,这些生命周期会被多次调用。如果开发者在这些函数里写了发起 AJAX 请求或修改全局变量的代码(副作用),就会导致请求发了多次、状态错乱的严重 Bug。
所以, React 废弃了它们,推荐把副作用写在 Commit 阶段之后触发的 componentDidMount 或 useEffect 里。
什么是 beginWork 和 completeWork?
它们是 Render 阶段的核心函数。
beginWork 负责自顶向下地处理节点,调用组件函数,执行 Diff 算法,并标记副作用(Flags);
completeWork 负责自底向上地处理节点,在内存中创建真实 DOM 节点,拼接 DOM 树,并将副作用向上冒泡收集,为 Commit 阶段做准备。
六、React 18 与并发模式
所谓的“并发(Concurrent)”,在单线程的 JavaScript 中并不是指“同时执行多段代码”,而是指 “React 可以同时准备多个版本的 UI,并根据优先级灵活切换”。
1. 自动批处理(Automatic Batching):极致的性能压榨
在 React 18 之前,如果你在一个事件处理函数里连续写了三个 setState,React 会聪明地把它们合并成一次更新(批处理)。
但是,如果这三个 setState 是写在 setTimeout 里,或者 Promise.then 里的呢?
只要在同一个宏任务/微任务周期内触发的更新,它们会被赋予相同优先级的 Lane。
调度器(Scheduler)一看:“哟,这几个任务都在同一条车道(Lane)上啊!”于是直接做个按位或(|)运算,合并车道,只开启一次 Render 阶段的构建。
2. useTransition:并发渲染的绝对杀器
这点在上面的章节演示过了。
这个 hook 完美利用了 Fiber 的“可中断”特性,能让 React 优先处理用户输入这种紧急的事件,其他的则放入 Lane 的次要车道里。
3. Suspense:组件级的“异步控制权”
Suspense 允许我们以同步的写法去写异步代码。
1 | <Suspense fallback={<Loading />}> |
当你在这个
没错,抛出的不是 Error,而是一个 Promise。
-
React 在 Render 阶段(beginWork)执行这个组件时,捕获到了这个 Promise。
-
React 说:“哦,你在等数据啊。行,那你先挂起(Suspend)。”
-
React 会停止深入这个组件的子节点,转而往上找最近的
<Suspense>边界,把它的 fallback(如 Loading)渲染出来。 -
同时,React 把这个 Promise 拦截下来,给它加上一个
promise.then(ping);这样的回调。其中 ping 就像 setState 一样,会创建更新任务,让 scheduler 重新渲染整个组件。 -
当请求完成,Promise resolve 时,.then() 被触发,React 会重新把这个 ProfileDetails 加入调度队列,重新开始 Render。这次数据有了,就不抛出了,顺利渲染完毕!
这就是利用 Fiber 机制实现的代数效应(Algebraic Effects)。
- 标题: React Fiber
- 作者: 三葉Leaves
- 创建于 : 2026-02-13 00:00:00
- 更新于 : 2026-03-16 12:05:06
- 链接: https://blog.oksanye.com/14093c76bd0f/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。