很多人第一次接触 Fiber,都是在面试题里听到一句话: “Fiber 让 React 变成了可中断渲染。”这句话不算错,但也只说对了一半。

如果只把 Fiber 理解成“性能优化”,其实很容易越学越糊涂。因为它解决的核心问题,不是单纯让一次渲染跑得更快,而是让 React 有能力决定: 哪些工作该先做,哪些工作可以等一等,哪些工作做到一半可以停下来,甚至推倒重来。

Fiber 随 React 16 在 2017 年正式落地。到 React 18 之后我们熟悉的并发渲染、startTransition、更细粒度的调度能力,本质上都建立在 Fiber 这套执行模型之上。

先看老问题: 为什么 React 需要重写 Reconciler

早期 React 的 reconciler 通常被称为 Stack Reconciler。它的思路很直接: 组件更新后,递归地往下走,算出新树,再把变更提交出去。

这个模型在简单场景下没什么问题,但它有一个天然短板: 一旦开始递归,中途很难停。

这在 UI 编程里是个真实问题。因为界面更新并不是“越快全部做完越好”,而是“先保证用户手上的操作别卡住”。

比如下面这两类更新,紧急程度其实完全不同:

  • 用户正在输入框里打字
  • 页面某个大列表因为筛选条件变化,需要重新渲染很多项

如果框架把两者都当成“立刻完整做完”的工作,主线程就可能被长任务占住,输入、动画、滚动都会变得不跟手。

Fiber 的目标,就是把 React 的渲染过程从“一次性跑到底”,改成“拆成很多可管理的小单元”。

Fiber 到底是什么

先给一个不那么绕的定义:

Fiber 是 React 内部用来表示“一个组件及其待处理工作”的数据结构,也是调度和协调更新的基本单位。

它不是你在业务代码里会直接接触到的东西。你写的是 JSX 和组件,React 在内部会把这些内容组织成一棵 Fiber 树。

一个 Fiber 节点通常会保存这些信息:

  • 这个节点对应什么类型的组件
  • 它的 key
  • 它的父节点、子节点、兄弟节点是谁
  • 上一次已经完成的 props/state 是什么
  • 这次待处理的更新是什么
  • 它对应的宿主实例是什么,比如某个 DOM 节点
  • 这次更新属于哪些优先级通道,也就是 lanes

很多老文章会提到 expirationTimependingWorkPriority。这些说法对应的是较早期的 Fiber 实现。现代 React 源码里,优先级模型更接近 lanes。可以把它粗略理解成“一组位标记”,React 用它来表达更新的紧急程度、可否合并,以及哪些更新需要一起完成。

如果把源码里的 FiberNode 拆开看

packages/react-reconciler/src/ReactFiber.js 里,React 定义了 Fiber 节点本身。你不用死记全部字段,但最好知道它大概按什么思路组织。

可以把一个 Fiber 节点粗略理解成下面这样:

type FiberNode = {
  tag: WorkTag;
  key: null | string;
  elementType: any;
  type: any;
  stateNode: any;

  return: Fiber | null;
  child: Fiber | null;
  sibling: Fiber | null;
  index: number;

  pendingProps: any;
  memoizedProps: any;
  memoizedState: any;
  updateQueue: mixed;

  flags: Flags;
  subtreeFlags: Flags;
  deletions: Array<Fiber> | null;

  lanes: Lanes;
  childLanes: Lanes;

  alternate: Fiber | null;
}

这里面最值得先记住的是几组字段:

  • tag: 这个节点是什么类型,比如函数组件、类组件、宿主节点、根节点
  • elementTypetype: 分别描述“用户写的元素类型”和“最终用来工作的类型信息”
  • stateNode: 指向真正挂着的东西。对 DOM 节点来说通常是实际 DOM;对类组件来说是组件实例;对根节点来说会关联到 FiberRoot
  • returnchildsibling: 这组三个指针把整棵 Fiber 树串起来
  • pendingPropsmemoizedPropsmemoizedState: 分别表示这轮要处理的输入,以及上一次已经完成的结果
  • flagssubtreeFlags: 标记当前节点和子树在 commit 阶段需要做什么
  • laneschildLanes: 当前节点以及它的子树上,分别还挂着哪些优先级的工作
  • alternate: 指向另一棵树上的对应节点,也就是 currentworkInProgress 之间的连接

如果你第一次读源码,最容易混淆的是 pendingPropsmemoizedProps。一个表示“这轮准备处理什么”,一个表示“上轮已经确认了什么”。React 是否需要继续往下算,经常就跟这两者的差异,以及当前 lanes 有没有命中有关。

Fiber 树本身不是传统的“每个节点挂一个 children 数组”的结构,而更像一棵基于链表关系连接起来的树:

graph TD
  P["Parent Fiber"]
  A["Child Fiber A"]
  B["Child Fiber B"]

  P -->|child| A
  A -->|sibling| B
  A -.->|return| P
  B -.->|return| P

这么设计有个实际好处: React 不必完全依赖 JavaScript 调用栈去遍历组件树,而是能把“下一步处理谁”这件事掌握在自己手里。

从 JSX 到 Fiber 树,中间发生了什么

站在业务代码视角,我们写的是组件嵌套:

function App() {
  return (
    <main>
      <Header />
      <List />
    </main>
  );
}

React 内部真正处理时,会把它们组织成更接近下面这样的结构:

graph TD
  A["App(FunctionComponent)"]
  B["main(HostComponent)"]
  C["Header(FunctionComponent)"]
  D["List(FunctionComponent)"]

  A -->|child| B
  B -->|child| C
  C -->|sibling| D

这里要注意两点:

  • 组件节点会有对应的 Fiber,原生 DOM 标签同样也会有对应的 Fiber
  • “组件树”和“Fiber 树”不是完全不同的两套东西,Fiber 更像是 React 为这些节点加上的运行时外壳

所以很多时候我们说“遍历组件树”,在源码语境里更准确地讲,其实是在遍历 Fiber 树。

current 和 workInProgress: React 的“双缓冲”

理解 Fiber,绕不开另一个关键点: React 内部通常会同时维护两棵互相关联的树。

  • current 表示当前已经显示在页面上的 Fiber 树
  • workInProgress 表示这次更新过程中正在构建的新树

当一次更新开始时,React 会基于当前树去创建或复用对应的 workInProgress 节点。两个版本之间通过 alternate 指针互相引用。

graph LR
  C["current Fiber tree"] -. alternate .- W["workInProgress Fiber tree"]

等整棵新树准备完成之后,React 再把 workInProgress 切成新的 current。这就是很多文章里说的“双缓冲”。

这件事很重要,因为它意味着 React 可以在“还没准备好之前”只在内存里推演,不急着把半成品直接改到 DOM 上。

顺着源码再看一步,createWorkInProgress 这类函数做的事情,也不是每次都新建一整棵树。React 会尽量复用 alternate 对应的节点,只把这轮需要更新的字段重置或覆盖掉。这样做的目的很现实: 降低重复分配对象的成本,也减少垃圾回收压力。

一次更新在 Fiber 里是怎么走的

我们平时写的 setState、父组件重新渲染、上下文变化,最终都会变成一次更新任务。

简化来看,一次更新大致会经过下面这条路径:

graph TD
  U["setState / props 变化 / startTransition"] --> L["分配 lanes"]
  L --> W["创建或复用 workInProgress"]
  W --> R["Render 阶段: 遍历 Fiber 树"]
  R --> Y{"需要让出主线程吗?"}
  Y -- "需要" --> R
  Y -- "不需要,且本轮工作完成" --> C["Commit 阶段"]
  C --> B["Before Mutation"]
  B --> M["Mutation: 应用 DOM 变更"]
  M --> LA["Layout: useLayoutEffect 等"]
  LA --> P["Passive Effects: 稍后刷新 useEffect"]

这里最容易记住的,其实就两句话:

1. Render 阶段负责“算”

Render 阶段会从根节点开始遍历 Fiber 树,做的事情包括:

  • 根据新输入重新执行组件
  • 对比新旧子树,决定哪些节点可以复用
  • 记录本次需要提交的变更

这个阶段的关键特征是: 可以被打断,可以恢复,也可以直接重来

所以“并发渲染”首先是一个调度能力,不是说 React 突然在浏览器里开了多线程。

2. Commit 阶段负责“改”

当 Render 阶段已经得到一棵可提交的新树后,React 才会进入 Commit 阶段,把真实变更应用到宿主环境里,比如 DOM。

Commit 一旦开始,默认就不会再被打断。原因也很直白: DOM 改到一半停下来,界面就会处在不一致状态。

这也是为什么很多资料里会反复强调一句话:

Fiber 让 Render 变得可中断,但 Commit 仍然是同步完成的。

beginWork 和 completeWork,可以怎么理解

如果你去看 React reconciler 的源码,经常会看到两个名字: beginWorkcompleteWork

不必一上来就抠实现细节,先用职责去记就够了:

  • beginWork 更像“往下看”,决定当前节点和它的子节点这轮该怎么处理
  • completeWork 更像“往上收”,在子节点处理完后,把当前节点需要提交的信息归并回来

这套“下探再回收”的过程,最终会把整棵 workInProgress 树准备好。

从源码看一次 work loop

如果把 Fiber 运行过程再翻译成源码里的几个关键函数,主线会更清楚:

  • performUnitOfWork
  • beginWork
  • completeUnitOfWork
  • completeWork

它的大体节奏可以画成这样:

graph TD
  A["performUnitOfWork(current)"] --> B["beginWork"]
  B -->|返回 child| C["进入子节点"]
  B -->|返回 null| D["completeUnitOfWork"]
  D --> E{"有 sibling 吗?"}
  E -- "有" --> F["切到 sibling"]
  E -- "没有" --> G["回到 return 节点"]
  G --> D

如果只说职责:

  • performUnitOfWork 负责驱动“处理一个 Fiber”
  • beginWork 负责决定这个 Fiber 需不需要重新计算,以及它的子节点怎么生成或复用
  • completeWork 负责在子节点都处理完后,为当前节点收尾,比如准备宿主节点相关信息
  • completeUnitOfWork 则负责把“回溯”这件事串起来,决定接下来去兄弟节点,还是继续往父节点退

你也可以把它想成一个手写遍历器,而不是依赖 JS 调用栈的递归。

很多文章会把 Fiber 描述成“把递归改成链表”,这句话不够精确,但方向是对的。更本质的变化是: React 不再把整棵树的执行控制权交给函数调用栈,而是自己维护一个 workInProgress 指针,一步一步推进。

用伪代码表示,它的心智模型大概像这样:

while (workInProgress !== null) {
  performUnitOfWork(workInProgress);
}

当然,真实源码里不会这么简单,而且 performUnitOfWork 也不是一个“简单返回下一个节点”的玩具函数。这里的伪代码只是为了说明: React 确实是在显式推进当前工作指针,而不是把整棵树一次性递归到底。

React 为什么能“暂停”,又为什么不是多线程

Fiber 最容易被误解的地方,就是大家会把“可中断渲染”听成“React 在后台另起线程偷偷算 UI”。

浏览器里的 React 依然主要跑在主线程上。它之所以能暂停,本质上是因为:

  • 渲染工作被拆成了一个个 Fiber 单元
  • React 自己掌握遍历节奏,而不是被递归一口气压到底
  • 在并发模式相关的工作循环里,React 会周期性判断当前是否应该让出执行权

也就是说,Fiber 提供的是“可以切片”的基础,Scheduler 提供的是“什么时候该停一下”的判断。两者配合,才有了我们后来看到的并发更新体验。

这也是为什么说 Fiber 是并发特性的地基,而不是并发特性的全部。

Fiber 和并发渲染,到底是什么关系

一个很常见的误解是: “有了 Fiber,React 就是异步渲染了。”

这话不严谨。

更准确的说法是:

  • Fiber 让 React 拥有了可调度的渲染模型
  • 并发渲染是这套模型在现代 React 中的一种能力体现
  • 它的重点是“可中断、可插队、可放弃过时结果”,不是“后台偷偷算完”

startTransition 就是一个很典型的例子:

startTransition(() => {
  setTab(nextTab);
});

这不是在说“这个更新一定更快”,而是在告诉 React: 这次更新没那么急,如果用户此时还有更重要的交互,比如继续输入、继续点击,那么优先保证那些更紧急的更新。

React 官方文档也明确提到,Transition 更新可以被其他状态更新打断,之后再重新开始。

为什么 key 在 Fiber 里这么重要

很多人把 key 只当成“消除列表 warning 的语法要求”,其实它更重要的作用,是帮助 React 判断一个 Fiber 能不能复用。

在同一层级下,React 会结合元素类型和 key 来判断身份。身份稳定,原来的 Fiber 以及它关联的状态就有机会被保留下来;身份变了,旧节点就会被卸载,新节点会重新挂载。

这也是为什么下面两种写法,效果会完全不同:

  • 只是改 props,组件状态通常会保留
  • 改了 key,组件通常会被当成一个全新的实例

所以从 Fiber 的角度看,key 本质上是在参与“节点身份识别”,而不只是给 diff 算法打辅助。

如果把视角再往源码里压一层,在 ReactChildFiber.js 这类文件里,React 处理子节点时并不是无脑双层遍历。

数组 diff 的常见路径通常是这样:

  1. 先按顺序尝试复用“当前位置上的旧节点”
  2. 一旦顺序对不上,再把剩余旧节点组织成可查找结构
  3. 继续根据 key 和类型去找还能不能复用
  4. 复用不了的就创建新 Fiber,旧的则标记删除

这里 key 的价值一下就具体了: 它不是给 React 一个“优化提示”,而是在告诉 React “这个节点在新旧两次渲染之间是不是同一个人”。

如果你见过源码里的 lastPlacedIndex,它解决的也是类似问题: 某个节点虽然还能复用,但它在新列表里的相对位置已经变了,那么 commit 阶段就需要把对应的移动或插入操作做出来。

Fiber 为什么有时能整片跳过不算

实际项目里我们经常会观察到一种现象: 父组件更新了,但某些子树并没有真的重新走到底。

这背后不是单一机制,而是几件事叠在一起:

  • 当前 Fiber 的输入没有变化,或者没有命中这轮需要处理的 lanes
  • 子树上也没有更高优先级的待处理工作
  • 上下文等依赖没有发生会影响这棵子树的变化

在这些条件满足时,React 可以在 beginWork 阶段直接 bailout,也就是跳过这棵已经完成过、并且当前看来不需要重算的子树。

这也是 childLanes 存在的意义之一。当前节点自己也许没有更新,但它的孩子可能有;如果连 childLanes 都没有命中,那 React 就更有把握直接跳过。

所以从源码视角看,“跳过渲染”不是一个魔法优化项,而是 Fiber 节点把上下文、状态、优先级信息都挂在树上之后,自然获得的判断能力。

Commit 阶段到底提交了什么

前面我们说过,Render 阶段主要是在“算”,Commit 阶段才是真正“改”。如果再拆细一点,Commit 通常会被分成几个时机:

  • Before Mutation
  • Mutation
  • Layout
  • Passive Effects 刷新

它们可以粗略理解成:

  • Before Mutation: DOM 真正变更前,先做一些提交前准备
  • Mutation: 执行插入、删除、更新 DOM 等宿主操作
  • Layout: 触发 useLayoutEffect 相关逻辑,此时 DOM 已经是最新的
  • Passive Effects: 之后再异步刷新 useEffect

源码层面还有一个很容易让人看旧资料时绕进去的点:

很多早期 Fiber 文章会强调 “React 会把有副作用的节点串成一条 effect list”。这个说法在历史版本里非常重要,但如果你看现代源码,会更常看到的是 flagssubtreeFlags 以及围绕它们的子树遍历逻辑。

换句话说,今天去理解 Commit 阶段,更稳妥的方式不是死记某个历史字段名,而是把握住这个核心事实:

  • Render 阶段负责给 Fiber 节点打标记
  • Commit 阶段根据这些标记,有选择地遍历并执行真实副作用

Fiber 和 Hooks,是怎么接上的

如果你是从业务代码出发学习 Fiber,另一个很自然的问题是: Hooks 跟这套东西怎么连起来的?

可以先抓住一个足够用的版本:

  • 函数组件对应的 Fiber 会保存自己的 memoizedState
  • 这个 memoizedState 会串起当前组件上的 Hooks 状态
  • 当你调用 setState 时,本质上是在对应 Hook 的更新队列里塞进一个 update
  • React 再根据这个 update 的 lane,把工作调度回对应 Fiber 所在的那棵树上

所以 Hooks 并不是 Fiber 之外的一层平行系统。相反,它就是建立在 Fiber 节点、更新队列和调度流程上的。

这也是为什么 Hooks 规则会那么严格: React 需要依赖稳定的调用顺序,把“这次执行读到的是第几个 Hook”跟 Fiber 上保存的链表状态一一对应起来。

结合一个真实例子,走一遍源码链路

前面这些概念如果只停留在定义层面,读起来还是容易飘。更好的办法,是抓一个非常普通的业务场景,看看它在 Fiber 里到底怎么跑。

我们先看一个最常见的函数组件:

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(c => c + 1)}>
      {count}
    </button>
  );
}

用户点了一下按钮,页面上的 0 变成 1。从业务层看,这只是一次普通更新;但在 React 内部,这次更新通常会经过这样一条链路:

graph TD
  A["点击按钮"] --> B["dispatchSetState"]
  B --> C["requestUpdateLane"]
  C --> D["enqueueConcurrentHookUpdate"]
  D --> E["scheduleUpdateOnFiber"]
  E --> F["renderRootSync / renderRootConcurrent"]
  F --> G["beginWork"]
  G --> H["updateFunctionComponent"]
  H --> I["renderWithHooks"]
  I --> J["reconcileChildren"]
  J --> K["completeWork"]
  K --> L["commitRoot"]
  L --> M["DOM 文本从 0 变成 1"]

下面按这条链路往下拆。

1. 点击之后,先不是“立刻重新渲染”,而是先创建 update

在当前 React 主线源码里,useState 返回的 dispatch 最终会落到 ReactFiberHooks.js 里的 dispatchSetState

这里最核心的几步是:

  • 先通过 requestUpdateLane(fiber) 为这次更新分配 lane
  • 再把这次更新包装成一个 update 对象
  • 然后通过 enqueueConcurrentHookUpdate 把它挂到对应 Hook 的更新队列里
  • 如果成功拿到了 root,再调用 scheduleUpdateOnFiber(root, fiber, lane)

这一步很关键,因为 React 还没有开始真正执行组件。它只是先回答两个问题:

  • 这次更新属于什么优先级
  • 这次更新应该回到哪棵 Fiber 树上去处理

如果你去看 dispatchSetState 那段源码,会看到它干的事情和我们平时对“setState 就是重新渲染”的直觉并不完全一样。更准确地说,它是“先登记一笔更新,再把对应 root 标记为需要工作”。

2. React 会沿着 Fiber 树往上,把工作一路标到 root

进入 scheduleUpdateOnFiber 之后,React 会把这次更新带着的 lane 往上冒泡。

可以把它想成这样:

  • 当前 Counter Fiber 自己有新工作了
  • 它的父节点需要知道“我的子树里有活要干”
  • 再往上的父节点也要知道
  • 最后根节点会知道: 这棵树上有一个对应 lane 的更新等待处理

这也是为什么 Fiber 节点上除了 lanes,还会有 childLanes。前者更像“我自己身上的工作”,后者更像“我下面这整片子树里还有没有工作”。

等根节点被标脏之后,React 就会确保这个 root 被调度起来。具体是走同步还是并发工作循环,要看当前更新的优先级、执行环境以及 root 的模式。

3. 真正进入 render 后,函数组件会在 renderWithHooks 里重新执行

当 work loop 真正开始跑时,React 会从 root 开始处理 Fiber。走到 Counter 这个函数组件时,主线通常会来到:

  • beginWork
  • updateFunctionComponent
  • renderWithHooks

这一段可以理解成“重新执行函数组件,但执行时带着一整套 React 的运行时上下文”。

renderWithHooks 里会发生几件关键的事:

  • 取到当前 Fiber 上已经保存的 Hook 链表
  • 按调用顺序依次读取 useStateuseEffect 等 Hook
  • 把更新队列里的 update 应用到旧状态上,算出新的 count
  • 最终重新执行组件函数,拿到新的 JSX 结果

这也是为什么 Hooks 的调用顺序不能乱。因为 React 并不是通过变量名识别“这是哪个 state”,而是通过“这个组件本次执行到第几个 Hook”去对齐旧 Hook 链表。

对上面这个例子来说,setCount(c => c + 1) 对应的 update 被消费后,组件重新执行,返回的按钮文本就从 0 变成了 1

4. JSX 算出来之后,React 还要决定哪些 Fiber 能复用

组件函数重新执行完,不代表工作就结束了。React 还要继续处理新旧子节点的关系。

在函数组件场景下,你可以把这一步理解成:

  • renderWithHooks 返回新的 children
  • reconcileChildren 开始对比新旧子树
  • 能复用的 Fiber 尽量复用
  • 需要新增、删除、移动的地方打上对应标记

Counter 这个例子来说,树形结构本身几乎没变:

  • Counter 这个函数组件 Fiber 还在
  • button 对应的 HostComponent Fiber 还在
  • 按钮里的文本节点对应的 HostText Fiber 也还在

真正变化的,是 HostText 对应的内容从 0 变成了 1。所以这次更新更多是在“复用现有 Fiber,然后给需要提交的节点打 Update 标记”,而不是创建一棵完全不同的新树。

5. completeWork 负责把提交所需的信息收回来

等子节点都处理完后,React 会在回溯阶段进入 completeWork

这一步可以理解成“收尾并归并副作用信息”:

  • 对宿主节点来说,判断需不需要创建实例、更新属性、拼接子节点
  • 对文本节点来说,判断文本内容有没有变化
  • 把当前节点和子树上的 flags 归并起来,方便 commit 阶段快速处理

在我们的例子里,文本从 0 变成 1,这类变化最终会被记录成 commit 阶段可执行的宿主更新。

6. 最后进入 commitRoot,页面才真的变

只有当整棵 workInProgress 树准备完成后,React 才会进入 commitRoot

这时前面 render 阶段累积下来的结果,才会真正落到 DOM 上:

  • mutation 阶段更新文本节点
  • layout 阶段执行 useLayoutEffect
  • passive effects 刷新阶段再处理 useEffect

所以从源码视角看,“点击按钮后文字变了”并不是一个单独动作,而是一串步骤的最终结果:

  • 先登记更新
  • 再调度 root
  • 再跑 render,算出新树
  • 最后统一 commit

再看一个列表重排例子: key 为什么能决定状态命运

再看一个更能体现 Fiber diff 思路的例子:

function List({ items }) {
  return (
    <ul>
      {items.map(item => (
        <Item key={item.id} label={item.label} />
      ))}
    </ul>
  );
}

假设旧列表是:

[A, B, C]

新列表变成:

[B, A, C]

如果 key 分别就是 ABC,那么在 ReactChildFiber.js 的那条 diff 路径里,React 关心的不是“长得像不像”,而是“还是不是同一个节点”。

可以把处理过程理解成这张表:

新位置 节点 旧位置 结果
0 B 1 可以复用,先保留
1 A 0 可以复用,但需要移动
2 C 2 可以复用,保持不变

这里一个很关键的变量就是 lastPlacedIndex

它可以粗暴理解成一句话: “到目前为止,已经确认不用动的旧节点,最靠后的那个位置在哪。”

顺着上面的例子看:

  • 先处理 B 时,它在旧列表里的位置是 1,大于等于当前 lastPlacedIndex,所以它可以先留着
  • 然后处理 A 时,它在旧列表里的位置是 0,小于当前的 lastPlacedIndex
  • 这说明 A 虽然还是同一个节点,但它已经跑到一个“更靠前的旧位置”去了,于是 React 会给它打上 Placement 相关标记

这就是“复用”和“移动”可以同时成立的原因:

  • Fiber 还是原来那个 Fiber
  • 组件状态也可以保住
  • 但 DOM 位置需要在 commit 阶段调整

这也是为什么稳定 key 这么重要。它直接决定了 React 是把一个节点当成:

  • 同一个人,只是换了位置
  • 还是一个旧节点消失了、一个新节点刚出现

如果这里不用稳定 key,而是直接用索引,当列表头部插入一个新项时,React 很可能会把后面一串节点都误认成“还是原来的位置上的人”。结果通常就是:

  • 组件状态错位
  • 输入框内容串行
  • 动画和焦点表现异常

所以从源码角度说,key 从来不是“为了消除控制台 warning 才勉强补上的属性”,而是 Fiber 复用策略里非常核心的一部分。

如果你准备自己读源码,建议从哪几处进

Fiber 源码不算适合从头平铺着看。更高效的方式,是先抓主干文件,再顺着调用关系往下走。

我更推荐这样的入口顺序:

  1. ReactFiber.js
  2. ReactFiberWorkLoop.js
  3. ReactFiberBeginWork.js
  4. ReactChildFiber.js
  5. ReactFiberCompleteWork.js
  6. ReactFiberCommitWork.js

可以这样理解它们的职责:

  • ReactFiber.js: Fiber 节点长什么样,currentworkInProgress 怎么关联
  • ReactFiberWorkLoop.js: 整个渲染和提交流程怎么被驱动起来
  • ReactFiberBeginWork.js: 某个 Fiber 开始处理时,如何决定是否继续往下算
  • ReactChildFiber.js: 子节点如何复用、插入、删除,key 到底怎么参与 diff
  • ReactFiberCompleteWork.js: 往回收时需要补哪些宿主层信息
  • ReactFiberCommitWork.js: 真正提交副作用时做了哪些事情

如果你第一次读 React reconciler,我建议别一开始就追所有分支。先盯住“函数组件更新一次”或者“列表节点重排一次”这类具体场景,顺着调用链走,理解会快很多。

几个常见误区

Fiber 不是 Virtual DOM 的同义词

Virtual DOM 更像“用内存对象描述界面”的抽象;Fiber 是 React 用来执行 reconciliation 和 scheduling 的内部结构。两者相关,但不是一回事。

Fiber 不等于“React 会自动更快”

Fiber 更像是让 React “更会安排工作”。如果一个组件树本身就有很多无意义重渲染,Fiber 也不会凭空把这些浪费变没。

并发渲染不等于 Commit 也能被切一半

可中断的是 Render,不是已经开始的 DOM 提交。

老文章里的字段名,不一定还对应今天的源码

如果你看到 expirationTimependingWorkPriorityeffectTag 这类说法,不一定全错,但很可能对应的是较旧版本实现。理解 Fiber 最好抓住稳定概念,比如:

  • 可中断的 render
  • current / workInProgress
  • alternate
  • lanes
  • render / commit 分离

写在最后

我觉得理解 Fiber,最重要的不是背字段名,而是把心智模型转过来:

React 不再只是“收到更新,然后一口气重新跑完组件树”。从 Fiber 开始,它更像一个会调度的运行时系统。它知道什么事情应该立刻响应,什么事情可以延后,什么事情做了一半可以先停,什么旧结果已经过期可以直接丢掉。

这也是为什么 Fiber 之后,React 才真正有了并发特性、生效更自然的过渡更新,以及更细粒度的界面响应能力。

如果只用一句话收尾,我会这么概括:

Fiber 的价值,不是把 React 变成“更快的递归”,而是把 React 变成“会安排工作的渲染系统”。


前端小白