React 源码探秘(二):从 ReactElement 到 Fiber 树

上一篇我们聊了 JSX 是怎么被编译成 React.createElement 调用、最终得到一个树状的 ReactElement 结构。但那棵 ReactElement 树是「静态」的描述对象——它只是告诉 React「页面上应该长什么样」,并不能直接拿来干活。React 真正用来调度、协调、渲染更新的,是另一棵树:Fiber 树。这一篇我们就来看,ReactElement 树是怎么一步步变成 Fiber 树的。

为什么需要 Fiber

ReactElement 是纯数据、不可变、没有状态的。而 React 要做的渲染工作非常重:需要知道哪些组件要更新、哪些可以跳过、优先级如何安排,甚至要支持「时间切片」把大任务拆开执行。这些都需要一个可变的、带状态、可暂停可恢复的工作单元——这就是 Fiber。

要理解 Fiber 为什么存在,得先看看它的前身。React 15 及更早版本用的是 stack reconciler:递归遍历组件树,同步地、一口气把整棵树协调完。这个方案实现简单,但有两个致命问题:

  1. 不可中断:只要开始协调,就必须一口气跑完,期间浏览器无法响应点击、输入等交互,一遇到深层组件树就会「卡死」页面;
  2. 无优先级:所有更新一视同仁,用户输入和后台数据更新抢同一份资源,体验很糟。

所以 React 16 起重写了协调器,核心思想是:把一次渲染拆成许多小的、可中断的工作单元,每个单元就是一个 Fiber 节点。这样浏览器可以在两个单元之间插空渲染帧、处理用户事件,高优先级更新也能随时打断低优先级的工作。Fiber 本质上是一个 JavaScript 对象,每个组件、每个 DOM 节点在更新流程里都对应一个 Fiber 节点。

Fiber 节点的结构

在 React 17/18 源码的 ReactFiber.js 里,Fiber 是通过构造函数创建的。它的核心字段大致分几类:

  • 标识信息tag(节点类型,如函数组件、类组件、HostComponent 等)、typekeyelementType
  • 树结构指针return(父节点)、child(第一个子节点)、sibling(下一个兄弟节点);
  • 状态与工作pendingPropsmemoizedPropsmemoizedStateupdateQueueflags(effect 标记);
  • 调度相关laneschildLanes(React 18 用 lanes 取代了 expirationTime 表示优先级)。

源码里的大致形态(做了精简):

JAVASCRIPT
function FiberNode(tag, pendingProps, key, mode) { // 标识 this.tag = tag; // 节点类型 this.key = key; this.type = null; // 组件函数 / 类 / DOM 标签名 this.elementType = null; // 树结构:链表指针 this.return = null; // 父节点 this.child = null; // 第一个子节点 this.sibling = null; // 下一个兄弟节点 // 状态 this.pendingProps = pendingProps; this.memoizedProps = null; this.memoizedState = null; this.updateQueue = null; this.flags = NoFlags; // effect 标记 // 双缓冲 this.alternate = null; // 指向另一棵树的对应节点 // 调度优先级(React 18 lanes) this.lanes = NoLanes; this.childLanes = NoLanes; }

有意思的是,Fiber 用 child + sibling 组成的链表来表示树,而不是用 children 数组。为什么不用数组?因为链表天然支持「只遍历需要的部分」和「中断后从断点继续」:只要记住当前走到哪个节点,就能随时暂停、恢复。遍历的时候走「先深度优先、再返回、再横向找兄弟」的路径,这就是后面 render 阶段 beginWork / completeWork 的遍历基础。

alternate 双缓冲

每个 Fiber 节点上还有一个 alternate 字段,指向它的「另一个版本」。React 维护着两棵 Fiber 树

  • current 树:当前屏幕上正在显示、已经提交的那棵;
  • workInProgress 树:正在内存中构建、尚未提交的新树。

两棵树的节点通过 alternate 互相指向对方,这就是双缓冲(double buffering)。它的好处有两个:

  1. 复用:更新时 React 不必每次都从零新建整棵树,而是尽量复用旧的 Fiber 节点(连 props 带 state 一起带过来),只复用不到的才新建——性能的关键之一;
  2. 一致性:正在构建的 workInProgress 树还没提交前,用户看到的始终是完整、一致的 current 树;即使中途被高优先级更新打断,也不会渲染出「改了一半」的界面。

打个比方:current 树是舞台上正在演的戏,workInProgress 树是后台正在排练的新版本,排练好了才换上台,观众永远看到完整的演出。

rootFiber 与 createFiberRoot

每个应用容器(比如 #root)都对应一个特殊的 Fiber:rootFiber,也就是整棵树的根。它的 tagHostRoot,状态里挂着当前应用的相关信息。

初次挂载时,ReactDOM.createRoot(container) 内部会调用 createFiberRoot(位于 ReactFiberRoot.js),做两件事:

  1. 创建一个 FiberRootNode(root 容器节点,保存整个应用层的状态,如 currentcontainerInfopendingChildrencallbackNode 等);
  2. 创建 rootFiber,并让两者互相引用:fiberRoot.current = rootFiberrootFiber.stateNode = fiberRoot

注意区分这两个概念:FiberRootNode 是整个应用的「壳」,全局只有一个,管的是调度、容器 DOM、任务队列这些应用级状态;rootFiber 是 Fiber 树的根节点,是树的一部分,tag = HostRoot。有了这个根,root.render(<App />) 才有一个可以挂载整棵树的锚点。

初次挂载:从 ReactElement 到 beginWork

挂载开始后,React 会进入 render 阶段。入口是 beginWork(在 ReactFiberBeginWork.js 里):

JAVASCRIPT
function beginWork(current, workInProgress, renderLanes) { switch (workInProgress.tag) { case FunctionComponent: /* 处理函数组件 */ case ClassComponent: /* 处理类组件 */ case HostComponent: /* 处理原生 DOM 节点 */ // ... } }

beginWork 根据 Fiber 的 tag 分发到不同的处理分支:把 workInProgress 上的 pendingProps 拿下来,计算出新的子节点(也就是把 ReactElement 转成子 Fiber),再用 reconcileChildren 去 diff,生成下一层 Fiber。如此循环,直到把整棵 ReactElement 树「翻译」成 Fiber 树。

一棵树的诞生:示例走一遍

光看概念容易晕,我们用一个最简单的组件走一遍:

JSX
function App() { return <div className="box">Hello</div>; } ReactDOM.createRoot(document.getElementById('root')).render(<App />);

挂载时大致经历:

  1. createFiberRoot 建好 fiberRoot 和 rootFiber(HostRoot);
  2. root.render(<App />)<App /> 这个 ReactElement 挂到 rootFiber 的更新队列上;
  3. beginWork 从 rootFiber 出发:它是 HostRoot,走 updateHostRoot 分支,取出更新,reconcileChildren 生成子 Fiber——也就是 <App /> 对应的 FunctionComponent Fiber;
  4. 继续 beginWork 到 App 的 Fiber:调用 App() 拿到返回的 <div> ReactElement,再 reconcileChildren 生成 HostComponent Fiber(div);
  5. 继续走到 div 的 Fiber:处理 className 等 props,生成文本节点 "Hello" 的 Fiber;
  6. 子节点走完后开始 completeWork 从下往上收尾,构建真实的 DOM 结构并挂 effect 标记;
  7. 整棵树完成后进入 commit 阶段,一次性把 DOM 插入页面。

于是内存里就有了一棵这样的链表树(箭头表示指针):

rootFiber (HostRoot) └─ child → App (FunctionComponent) └─ child → div (HostComponent) ├─ child → "Hello" (HostText) └─ return → App → return → rootFiber

注意:div 的 sibling 是 null,因为它是 App 唯一的子节点;如果 App 返回多个子元素,它们就会通过 sibling 串成一条横向链表。

简单说,挂载的本质就是:沿着 ReactElement 树自顶向下走一遍,把每个 ReactElement 变成一个 Fiber 节点,再用链表把它们串起来。

挂载 vs 更新:alternate 什么时候登场

初次挂载时,current 树还不存在(rootFiber.alternate 为 null),React 会从零构建一棵 workInProgress 树。而之后每次更新就不同了:React 会以 current 树为底,通过 alternate 找到对应节点,能复用的直接复用(clone 一份改 props),不能复用的(比如列表里 key 对不上的节点)才新建。这也解释了为什么 Fiber 一定要「可变」——同一棵树上的节点在多次更新中被反复改造,而不是像 ReactElement 那样每次重新生成一份新的。

小结

  • ReactElement 是不可变的描述对象,Fiber 是可变的「工作单元」;
  • Fiber 通过 child/sibling/return 组成链表树,携带 props、state、flags、lanes 等状态;
  • alternate 指向另一棵树,构成 current / workInProgress 双缓冲;
  • 挂载入口是 createFiberRoot 建根,beginWork 逐层把 ReactElement 转成 Fiber;
  • 初次挂载从零建树,后续更新通过 alternate 复用旧节点,保证性能和一致性。

下一篇,我们深入 render 阶段,看 Reconciler 到底是怎么用 beginWork / completeWork 完成整棵树协调更新的。

【END】

本文链接:

版权声明:本博客所有文章除声明转载外,均采用 BY-NC-SA 3.0 许可协议。转载请注明来自 知己知事知理

阅读 3 | 发布于 2026-09-01
暂无评论