React 渲染与 Fiber:从同步递归到可中断的并发渲染

0 阅读10分钟

Day14 把 TypeScript 工程讲完了,阶段三正式进入框架内核。React 近几年的所有大事:Concurrent Mode、Suspense、useTransition、React 19 的 use(),根都在同一个地方:Fiber 架构。今天把它彻底拆开。

1. 技术难点:同步递归渲染为什么非改不可

React 16 之前(以及 Vue 至今的大部分更新),渲染一棵组件树用的是朴素递归:render() 返回 vdom,协调器对比新旧 vdom 后同步改 DOM。在中小项目里它简单可靠,但有一个致命结构缺陷:

  1. 递归一旦开始就无法停下。React 的函数组件天然是树形递归,一个组件渲染完才能轮到下一个。整棵树的协调工作挤在一个调用栈里同步跑完,中途没有任何"检查点"。当一次更新涉及几千个组件时,主线程被独占几十甚至上百毫秒,期间用户的输入、滚动、动画全部排队等待。浏览器一帧只有约 16.6ms,超过这个预算就会掉帧、卡输入。
  2. 隐式调用栈没有"存档"能力。递归的状态全压在函数栈帧里,那是运行时私有数据,框架够不着。想"做一半暂停,让高优先级任务插队,再回来接着做",就必须能回答一个问题:下一次该从哪个组件继续。调用栈给不出这个答案,它只能一路弹完。
  3. 副作用不能中途发生。如果协调过程中边对比边改 DOM,一旦被中断,页面上就会残留半新半旧的状态,用户看到撕裂的界面。所以任何"可中断"方案的前提,是把副作用全部推迟到最后一口气做完。

这三点合起来指向一个结论:渲染必须从"一次函数调用"变成"可调度的工作队列"。而队列里的每一项得是一个能独立暂停、能被记住、能被丢弃重来的最小工作单元,这就是 Fiber。

2. 完整解法:Fiber 架构四件套

2.1 把调用栈摊平成链表:FiberNode

Fiber 的本质,是把递归调用栈显式化为一个可持久化的数据结构。每个组件对应一个 Fiber 节点,节点之间不靠函数嵌套,而靠三个指针连接:

  • child:第一个子节点
  • sibling:下一个兄弟节点
  • return:父节点(指回去,遍历结束要能向上回溯)

一棵树因此变成了可随意进出、可存档恢复的链表结构。真实的 FiberNode 还挂着 type/key(对应 vdom 元素)、pendingProps/memoizedProps(新旧 props)、stateNode(组件实例或真实 DOM)、alternate(双缓存对端)、lanes(优先级位掩码)、flags(副作用标记)。

2.2 双缓存树:用户永远看不到"半成品"

React 同时维护两棵树:current(已经提交、正在屏幕上展示的树)和 workInProgress(在后台静默构建的新树),通过 alternate 互指。协调阶段只在 workInProgress 上施工,屏幕上的一切不受打扰;整棵树构建完毕才做一次指针切换,current 整体替换。这就是难点 3 的解法:要么不动,要么一次到位,中间态永远对用户不可见,丢弃半成品树也毫无心理负担。

2.3 render 阶段:DFS 工作循环

render 阶段从根出发,逐个处理工作单元,顺序是标准深度优先:

  • beginWork:处理当前 Fiber(按需创建 DOM、调和 children 生成子 Fiber)
  • 有 child 就下钻;没有就 completeWork 收尾当前节点
  • 向右走 sibling;兄弟走完沿 return 向上找下一个带兄弟的祖先

主循环每次只调用 performUnitOfWork 处理一个单元,处理完把"下一个单元"存进全局 nextUnitOfWork。这个变量就是整个架构的断点存档:无论何时被中断,下次回来从它接着走即可,不需要重建任何调用栈。

2.4 时间切片 + 优先级抢占:并发的地基

Scheduler 用宏任务(MessageChannel,不用 requestIdleCallback,兼容性差且有 50ms 调用上限问题)驱动工作循环,每个宏任务内设置一个 deadline(约 5ms 预算),workLoop 每处理完一个单元就检查是否超时:

while (nextUnitOfWork && !shouldYield()) {
  nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}

超时就主动让出主线程,浏览器得以渲染一帧、响应输入,下个宏任务再回来继续。同时每个更新按 lane 位掩码标记优先级:离散事件(输入、点击)对应最高优先级,startTransition 包住的更新是低优先级。高优先级任务到达时,直接丢弃正在构建的低优先级 workInProgress 树,低优先级任务转入重试队列稍后再来。render 阶段是纯函数、无副作用,所以丢弃永远安全。被中断、被抢占、被重建,用户看到的始终是完整一致的旧树,直到新树 commit。

2.5 commit 阶段:不可中断的"最后一口气"

所有真实副作用(DOM 插入删除、useLayoutEffect、useEffect)都推迟到 commit 阶段,按 mutation → layout → passive 三段同步执行,绝不切片。因为 DOM 一旦开始改动,中断就会留下中间态,commit 必须一口气跑完。所以 Fiber 的全部灵活性都在 render 阶段,commit 阶段是"牺牲灵活性换原子性"。

2.6 可运行的最小实现

下面这段代码把上述骨架浓缩成约百行,Node 直接可跑。它演示低优先级大列表渲染过程中,高优先级输入事件到达时的完整行为:时间片让出 → 抢占 → 丢弃低优先级树 → 高优先级 commit → 低优先级重试。代码注释标出了与真实 React 的对应关系。

// mini-fiber.mjs —— 最小可运行 Fiber 协调器(Node 直接跑)
// 运行: node mini-fiber.mjs

// ---------- 1. 超轻量 DOM 桩:只记录挂载顺序,便于观察 commit ----------
const createEl = (tag) => ({ tag, children: [] });
const createText = (text) => ({ tag: '#text', text });

// ---------- 2. vdom 工厂(不依赖 JSX) ----------
const h = (type, props, ...children) => ({
  type,
  props: props ?? {},
  // 字符串子节点统一规范化为 TEXT 节点,与 JSX 文本子节点行为一致
  children: children.flat().map((c) =>
    typeof c === 'string' || typeof c === 'number'
      ? { type: 'TEXT', props: { nodeValue: String(c) }, children: [] }
      : c,
  ),
});

// ---------- 3. Fiber 与全局状态 ----------
let wipRoot = null;   // 正在构建的 workInProgress 树根(双缓存的后台树)
let nextUnit = null;  // 下一个工作单元 = 中断恢复点(对应 nextUnitOfWork)
let epoch = 0;        // 渲染代数:抢占重建时自增,旧单元据此作废

function createFiber(vnode, parent) {
  return { vnode, parent, dom: null, child: null, sibling: null };
}

function createDom(fiber) {
  const { type, props } = fiber.vnode;
  if (type === 'TEXT') return (fiber.dom = createText(props.nodeValue));
  fiber.dom = createEl(type);
  for (const k in props) if (k !== 'nodeValue') fiber.dom[k] = props[k];
}

// beginWork:建 DOM + 把 children 串成 child/sibling 链表
function beginWork(fiber) {
  if (fiber.vnode.type !== 'ROOT') createDom(fiber);
  if (fiber.vnode.type === 'TEXT') return;
  let prev = null;
  for (const c of fiber.vnode.children) {
    const f = createFiber(c, fiber);
    if (!prev) fiber.child = f;
    else prev.sibling = f;
    prev = f;
  }
}

// 深度优先取下一个单元:有 child 下钻;否则向右找 sibling;再向上找带 sibling 的祖先
function nextWorkUnit(fiber) {
  if (fiber.child) return fiber.child;
  let n = fiber;
  while (n) {
    if (n.sibling) return n.sibling;
    n = n.parent;
  }
  return null;
}

const slow = (ms) => { const t = Date.now() + ms; while (Date.now() < t) {} };

function perform(fiber) {
  beginWork(fiber);
  slow(0.5); // 模拟复杂组件计算量,让时间片真实耗尽
  return nextWorkUnit(fiber);
}

// commit:一次性把整棵新树挂到容器。对应真实 React 的 commit 阶段:不可中断
function commit(fiber, container) {
  if (!fiber) return;
  if (fiber.dom && fiber.parent?.dom) fiber.parent.dom.children.push(fiber.dom);
  commit(fiber.child, container);
  commit(fiber.sibling, container);
}

// ---------- 4. 调度器:时间切片 + 优先级抢占 ----------
const SLICE_MS = 3;       // 一个时间片的预算,耗尽就让出主线程
let renderLabel = '';
let pendingHigh = null;   // 抢占注入点(模拟高优先级事件到达)
let retryQueue = [];      // 被抢占的低优先级任务,稍后重试(对应 transition 语义)
let sliceCounter = 0;     // 日志节流

function startRender(vnode, container, label, fromScrap) {
  epoch++;
  const root = createFiber({ type: 'ROOT', props: {}, children: [vnode] }, null);
  root.dom = container;
  wipRoot = root;
  nextUnit = root;
  renderLabel = label;
  console.log(`[${label}] ${fromScrap ? '重建(epoch=' + epoch + ')' : '开始渲染(epoch=' + epoch + ')'} 队列中有 ${countNodes(vnode)} 个节点`);
  workLoop();
}

function workLoop() {
  const deadline = Date.now() + SLICE_MS;
  let done = 0;
  while (nextUnit && Date.now() < deadline) {  // shouldYield() 的雏形
    nextUnit = perform(nextUnit);
    done++;
  }
  if (!nextUnit) {
    commit(wipRoot.child); // 树建完,一次性提交,切换 current 树
    console.log(`[${renderLabel}] commit 完成,容器内节点: ${container.children.map((c) => c.tag).join(' -> ')}`);
    wipRoot = null;
    if (retryQueue.length) {           // 高优先级提交后,让被抢占的低优先级任务重试
      const [v, c, l] = retryQueue.shift();
      startRender(v, c, l, true);
    } else if (pendingHigh) {
      const [v, c, l] = pendingHigh;
      pendingHigh = null;
      startRender(v, c, l, false);
    }
    return;
  }
  if (++sliceCounter % 5 === 0) {
    console.log(`[${renderLabel}] 时间片耗尽(已处理 ${done} 单元),让出主线程,等待下个宏任务`);
  }
  if (pendingHigh) {
    // 抢占:丢弃当前 workInProgress 树(render 无副作用,丢弃安全),任务转入重试队列
    console.log(`   >> 高优先级到达!丢弃低优先级 workInProgress 树,任务转入重试队列`);
    retryQueue.push([wipRoot.vnode.children[0], wipRoot.dom, renderLabel]);
    const [v, c, l] = pendingHigh;
    pendingHigh = null;
    startRender(v, c, l, false); // 高优先级立即重建并优先 commit
  } else {
    setTimeout(workLoop, 0); // 让出主线程,下个宏任务回来继续
  }
}

const countNodes = (v) =>
  v.type === 'TEXT' ? 1 : 1 + v.children.reduce((s, c) => s + countNodes(c), 0);

// ---------- 5. 场景演示 ----------
// 低优先级:渲染 300 项大列表(901 个 fiber 单元,必然跨多个时间片)
const bigList = h('ul', null,
  ...Array.from({ length: 300 }, (_, i) => h('li', { 'data-i': i }, h('span', null, 'item ' + i))));
// 高优先级:一个输入框小组件(离散事件对应最高优先级 lane)
const inputView = h('div', null, h('input', { type: 'text' }), h('span', null, '已输入: '));

const container = createEl('root');
console.log('=== 场景:低优先级大列表渲染中,用户输入事件(高优先级)到达 ===\n');
setTimeout(() => { pendingHigh = [inputView, container, '高:输入框']; }, 60);
startRender(bigList, container, '低:大列表', false);

实际运行的关键输出(已节流):

[低:大列表] 开始渲染(epoch=1) 队列中有 901 个节点
[低:大列表] 时间片耗尽(已处理 2 单元),让出主线程,等待下个宏任务
   ...(反复让出)...
   >> 高优先级到达!丢弃低优先级 workInProgress 树,任务转入重试队列
[高:输入框] 开始渲染(epoch=2) 队列中有 4 个节点
[高:输入框] commit 完成,容器内节点: div
[低:大列表] 重建(epoch=3) 队列中有 901 个节点
[低:大列表] commit 完成,容器内节点: div -> ul

注意两个细节:低优先级任务从未"做一半就提交",高优先级 commit 后容器里只有 div(输入框),随后低优先级重建(epoch 3)并整体提交,容器从 div 干净地变成 div -> ul。中间的每次让出和抢占,用户都感知不到任何撕裂。

3. 应用场景

  • 大列表输入卡顿:表格几千行、搜索框每敲一个字触发一次全表重渲染,同步渲染必然丢帧。用 Fiber 的优先级体系,输入更新走最高 lane 直接 commit,列表重渲染降级为可抢占的普通更新,甚至包进 useDeferredValue/startTransition 让它在空闲时间片里慢慢做完。这是并发特性对业务最直接的收益。
  • Suspense 与数据加载:组件抛出一个 Promise,Fiber 把该分支标记为挂起并中断构建,Promise resolve 后重新调度该分支。Suspense 能"等一等再渲染"的机制与抢占共用同一套可中断渲染地基,React 19 的 use() 让这个模型从 lazy 扩展到任意异步资源。
  • 保持交互响应性的代价模型:写 React 时要区分"急事"和"不急的事"。状态更新默认可能被中断,副作用必须放进 effect(commit 阶段),render 里永远不做有副作用的事。这个心智模型一旦建立,useTransition 的返回值(isPending)和 useDeferredValue 就都是顺理成章的工具。
  • 与 Vue 3 的对比:Vue 用细粒度响应式把更新范围缩到最小,再靠微任务批量同步提交,大部分更新根本不需要中断;React 粒度粗(整树协调),但用可中断换取"永远不让用户等"。两者一个省事、一个让路,是两种工程哲学,不是简单的谁快谁慢。

4. 总结

Fiber 架构只回答了一个问题:怎么让渲染可以随时停下、记住进度、被人插队、还能安全重来。答案是放弃函数调用栈的便利,把工作显式地做成数据结构:链表化的 fiber 树提供断点,双缓存保证原子可见,时间切片把大任务拆进帧间隙,lane 优先级让插队有秩序,commit 阶段用"不可中断"换"永不撕裂"。React 18 的并发、Suspense、过渡更新,React 19 的 Actions,全部是这套地基上的应用层。看任何 React 源码或报错栈时记住一句话:看到 beginWork 是它在往下钻,看到 completeUnitOfWork 是它在回溯,看到 "render was interrupted" 是它被更高优先级的人插了队。这套"递归变循环、调用栈变显式数据"的思路,离开 React 也通用,协程、redux-saga、甚至操作系统的进程调度,都是同一个故事。