上一篇我们聊了 JSX 是怎么被编译成 React.createElement 调用、最终得到一个树状的 ReactElement 结构。但那棵 ReactElement 树是「静态」的描述对象——它只是告诉 React「页面上应该长什么样」,并不能直接拿来干活。React 真正用来调度、协调、渲染更新的,是另一棵树:Fiber 树。这一篇我们就来看,ReactElement 树是怎么一步步变成 Fiber 树的。
为什么需要 Fiber
ReactElement 是纯数据、不可变、没有状态的。而 React 要做的渲染工作非常重:需要知道哪些组件要更新、哪些可以跳过、优先级如何安排,甚至要支持「时间切片」把大任务拆开执行。这些都需要一个可变的、带状态、可暂停可恢复的工作单元——这就是 Fiber。
要理解 Fiber 为什么存在,得先看看它的前身。React 15 及更早版本用的是 stack reconciler:递归遍历组件树,同步地、一口气把整棵树协调完。这个方案实现简单,但有两个致命问题:
- 不可中断:只要开始协调,就必须一口气跑完,期间浏览器无法响应点击、输入等交互,一遇到深层组件树就会「卡死」页面;
- 无优先级:所有更新一视同仁,用户输入和后台数据更新抢同一份资源,体验很糟。
所以 React 16 起重写了协调器,核心思想是:把一次渲染拆成许多小的、可中断的工作单元,每个单元就是一个 Fiber 节点。这样浏览器可以在两个单元之间插空渲染帧、处理用户事件,高优先级更新也能随时打断低优先级的工作。Fiber 本质上是一个 JavaScript 对象,每个组件、每个 DOM 节点在更新流程里都对应一个 Fiber 节点。
Fiber 节点的结构
在 React 17/18 源码的 ReactFiber.js 里,Fiber 是通过构造函数创建的。它的核心字段大致分几类:
- 标识信息:
tag(节点类型,如函数组件、类组件、HostComponent 等)、type、key、elementType; - 树结构指针:
return(父节点)、child(第一个子节点)、sibling(下一个兄弟节点); - 状态与工作:
pendingProps、memoizedProps、memoizedState、updateQueue、flags(effect 标记); - 调度相关:
lanes、childLanes(React 18 用 lanes 取代了 expirationTime 表示优先级)。
源码里的大致形态(做了精简):
JAVASCRIPTfunction 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)。它的好处有两个:
- 复用:更新时 React 不必每次都从零新建整棵树,而是尽量复用旧的 Fiber 节点(连 props 带 state 一起带过来),只复用不到的才新建——性能的关键之一;
- 一致性:正在构建的 workInProgress 树还没提交前,用户看到的始终是完整、一致的 current 树;即使中途被高优先级更新打断,也不会渲染出「改了一半」的界面。
打个比方:current 树是舞台上正在演的戏,workInProgress 树是后台正在排练的新版本,排练好了才换上台,观众永远看到完整的演出。
rootFiber 与 createFiberRoot
每个应用容器(比如 #root)都对应一个特殊的 Fiber:rootFiber,也就是整棵树的根。它的 tag 是 HostRoot,状态里挂着当前应用的相关信息。
初次挂载时,ReactDOM.createRoot(container) 内部会调用 createFiberRoot(位于 ReactFiberRoot.js),做两件事:
- 创建一个
FiberRootNode(root 容器节点,保存整个应用层的状态,如current、containerInfo、pendingChildren、callbackNode等); - 创建 rootFiber,并让两者互相引用:
fiberRoot.current = rootFiber,rootFiber.stateNode = fiberRoot。
注意区分这两个概念:FiberRootNode 是整个应用的「壳」,全局只有一个,管的是调度、容器 DOM、任务队列这些应用级状态;rootFiber 是 Fiber 树的根节点,是树的一部分,tag = HostRoot。有了这个根,root.render(<App />) 才有一个可以挂载整棵树的锚点。
初次挂载:从 ReactElement 到 beginWork
挂载开始后,React 会进入 render 阶段。入口是 beginWork(在 ReactFiberBeginWork.js 里):
JAVASCRIPTfunction 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 树。
一棵树的诞生:示例走一遍
光看概念容易晕,我们用一个最简单的组件走一遍:
JSXfunction App() {
return <div className="box">Hello</div>;
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
挂载时大致经历:
createFiberRoot建好 fiberRoot 和 rootFiber(HostRoot);root.render(<App />)把<App />这个 ReactElement 挂到 rootFiber 的更新队列上;beginWork从 rootFiber 出发:它是 HostRoot,走updateHostRoot分支,取出更新,reconcileChildren生成子 Fiber——也就是<App />对应的 FunctionComponent Fiber;- 继续
beginWork到 App 的 Fiber:调用App()拿到返回的<div>ReactElement,再reconcileChildren生成 HostComponent Fiber(div); - 继续走到 div 的 Fiber:处理
className等 props,生成文本节点 "Hello" 的 Fiber; - 子节点走完后开始
completeWork从下往上收尾,构建真实的 DOM 结构并挂 effect 标记; - 整棵树完成后进入 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 完成整棵树协调更新的。