Lesson 30:React 源码深度剖析 — Fiber、Reconciler 与调度器
🧩 本节信息卡(学习前先看)
- 阶段定位:Phase 4(原理篇)
- 推荐时长:120~180 分钟(首次学习)
- 先修要求:至少完成一个 React 中型项目,具备 TS 与性能调优基础
- 学习产出:区分渲染与提交、Fiber 与调度优先级,并在现有构建链中显式启用 Compiler
✅ 本节完成标准(自检清单)
- [ ] 我可以独立复现文中的核心代码片段
- [ ] 我能解释“为什么这样实现”,而不只是“照着写”
- [ ] 我记录了至少 1 个踩坑点和修复方法
🧭 本节统一学习流程
- 学习目标:先明确本节要解决的业务问题与核心 API。
- 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
- 原理深挖:理解为什么这样设计,以及常见误区。
- 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
- 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。
建议节奏:阅读 20% + 编码 60% + 复盘 20%。
🎯 本节目标:理解 React 的核心内部实现——Fiber 架构、Reconciliation 算法、Scheduler 调度机制,以及 React Compiler 的编译原理。用这些机制解释日常开发中遇到的渲染、状态与性能问题。
📦 本节产出:对 React 内部运行机制的深刻理解,能够解释 React "为什么这样设计",而不仅仅是"怎么用"。
一、从源码仓库说起
React 源码是 Monorepo。本课内部结构以 React v19.0.0 源码 为阅读基线;后续补丁或功能版本可能调整字段与调度策略,内部实现不是公开 API。下面的内部算法片段均为教学示意,省略了 lanes、错误恢复等分支,不能当作可运行的 React 实现。
packages/
├── react/ ← 公共 API(useState、useEffect 等)
├── react-dom/ ← DOM 渲染器相关入口
├── react-reconciler/ ← 协调、更新队列与 Fiber 工作循环
├── scheduler/ ← 任务优先级与协作式调度
└── react-server/ ← 服务端渲染基础(包含 Fizz / Flight 相关实现)
compiler/ ← 仓库根目录下的 React Compiler 工程NOTE
React 的设计是渲染器无关的。react 提供公共 API;DOM 与原生渲染器把协调结果应用到各自宿主环境,复用协调器的核心逻辑。RSC 的服务端模型还有独立协议和运行边界,不能把所有 React 渲染都等同为 DOM Fiber 遍历。
二、Fiber 架构深度解析
2.1 什么是 Fiber?
在 Lesson 01 我们知道了 Fiber 是 React 16+ 的内部架构。现在深入看它的数据结构:
一个 Fiber 节点就是一个普通的 JavaScript 对象,代表组件树中的一个工作单元:
// 简化结构;类型定义见 ReactInternalTypes.js,节点创建见 ReactFiber.js
interface FiberNode {
// === 身份信息 ===
tag: number // 节点类型:FunctionComponent(0), ClassComponent(1), HostComponent(5)...
type: any // 对应的组件函数或 HTML 标签名
key: string | null // 就是 JSX 中的 key
// === 树关系(父子/兄弟指针) ===
return: FiberNode | null // 父节点
child: FiberNode | null // 第一个子节点
sibling: FiberNode | null // 下一个兄弟节点
// === 状态 ===
memoizedState: any // 含义随 tag 变化;函数组件通常关联其 Hook 链表
memoizedProps: any // 上次渲染的 Props
pendingProps: any // 本次待处理的 Props
// === 副作用 ===
flags: number // 工作标记,例如 Placement、Update、父节点上的 ChildDeletion
// === 双缓冲 ===
alternate: FiberNode | null // 指向另一棵树中的对应节点
}2.2 为什么显式保存父子和兄弟关系?
Fiber 仍然组成一棵树,只是通过 child/sibling/return 指针表示。React 将工作进度保存为堆上的数据,不必把全部遍历进度隐含在一次递归调用栈里,因此可以在工作单元之间主动让出主线程。
// 调度示意;nextUnitOfWork 与下列函数由这个假想运行时提供。
function workLoop() {
while (nextUnitOfWork !== null && !shouldYield()) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
}
if (nextUnitOfWork !== null) {
scheduleContinuation(workLoop)
} else {
commitFinishedWork()
}
}这不是在任意一行 JavaScript 中间暂停,也不是换用链表就自动获得并发。组件函数内部一个很长的同步循环仍会阻塞。React Scheduler 的浏览器实现并非直接依赖 requestIdleCallback;它使用任务回调等机制安排后续工作。
2.3 双缓冲机制 (Double Buffering)
可以用 current 与 workInProgress 两个版本理解 Fiber 的双缓冲关系;并非每次更新都完整复制两份 DOM:
- current 树:对应当前屏幕上的 UI
- workInProgress 树:正在计算的新 UI;这里的“后台”不表示独立线程
- 完成的工作进入 commit,React 应用需要的 DOM 变更,并更新 current 指针;指针赋值成本很小,不代表整个提交是 O(1)
- 旧的
current变成下一次更新的workInProgress底板(复用内存)
渲染候选结果与修改当前 UI 分开,使 React 可以放弃未完成的渲染。真正的 DOM 修改和 layout Effects 仍有成本,提交过长也会阻塞绘制。
三、Reconciliation 算法(Diff)
3.1 核心假设
React 使用启发式协调算法,避免求解任意两棵树之间的最小编辑序列。官方经典说明列出两个假设:不同类型的元素产生不同的树;开发者用稳定 key 标识同级元素。跨父节点移动不会仅凭同一个 key 保留状态。React Reconciliation 说明。
这里的复杂度描述的是协调策略,不能推导出“1000 个节点恰好比较 1000 次”,更不包括组件内部计算、浏览器布局和绘制的全部成本。
3.2 Diff 的三种策略
策略一:类型变了 → 整棵子树销毁重建
// 前后两次渲染:
<div><Counter /></div> → <span><Counter /></span>
// div 变成了 span → React 销毁整个 <div> 子树(包括 Counter 的状态!)
// 重新创建 <span> 和全新的 <Counter>策略二:同位置、同 key、同宿主类型 → 复用 DOM 并更新变化的属性
// 前后两次渲染:
<div className="old" style={{color: 'red'}} />
<div className="new" style={{color: 'blue'}} />
// 同样是 div → React 只更新 className 和 style,不重建 DOM策略三:列表 → 用 key 对比
IMPORTANT
key 标识的是同级子项的身份,不是 DOM 的全局 ID。省略 key 或用 index 作为 key,增删/排序后状态可能留在旧位置、对应到另一条业务数据;并非 props 本身被 React 传错。列表结构固定时 index 未必造成问题,但有稳定业务 ID 时优先用它。
四、Hooks 的内部实现
4.1 Hook 是一个链表
函数组件的 Fiber 通过 memoizedState 关联 Hook 链表。useState/useEffect/useRef 等用它保存相应信息;并非每个名字以 use 开头的 API 都恰好对应一个这样的节点:
useState/useEffect 等 Hooks 必须保持调用顺序。
// ❌ 条件调用 Hook
function Bad({ showExtra }) {
const [name, setName] = useState('') // Hook 1
if (showExtra) {
const [extra, setExtra] = useState('') // Hook 2 ← 有时存在有时不存在!
}
const ref = useRef(null) // Hook 3(或 Hook 2?)
}React 在渲染时按调用顺序匹配这些 Hook。条件变化使某个调用出现或消失时,后面的状态槽会失去对应,React 通常会报调用顺序或数量错误。React 19 的 use(resource) 是例外:可在条件和循环中调用,仍必须位于组件或 Hook 内,且不能用 try/catch 包住 use(promise)。use 官方说明。
4.2 useState 的更新队列
// 伪代码:仅说明环形队列,Update/Hook 类型与调度函数均省略
interface StateHook<S> {
memoizedState: S // 当前值
queue: {
pending: Update<S> | null // 待处理的更新链表(环形链表)
}
next: Hook | null
}
// 当你调用 setState 时:
function dispatchSetState(fiber, queue, action) {
const update = { action, next: null }
// 将 update 加入环形链表
if (queue.pending === null) {
update.next = update // 自环
} else {
update.next = queue.pending.next
queue.pending.next = update
}
queue.pending = update
// 调度一次更新
scheduleUpdateOnFiber(fiber)
}setter 请求一次更新,后续渲染读取处理后的状态;它不会改写当前事件处理器已经捕获的状态快照。队列可能按优先级跳过、合并或重放更新,不能据此断言所有更新都在“下一个微任务”生效。
五、Scheduler 调度器
5.1 优先级系统
要分清两层:协调器用 lanes 标识和组织 React 更新;独立的 Scheduler 用任务优先级安排回调。两者存在映射,但不能把某个 Hook 一对一等同为一个 Scheduler 优先级。
| Scheduler 优先级 | 含义(实现层) |
|---|---|
| Immediate | 立即过期的任务 |
| UserBlocking | 较短的过期时间 |
| Normal | 常规任务 |
| Low | 较低优先级的任务 |
| Idle | 极长的过期时间 |
点击、连续输入、Transition 和同步刷新涉及不同 lanes 与调度路径;flushSync 也不能简单解释为提交一个 Immediate 回调。特别是 Transition 不等于 Scheduler 的 LowPriority。源码中的超时表示何时视任务为过期,不是“必须在这个时间内完成”的承诺。
5.2 时间分片(Time Slicing)
// 伪代码:在工作单元边界检查是否需要让出;不是定时打断函数。
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress) // 内部推进 workInProgress
}
// 未完成时由调度层安排 continuation;完成后再判断是否可提交。
}React 19.0.0 的 Scheduler 实现中能看到默认 frameYieldMs = 5,但这不是每 5ms 必然中断一次的公共保证。它在可让出的边界检查已经消耗的时间;同步任务、过期任务和一次耗时很长的工作单元都可能运行得更久。Scheduler 源码、该版本参数。
useTransition 把相关状态更新标记为非紧急,让 React 可以优先响应更紧急的更新,并在需要时重启候选渲染。它不会把 startTransition 回调中的同步计算移到工作线程,也不能保证大列表一定流畅。受控输入本身的值应立即更新,再把结果区域的更新放进 Transition;大量 DOM 仍可能需要虚拟列表。
六、渲染的两个阶段
关键理解:
- Render 阶段计算候选结果;并发渲染可在工作单元间让出、重启或丢弃,同步路径不一定让出,因此组件应保持纯粹
- Commit 的 DOM mutation 和 layout 工作同步执行,不像并发 render 那样时间分片;它不是数据库事务式回滚机制
useLayoutEffect的 setup 在 DOM 变更后、浏览器绘制前执行,会阻塞绘制;清理逻辑有自己的提交时序useEffect在提交后处理,通常可让浏览器先绘制,但交互触发或 layout Effect 触发更新等情况可能使其在绘制前执行,不能把“必定在 paint 后”当作保证。两种 Effect 都只在客户端运行。useEffect 时序说明
七、React Compiler 原理
7.1 它解决什么问题?
// 组件示意:省略 Product、回调类型和 ProductCard 的实现
function ProductList({ products, onSelect }) {
const sorted = [...products].sort((a, b) => a.price - b.price)
const handleClick = (id) => onSelect(id)
return sorted.map(p => <ProductCard key={p.id} product={p} onClick={() => handleClick(p.id)} />)
}这里先复制数组再排序,避免修改 props。未做缓存时,每次执行组件都会重复排序,内联回调的新引用也可能使 ProductCard 的浅比较无法跳过渲染。是否值得优化,仍需看组件成本和输入是否经常不变。
7.2 编译器分析依赖并生成缓存
Compiler 在构建时分析控制流、数据依赖和可变性,生成缓存读取、依赖比较与必要的重新计算。它可以缓存值、函数或 JSX,并不是把源代码机械改成一堆 useMemo/useCallback。不能为了画“等效输出”就在 map() 中插入普通 Hook,那仍违反 Hooks 规则。
编译器不会修复所有非纯渲染,也不保证每个组件或每个表达式都会缓存。遇到不支持的模式时可能跳过该部分;使用官方 Playground 和诊断工具检查实际结果。
7.3 如何启用
React Compiler 1.0 已于 2025-10-07 发布稳定版。它是单独的构建工具,安装 React 19 不会自动启用。课程固定 1.0.0 以便复现,升级前运行行为测试和性能对比。官方稳定版公告。
npm install -D --save-exact [email protected]Phase 1–2 的 Vite 7 + @vitejs/plugin-react 5 项目,在现有 react() 配置中合并 Babel 插件,保留 Tailwind 插件、alias 等其他配置。Compiler 放在其他 Babel 转换之前;不要只创建一个 Vite 未读取的 Babel 配置文件。
// vite.config.ts:替换 plugins 数组中的 react() 项,不是替换整个文件。
react({
babel: { plugins: ['babel-plugin-react-compiler'] },
}),Phase 3 的 Next.js 15.5 项目使用该版本的配置入口;这里的 experimental 是 Next.js 15 的集成开关,不代表 Compiler 1.0 本身还是测试版:
// 合并到 next.config.ts 的 nextConfig,保留其他 experimental 字段与包装器。
experimental: {
reactCompiler: true,
},Next.js 其他主版本的配置位置可能不同,按项目版本核对。参见 Compiler 安装文档 与 Next.js 15.5.25 配置源码。启用后确认构建确实经过 Compiler,修复诊断并复测关键行为;不要直接批量删除已有 memoization。
八、面试高频问题解析
Q1: React 的 Diff 算法为什么是 O(n)?
答: 通常用近似线性的启发式来描述:按层级匹配,类型不同则重建,同级列表用 key 辅助复用。它不求任意树的最小编辑方案,也不保证整个应用渲染是 O(n);组件内部计算和宿主操作要另算。
Q2: 为什么 Hook 不能在 if/for 里调用?
答: useState/useEffect 等 Hooks 的状态按调用顺序匹配,条件变化可能破坏对应关系。自定义 Hook 内的调用也遵守此规则;React 19 的 use(resource) 允许条件和循环调用,是需要单独记住的例外。
Q3: setState 是同步还是异步的?
答: setter 是同步调用,用来请求后续更新;当前函数里的 state 仍是这次渲染的快照。React 18+ 的现代根支持自动批处理,但不会把所有事件的更新无限合并,也不保证统一在下一个微任务执行。flushSync 可以强制刷新相关更新,应只在必须同步对接外部系统时使用。flushSync 文档。
Q4: useEffect 和 useLayoutEffect 的区别?
答: useLayoutEffect 的 setup 在 DOM 更新后、绘制前执行,适合必须在绘制前完成的布局测量。useEffect 适合与外部系统同步,通常允许先绘制,但不保证始终在绘制之后;其中耗时的同步代码也会占用主线程。
Q5: React 19 的 RSC 和传统 SSR 有什么区别?
答: SSR 生成初始 HTML,之后客户端可 hydration 接入交互;RSC 划分组件在哪里执行以及如何传输结果,两者可以组合使用。Server Components 在服务端或构建环境执行,结果进入 RSC Payload;Client Components 仍可参与首屏 HTML 预渲染,再在客户端 hydration。仅由 Server Components 引用的代码不会进入客户端包;同一依赖若也被客户端边界引用,仍可能进入。RSC 不等于每次请求 SSR。Server Components 文档。
九、练习与深入阅读
动手实验
- 对照组件与源码:用 React DevTools 观察组件树和状态,再阅读对应版本
ReactFiber.js/ReactInternalTypes.js中的字段。DevTools 的组件树与 owner 信息不是完整 Fiber 内部指针查看器。 - 体验时间分片:保持受控输入的 state 即时更新,把大量列表项的结果更新标记为 Transition;对比录制交互,再试虚拟列表。
useState和useTransition是配合使用的 API,不能简单二选一。 - 尝试 React Compiler:在一个 Vite 项目中启用
babel-plugin-react-compiler,对比编译前后的 Bundle 产物。
推荐阅读
| 资源 | 说明 |
|---|---|
| React 19.0.0 源码 | 用固定版本阅读 Fiber、lanes 与工作循环 |
| Render and Commit | 区分执行组件、更新 DOM 与浏览器绘制 |
| 状态的保留与重置 | 从位置、类型与 key 理解状态身份 |
| React Compiler Playground | 检查实际生成的优化代码 |
📌 本节小结
| 你学到了什么 | 核心要点 |
|---|---|
| Fiber 节点数据结构 | 显式保存树关系与工作进度,在单元边界协作让出 |
| 双缓冲机制 | current 与候选工作分离,提交仍有实际成本 |
| Reconciliation Diff | 类型与同级 key 辅助复用;复杂度是启发式描述 |
| Hook 链表 | 普通 Hooks 按调用顺序匹配,use API 有例外 |
| Scheduler 调度器 | lanes 与任务优先级分层;时间片不是固定保证 |
| 渲染两阶段 | 候选渲染、同步提交与 Effect 时序要分清 |
| React Compiler | 编译时自动追踪依赖、插入缓存 |