Skip to content

第3章 Fiber 架构:React 的操作系统

本章要点

  • Fiber 诞生的历史背景:Stack Reconciler 的致命缺陷与浏览器渲染机制的冲突
  • Fiber 数据结构的完整剖析:30+ 字段的设计意图与相互关系
  • 双缓冲(Double Buffering)机制:current 树与 workInProgress 树的交替策略
  • Fiber 工作循环:从 performUnitOfWorkcompleteWork 的递归拆解
  • Fiber 与操作系统的类比:进程调度、中断恢复、时间片轮转
  • Lane 模型:React 的优先级系统如何用位运算实现 O(1) 调度

2016 年的某一天,React 团队成员 Andrew Clark 在 GitHub 上提交了一份名为 "React Fiber Architecture" 的文档。文档开头只有一句话:

"React Fiber is an ongoing reimplementation of React's core algorithm."

这个看似温和的描述背后,是 React 历史上最大规模的一次内部重写。从 React 0.x 到 React 15,React 的协调算法(Reconciler)使用的是一种被称为 "Stack Reconciler" 的递归实现——它简单、直觉、高效,但有一个致命的缺陷:一旦开始渲染,就无法停止。

想象你在一台单核 CPU 的计算机上运行一个没有时间片轮转的操作系统。你启动了一个耗时 500 毫秒的计算任务。在这 500 毫秒里,键盘输入没有响应,鼠标移动被冻结,屏幕上的动画停止——因为 CPU 被那个任务完全占据,没有机会处理其他任何事情。

这就是 React 15 面临的问题。而 Fiber,就是 React 团队为它设计的"操作系统"。

3.1 Stack Reconciler:为什么需要推倒重来

递归的致命缺陷

React 15 的 Stack Reconciler 使用原生的 JavaScript 调用栈进行组件树的遍历。当你调用 setState 触发更新时,React 会从触发更新的组件开始,递归地向下遍历整个子树,对比新旧虚拟 DOM,生成变更列表,然后一次性提交到真实 DOM。

typescript
// Stack Reconciler 的简化模型(React 15)
function reconcile(parentDom: HTMLElement, oldVNode: VNode, newVNode: VNode) {
  if (oldVNode.type !== newVNode.type) {
    // 类型不同,直接替换
    parentDom.replaceChild(createDom(newVNode), parentDom.childNodes[0]);
    return;
  }

  // 类型相同,更新属性
  updateProps(parentDom, oldVNode.props, newVNode.props);

  // 递归处理 children —— 这就是"Stack"的由来
  const oldChildren = oldVNode.children;
  const newChildren = newVNode.children;
  const maxLen = Math.max(oldChildren.length, newChildren.length);

  for (let i = 0; i < maxLen; i++) {
    reconcile(parentDom.childNodes[i], oldChildren[i], newChildren[i]);
    // ⚠️ 每一层递归都压入调用栈
    // ⚠️ 一旦开始,无法中断——JavaScript 没有"暂停调用栈"的机制
  }
}

这段代码的问题在于:JavaScript 的调用栈是不可中断的。一旦你调用了 reconcile,它就会一路递归到叶子节点,然后逐层返回。在整个过程中,主线程被完全占据——没有机会处理用户输入、执行动画帧、或者响应任何其他事件。

16 毫秒的硬约束

浏览器的渲染管线以 60fps 为目标,这意味着每帧只有约 16.67 毫秒的时间窗口。在这个窗口内,浏览器需要完成:

JavaScript 执行 → 样式计算 → 布局 → 绘制 → 合成

如果 JavaScript 执行超过了这个时间窗口,浏览器就没有时间执行后续的渲染步骤——用户看到的就是"卡顿"。

图 3-1:Stack Reconciler vs Fiber Reconciler 的时间线对比

让我用一个真实场景说明这个问题有多严重。

大型列表的噩梦

假设你有一个包含 10000 条记录的数据表格。用户在搜索框中输入了一个字符,触发了整个表格的重新过滤和渲染。

tsx
function DataTable({ data, filter }: { data: Item[]; filter: string }) {
  const filtered = data.filter(item =>
    item.name.toLowerCase().includes(filter.toLowerCase())
  );

  return (
    <table>
      <tbody>
        {filtered.map(item => (
          <TableRow key={item.id} item={item} />
        ))}
      </tbody>
    </table>
  );
}

function SearchableTable() {
  const [filter, setFilter] = useState('');

  return (
    <div>
      <input
        value={filter}
        onChange={e => setFilter(e.target.value)}
        placeholder="搜索..."
      />
      <DataTable data={largeDataset} filter={filter} />
    </div>
  );
}

在 React 15 中,用户每输入一个字符,React 就会同步地渲染 10000 行表格数据。如果每行的渲染需要 0.05ms,那么整个表格的渲染就需要 500ms。在这 500ms 内:

  • 用户继续输入的字符不会出现在搜索框中
  • 光标停止闪烁
  • 如果有动画,动画会冻结
  • 整个页面处于"假死"状态

这不是一个理论上的问题——这是 2016 年无数 React 应用面临的真实困境。React 团队需要一个根本性的解决方案。

3.2 Fiber 的核心思想:把递归变成迭代

Fiber 的核心思想可以用一句话概括:把不可中断的递归,变成可中断的迭代。

在计算机科学中,任何递归都可以用显式的数据结构(通常是栈或链表)转化为迭代。Fiber 正是这个思想的应用——它用一个链表结构(Fiber Tree)替代了 JavaScript 的调用栈,使得 React 可以在任意时刻"暂停"工作,将控制权交还给浏览器,然后在下一个时间窗口"恢复"工作。

这就像操作系统的上下文切换(Context Switch)——操作系统通过保存和恢复 CPU 寄存器的状态,让多个进程"轮流"使用 CPU,营造出"同时运行"的幻觉。Fiber 通过保存和恢复 Fiber 节点的状态,让 React 的渲染工作和浏览器的渲染工作"轮流"使用主线程。

typescript
// 概念模型:从递归到迭代的转变

// ❌ 递归(Stack Reconciler)—— 调用栈隐式管理状态
function processTree(node: VNode) {
  processNode(node);
  for (const child of node.children) {
    processTree(child); // 递归调用,状态在调用栈中
  }
}

// ✅ 迭代(Fiber Reconciler)—— 显式数据结构管理状态
function workLoop(deadline: IdleDeadline) {
  while (workInProgress !== null && deadline.timeRemaining() > 0) {
    workInProgress = performUnitOfWork(workInProgress);
    // 每处理一个节点,检查是否还有时间
    // 如果时间用完,workInProgress 保存了当前位置
    // 下次恢复时,从这个位置继续
  }

  if (workInProgress !== null) {
    // 还有工作没做完,请求下一个时间片
    requestIdleCallback(workLoop);
  }
}

🔥 深度洞察:Fiber 不只是一个数据结构

很多教程把 Fiber 简单地描述为"一种链表数据结构"。这是不完整的。Fiber 是一个协调架构——它包含了数据结构(Fiber Node)、遍历算法(工作循环)、调度策略(优先级系统)和并发控制(中断与恢复)四个层面。把 Fiber 类比为操作系统更加贴切:Fiber Node 是进程控制块(PCB),工作循环是 CPU 调度器,Lane 是优先级队列,双缓冲是进程切换的上下文保存。理解 Fiber,就是理解 React 如何在单线程的 JavaScript 环境中模拟了一个多任务操作系统。

3.3 Fiber Node 的数据结构

每一个 React Element 在 React 内部都对应一个 Fiber Node。Fiber Node 是一个普通的 JavaScript 对象,但它的字段之丰富,足以承载 React 运行时的全部信息。

完整的 Fiber Node 定义

typescript
// packages/react-reconciler/src/ReactInternalTypes.ts(React 19,简化)
interface FiberNode {
  // === 身份标识 ===
  tag: WorkTag;              // Fiber 的类型标签(FunctionComponent, HostComponent, etc.)
  key: string | null;        // 来自 React Element 的 key
  elementType: any;          // 来自 React Element 的 type
  type: any;                 // 经过解析后的 type(对于 lazy 组件,这是解析后的组件)
  stateNode: any;            // 对应的真实 DOM 节点(HostComponent)或组件实例(ClassComponent)

  // === 树结构(链表指针)===
  return: FiberNode | null;  // 父节点
  child: FiberNode | null;   // 第一个子节点
  sibling: FiberNode | null; // 下一个兄弟节点
  index: number;             // 在兄弟中的位置索引

  // === 引用 ===
  ref: React.Ref<any> | null;

  // === 状态与更新 ===
  pendingProps: any;          // 本次更新的新 props
  memoizedProps: any;         // 上次渲染的 props(已提交)
  memoizedState: any;         // 上次渲染的 state(Hooks 链表的头节点)
  updateQueue: UpdateQueue | null; // 待处理的更新队列

  // === 副作用 ===
  flags: Flags;               // 副作用标记(Placement, Update, Deletion, etc.)
  subtreeFlags: Flags;        // 子树的副作用标记(冒泡优化)
  deletions: FiberNode[] | null; // 需要删除的子 Fiber

  // === 调度与优先级 ===
  lanes: Lanes;               // 本节点上挂起的更新优先级
  childLanes: Lanes;          // 子树中挂起的更新优先级

  // === 双缓冲 ===
  alternate: FiberNode | null; // 指向另一棵树中的对应节点

  // === 调试(开发模式)===
  _debugOwner?: FiberNode | null;
  _debugSource?: Source | null;
}

这个数据结构可以分成六个维度来理解:

维度一:身份标识

字段类型作用
tagWorkTag(枚举)标识 Fiber 的类型,决定 React 如何处理这个节点
keystring | nullDiff 算法中用于识别列表元素的唯一标识
elementTypeanyReact Element 原始的 type
typeany解析后的 type(lazy 组件会被解析为实际组件)
stateNodeany对应的宿主实例(DOM 节点、组件实例)

tag 字段是 React 的"类型分发器"。React 19 中定义了超过 25 种 WorkTag:

typescript
// packages/react-reconciler/src/ReactWorkTags.ts
export const FunctionComponent = 0;
export const ClassComponent = 1;
export const HostRoot = 3;         // ReactDOM.createRoot 创建的根节点
export const HostComponent = 5;    // 原生 DOM 元素(div, span, etc.)
export const HostText = 6;         // 文本节点
export const Fragment = 7;
export const ContextProvider = 10;
export const ForwardRef = 11;
export const MemoComponent = 14;
export const SimpleMemoComponent = 15;
export const LazyComponent = 16;
export const SuspenseComponent = 13;
export const OffscreenComponent = 22;
export const CacheComponent = 24;
// ... 更多类型

维度二:树结构

Fiber 树不是传统的多叉树,而是通过三个指针实现的链表树

图 3-2:Fiber 树的链表结构

typescript
// 三指针结构
// App.child → Header
// Header.sibling → Content
// Content.sibling → Footer
// Header.return → App
// Content.return → App
// Footer.return → App

为什么不用 children: FiberNode[] 数组?因为链表结构有两个关键优势:

优势一:O(1) 的插入和删除。 数组的插入和删除是 O(n),链表是 O(1)。在大型列表的重排序中,这个差异是实质性的。

优势二:天然支持深度优先遍历。 Fiber 的工作循环需要深度优先遍历整棵树,链表结构让这个遍历可以不使用递归完成——只需要沿着 child 向下、沿着 sibling 向右、沿着 return 向上。

typescript
// Fiber 树的深度优先遍历(无递归)
function traverseFiberTree(root: FiberNode) {
  let node: FiberNode | null = root;

  while (node !== null) {
    // 处理当前节点
    processNode(node);

    // 1. 有子节点?向下
    if (node.child !== null) {
      node = node.child;
      continue;
    }

    // 2. 没有子节点,尝试兄弟节点
    while (node !== null) {
      // 回到根节点,遍历结束
      if (node === root) return;

      // 有兄弟节点?向右
      if (node.sibling !== null) {
        node = node.sibling;
        break;
      }

      // 没有兄弟,向上回溯
      node = node.return;
    }
  }
}

维度三:状态与更新

typescript
// pendingProps vs memoizedProps
// pendingProps:本次渲染传入的新 props
// memoizedProps:上次渲染已提交的 props
// 两者比较可以判断 props 是否变化

// memoizedState:对于函数组件,这是 Hooks 链表的头节点
// 每个 Hook 是链表中的一个节点
type HookState = {
  memoizedState: any;     // Hook 的当前值
  baseState: any;         // 基础 state(用于并发模式的优先级跳过)
  baseQueue: Update | null;
  queue: UpdateQueue;      // 该 Hook 的更新队列
  next: HookState | null;  // 指向下一个 Hook
};

💡 最佳实践:理解 memoizedState 的结构解释了为什么 Hooks 不能写在条件语句中。React 通过链表的顺序来匹配每个 useState/useEffect 调用。如果某次渲染跳过了一个 Hook 调用,链表的顺序就会错位,导致每个 Hook 读取了错误的状态。这不是 React 的"设计缺陷",而是链表结构的必然约束——在 O(1) 空间复杂度和 O(1) 访问时间的约束下,顺序是唯一的标识方式。

维度四:副作用标记

typescript
// packages/react-reconciler/src/ReactFiberFlags.ts
export const NoFlags =         0b0000000000000000000000000000;
export const Placement =       0b0000000000000000000000000010; // 新增节点
export const Update =          0b0000000000000000000000000100; // 更新节点
export const ChildDeletion =   0b0000000000000000000000010000; // 子节点需要删除
export const ContentReset =    0b0000000000000000000000100000; // 文本内容重置
export const Callback =        0b0000000000000000000001000000; // 有 callback(setState 第二参数)
export const Ref =             0b0000000000000000001000000000; // ref 需要更新
export const Snapshot =        0b0000000000000000010000000000; // getSnapshotBeforeUpdate
export const Passive =         0b0000000000000000100000000000; // useEffect
export const Visibility =      0b0000000000000010000000000000; // Offscreen 可见性
// ... 更多标记

为什么用位掩码? 因为一个 Fiber 节点可以同时拥有多种副作用。位掩码允许用单个整数存储多个标记,用位运算进行 O(1) 的查询和修改:

typescript
// 添加标记
fiber.flags |= Placement | Update; // 同时标记为"新增"和"更新"

// 检查标记
if (fiber.flags & Placement) { /* 需要新增 */ }
if (fiber.flags & Update) { /* 需要更新 */ }

// 清除标记
fiber.flags &= ~Placement; // 移除"新增"标记

// 子树标记冒泡
parent.subtreeFlags |= child.flags | child.subtreeFlags;
// 父节点知道子树中是否有副作用,可以跳过无副作用的子树

subtreeFlags 是 React 18 引入的优化。在此之前,React 使用 "Effect List"(一个链表)收集所有有副作用的节点。subtreeFlags 的优势在于:如果一个子树的 subtreeFlags 为 0,那么整个子树可以跳过,不需要遍历。 这在大型组件树中是一个重大优化。

基于 VitePress 构建