跳至正文
来两杯美式
返回

React 核心(13):React 渲染机制深入——Fiber 与调度

By 来两杯美式
发布于

Fiber 是 React 16 重写的渲染引擎——把递归变成链表,把”一口气跑完”变成”跑一段歇一段”,让渲染可以被中断、恢复、按优先级调度。

一、为什么需要 Fiber:Stack Reconciler 的困境

1.1 React 15 的渲染方式:递归到底

React 15 用的是 Stack Reconciler——名字就说明了问题:它用递归(调用栈)遍历组件树,一旦开始就停不下来。

setState()
  → 递归遍历整棵组件树
    → 对每个组件调用 render()
      → Diff 新旧 Virtual DOM
        → 计算出所有 DOM 操作
          → 一次性全部执行

这个过程是同步的、不可中断的。就像你走进一条没有出口的隧道——进去就得走到头,中途不能停下来。

1.2 问题:长任务阻塞主线程

浏览器的主线程同时负责 JS 执行和页面渲染。如果 React 的递归遍历耗时超过一帧(约 16ms),浏览器就没时间做渲染和响应用户操作——页面就卡了

主线程时间线(React 15):

|--- 递归 Reconcile(50ms)---------------------------------------|
|                                                                 |
|  没时间渲染  |  没时间渲染  |  没时间渲染  |  终于完了  |  渲染一帧  |
|  掉帧 ❌     |  掉帧 ❌     |  掉帧 ❌     |            |           |

❌ 用户输入没响应(事件处理被阻塞) ❌ 动画掉帧(浏览器没时间渲染) ❌ 页面看起来”冻住”了

1.3 根本原因:递归不可中断

递归调用的特点是——调用栈一压进去,就必须等它弹出来。你没法在递归到一半时说”暂停,我先处理别的事”。

Stack Reconciler(React 15)Fiber Reconciler(React 16+)
遍历方式递归(调用栈)链表(可中断)
可中断❌ 一旦开始必须跑完✅ 随时暂停,下次接着来
主线程占用长时间独占分片,每片 5ms
优先级无,先来先做有,高优先级插队

Fiber 的核心目标就一个:让渲染过程可以被中断,把控制权还给浏览器,保证页面流畅。

二、Fiber 是什么:虚拟栈帧

2.1 从递归到链表

Fiber 的思路是:既然递归不可中断,那就不用递归。把递归拆成链表——每个节点是一个独立的工作单元,做完一个可以停下来,下次接着做下一个。

类比:Fiber 像可暂停的游戏存档——玩到一半可以保存退出,下次接着来。而递归像看一场不能暂停的电影——进了影院就得看到底。

2.2 每个 Fiber 节点就是一个”栈帧”

在递归模型里,每个组件的渲染信息存在调用栈的栈帧中——函数返回,栈帧就没了。Fiber 把这些信息从调用栈搬到了堆上,变成一个 JS 对象,叫做 Fiber 节点

// 简化的 Fiber 节点结构
const fiber = {
  // === 身份信息 ===
  type: App, // 组件函数 / HTML 标签名
  key: null, // 列表中的 key
  props: { count: 1 }, // 传入的 props

  // === 链表三指针(树结构) ===
  return: parentFiber, // 父节点(为什么叫 return?因为"回到"调用者)
  child: firstChildFiber, // 第一个子节点
  sibling: nextFiber, // 下一个兄弟节点

  // === 双缓冲 ===
  alternate: currentFiber, // 指向另一棵树的对应节点

  // === 副作用标记 ===
  effectTag: "Placement", // 标记需要什么 DOM 操作(插入/更新/删除)

  // === 状态 ===
  memoizedState: hookList, // Hook 链表(函数组件的状态)
  pendingProps: {}, // 待处理的新 props
};

2.3 链表三指针:child、sibling、return

这三个指针把树形结构变成了链表,让遍历可以一步步走,不用递归:

        App (return: null)
       /
     div (return: App)
    /    \
  header  main (sibling: header, return: div)
   |        |
  h1       p (sibling: h1, return: header/main)

遍历顺序:App → div → header → h1 → main → p

// Fiber 树的遍历算法(简化版)
function workLoop() {
  while (currentFiber && !shouldYield()) {
    // 对当前 Fiber 执行工作
    currentFiber = performUnitOfWork(currentFiber);
  }
  // shouldYield() 返回 true → 时间片用完,暂停
  // 下次从 currentFiber 继续就行
}

function performUnitOfWork(fiber) {
  // 1. 处理当前节点(执行组件函数 / Diff)
  // 2. 返回下一个要处理的节点
  if (fiber.child) return fiber.child; // 优先子节点
  let next = fiber;
  while (next) {
    if (next.sibling) return next.sibling; // 其次兄弟节点
    next = next.return; // 回到父节点
  }
  return null; // 遍历完了
}

关键洞察:递归用调用栈维护”我在哪”,Fiber 用链表指针维护”我在哪”。链表的好处是——你可以随时停下来,因为”我在哪”存在对象里,不在栈帧里。

三、双缓冲机制:current 树 ↔ workInProgress 树

3.1 为什么需要两棵树

React 同时维护两棵 Fiber 树:

含义什么时候变
current 树当前屏幕上显示的 UICommit 阶段切换后更新
workInProgress 树正在内存中构建的新 UIRender 阶段逐步构建

类比:双缓冲像画家在后台画——观众看到的是已完成的画(current),画家在画布背面画新的(workInProgress),画完了才翻过来展示。观众永远不会看到画到一半的画。

3.2 alternate 指针:两棵树的桥梁

每个 Fiber 节点通过 alternate 指向另一棵树的对应节点:

// current 树上的节点
currentFiber.alternate === workInProgressFiber;

// workInProgress 树上的节点
workInProgressFiber.alternate === currentFiber;

构建 workInProgress 树时,React 不是从零创建所有节点——它复用 current 树的节点,只创建新的或修改有变化的:

// 简化的复用逻辑
function createWorkInProgress(current, pendingProps) {
  let workInProgress = current.alternate;
  if (workInProgress) {
    // 已有 alternate → 复用,更新 props
    workInProgress.pendingProps = pendingProps;
  } else {
    // 没有 alternate → 新建
    workInProgress = createFiber(current.type, pendingProps, current.key);
    workInProgress.alternate = current;
    current.alternate = workInProgress;
  }
  // 复制其他属性...
  return workInProgress;
}

3.3 切换:一根指针的翻转

Render 阶段结束后,workInProgress 树构建完毕。Commit 阶段做的事很简单——把 FiberRoot 的 current 指针从旧树切到新树

// Commit 阶段最后一步
root.current = workInProgressTree;
// 就这一行——屏幕上的 UI 就"瞬间"更新了
Render 阶段结束前:
  root.current → currentTree(屏幕上显示的)
  workInProgressTree(内存中构建好的)

Commit 阶段:
  root.current → workInProgressTree(切换!)

下次渲染时:
  旧的 currentTree 变成新的 workInProgressTree(角色互换)

双缓冲保证了用户永远不会看到半成品 UI——要么是旧的完整画面,要么是新的完整画面,没有中间态。

四、渲染两阶段:Render(可中断)与 Commit(不可中断)

4.1 Render 阶段:纯计算,可中断

Render 阶段做的事:遍历 Fiber 树,执行组件函数,Diff 新旧子树,给变化的节点打 effectTag。

// Render 阶段的核心工作(简化)
function renderPhase(fiber) {
  // 1. 执行组件函数,拿到新的 JSX
  const newChildren = fiber.type(fiber.pendingProps);

  // 2. Diff:对比新旧子 Fiber,决定哪些要更新
  reconcileChildren(fiber, newChildren);

  // 3. 给有变化的子 Fiber 打 effectTag
  //    Placement = 插入新节点
  //    Update = 更新已有节点
  //    Deletion = 删除旧节点
}

这个阶段的特点:

特性说明
纯计算不操作真实 DOM,不产生可见副作用
可中断时间片用完就暂停,下次接着来
可丢弃如果有更高优先级更新进来,当前计算可以作废重来
可并发多个更新可以交替进行

因为 Render 阶段不碰 DOM,所以中断、重做都没关系——反正用户看不到。

4.2 Commit 阶段:操作 DOM,不可中断

Commit 阶段做的事:遍历 effectList(所有打了 effectTag 的 Fiber),执行真实的 DOM 操作。

// Commit 阶段的核心工作(简化)
function commitPhase(root) {
  const effectList = root.firstEffect; // 收集好的副作用链表

  // 遍历 effectList,执行 DOM 操作
  let effect = effectList;
  while (effect) {
    switch (effect.effectTag) {
      case "Placement":
        parentNode.appendChild(effect.stateNode); // 插入
        break;
      case "Update":
        updateDOMProperties(effect.stateNode, effect); // 更新属性
        break;
      case "Deletion":
        parentNode.removeChild(effect.stateNode); // 删除
        break;
    }
    effect = effect.nextEffect;
  }

  // 最后:切换 current 指针
  root.current = finishedWork;
}

这个阶段的特点:

特性说明
操作 DOM产生可见的副作用
不可中断必须一口气做完,保证 DOM 一致性
同步执行一旦开始,直到所有 DOM 操作完成

为什么 Commit 不能中断?想象一下:插入了一个新节点,还没来得及删除旧节点就停了——用户看到新旧节点同时存在,UI 就不一致了。

4.3 两阶段对比

Render 阶段Commit 阶段
做什么执行组件函数、Diff、打标记操作真实 DOM
可中断✅ 可以❌ 不可以
有副作用❌ 纯计算✅ 操作 DOM
执行时机时间片内,可分多次一次性同步完成
类比在后台画新画把新画翻到前面展示

五、调度机制:时间切片与优先级

5.1 时间切片(Time Slicing)

Fiber 的调度核心是 MessageChannel + shouldYield

// 简化的调度循环
function workLoopConcurrent() {
  while (workInProgress && !shouldYield()) {
    // shouldYield() 检查:当前时间片(5ms)用完了吗?
    workInProgress = performUnitOfWork(workInProgress);
  }

  if (workInProgress) {
    // 时间片用完但还没做完 → 安排下一个时间片继续
    scheduleCallback(continueWork);
  }
  // 如果做完了 → 进入 Commit 阶段
}

时间切片的工作方式:

主线程时间线(Fiber):

|-- 5ms React --|-- 浏览器渲染 --|-- 5ms React --|-- 浏览器渲染 --|
   做了一部分      页面流畅 ✅      接着做          页面流畅 ✅

React 用 MessageChannel 而不是 setTimeout 来调度下一个时间片——因为 MessageChannel 的优先级比 setTimeout(0) 更高,能更快拿到控制权。

5ms 不是硬编码的魔法数字——它是经验值,保证浏览器有足够时间渲染(16ms 一帧 - 5ms React ≈ 11ms 给浏览器)。

5.2 优先级调度(Lane Model)

不是所有更新都一样急。用户打字输入要立刻响应,数据获取可以慢一点。React 用 Lane Model 给更新分优先级:

优先级Lane场景特点
同步SyncLaneflushSync 包裹的更新立即执行,不可中断
用户输入InputContinuousLane输入框打字、拖拽高优先级,尽快执行
默认DefaultLanesetState 普通更新正常优先级
过渡TransitionLaneuseTransition / startTransition低优先级,可被中断
空闲IdleLane离屏渲染、预加载最低优先级,有空再做
// 优先级调度的实际效果
function SearchPage() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);

  function handleChange(e) {
    // 高优先级:用户打字,必须立刻响应
    setQuery(e.target.value);

    // 低优先级:搜索结果可以慢慢来
    startTransition(() => {
      setResults(search(e.target.value));
    });
  }
}

高优先级更新可以打断低优先级更新——用户打字时,搜索结果的渲染会被暂停,等输入处理完再继续。

Lane Model 用二进制位表示优先级(每个 Lane 是一个 bit),这样可以用位运算快速判断优先级关系。但作为使用者,你只需要理解”不同更新有不同优先级,高优先级可以打断低优先级”就够了。

5.3 可中断 + 可恢复

Fiber 架构让渲染过程可以暂停和恢复

场景:正在渲染一个低优先级更新(搜索结果),用户突然打字

1. Render 阶段进行中(低优先级)
   → 处理到第 30 个 Fiber 节点
   → shouldYield() 返回 true(有高优先级更新进来)

2. 暂停当前渲染
   → 保存当前 workInProgress 指针
   → 开始处理高优先级更新(用户输入)

3. 高优先级更新完成
   → Commit 阶段执行,DOM 更新

4. 恢复低优先级渲染
   → 从第 30 个 Fiber 节点继续
   → 但如果低优先级更新的数据已经过期,直接丢弃重来

关键:workInProgress 树构建到一半可以暂停,因为进度存在 Fiber 对象里(链表指针),不在调用栈里。这就是 Fiber 把递归变链表的核心价值。

六、从 setState 到屏幕更新的完整流程

把前面所有知识串起来,看一次状态更新从头到尾经历了什么:

setState(newValue)


① 创建 Update 对象
  │  包含:新值、优先级(lane)、回调


② 入队到 Fiber 的 updateQueue
  │  多个 setState 可能合并到同一个队列


③ 调度:根据优先级安排执行时机
  │  同步 → 立即执行
  │  普通 → 放入调度队列,等时间片
  │  过渡 → 低优先级队列


④ Render 阶段(可中断)
  │  从根 Fiber 开始遍历
  │  ├─ 执行组件函数,拿到新 JSX
  │  ├─ Diff 新旧子 Fiber
  │  ├─ 打 effectTag
  │  ├─ 收集 effectList
  │  └─ 时间片用完 → 暂停,下次继续


⑤ Commit 阶段(不可中断)
  │  遍历 effectList
  │  ├─ 操作真实 DOM(插入/更新/删除)
  │  ├─ 执行 useEffect / useLayoutEffect
  │  └─ 切换 current 指针


⑥ 浏览器绘制
  │  浏览器拿到新的 DOM → 计算布局 → 绘制像素


  屏幕更新 ✅

6.1 批量更新

React 会自动批量处理状态更新——同一个事件处理函数里的多次 setState 只触发一次渲染:

function handleClick() {
  setCount(1); // 不会立刻渲染
  setName("yi"); // 不会立刻渲染
  setOpen(true); // 不会立刻渲染
  // 三个更新合并成一次渲染
}

React 18 之前,批量更新只在 React 事件处理中生效;React 18 之后,setTimeoutPromise、原生事件中的 setState 也会自动批量处理。

6.2 与 声明式与React的实现机制 的关系

声明式与React的实现机制 里,我们讲了 Diff 算法”算什么”——对比新旧 Virtual DOM,找出最小 DOM 操作。这篇讲的是”怎么算”——React 用什么架构来执行这个计算:

维度声明式与React的实现机制本篇
关注点Diff 算法的规则Diff 的执行架构
核心问题“哪些节点变了”“怎么高效地算出哪些节点变了”
关键概念Virtual DOM、ReconciliationFiber、双缓冲、时间切片
类比裁缝的量尺(量出哪里要改)裁缝的工作台(怎么安排改衣服的流程)

声明式那篇的链条是:setState → 重新渲染 → Diff → 更新 DOM。本篇展开的是中间两步——“重新渲染”和”Diff”具体怎么执行的。

七、Fiber 与 React 18 并发特性

Fiber 是 React 18 并发特性的基础设施。没有 Fiber 的可中断渲染,这些特性都不可能实现:

React 18 特性依赖 Fiber 的什么能力
startTransition / useTransition低优先级更新可被高优先级打断
useDeferredValue延迟渲染,让位给更紧急的更新
Suspense 的流式 SSR服务端可以中断/恢复 HTML 流的发送
useId双缓冲两棵树需要稳定的 ID 生成
批量更新(自动批处理)调度器可以合并同一优先级的更新

Fiber 不是并发特性本身,而是让并发成为可能的底层架构。就像高速公路不是”快”本身,而是让”快”成为可能的基础设施。

八、一句话总结

Fiber 把递归变链表,让渲染可中断;双缓冲保证用户永远看不到半成品;时间切片让主线程不被独占;优先级调度让紧急更新先执行。 这四件事合在一起,就是 React 从”一口气跑完”到”跑一段歇一段”的架构升级。


分享这篇文章:
通过邮件分享这篇文章✓ 链接已复制
所属专题
React
第 13 / 17 篇
查看系列全部文章
  1. 01.React 核心(1):React 组件与函数的本质区别
  2. 02.React 核心(2):声明式与 React 的实现机制
  3. 03.React 核心(3):Props 完全指南
  4. 04.React 核心(4):useState 完全指南
  5. 05.React 核心(5):useEffect 完全指南
  6. 06.React 核心(6):useRef 完全指南
  7. 07.React 核心(7):Context API 与状态共享
  8. 08.React 核心(8):useCallback 与 useMemo
  9. 09.React 核心(9):Hooks 规则与闭包陷阱
  10. 10.React 核心(10):自定义 Hook 设计
  11. 11.React 核心(11):Key 与列表渲染
  12. 12.React 核心(12):表单与受控/非受控组件
  13. 13.React 核心(13):React 渲染机制深入——Fiber 与调度
  14. 14.React 核心(14):React 18 并发特性
  15. 15.React 核心(15):Suspense 机制详解
  16. 16.React 核心(16):TanStack Query 入门指南
  17. 17.React 核心(17):TanStack Query 缓存机制详解

上一篇
React 核心(14):React 18 并发特性
下一篇
React 核心(12):表单与受控/非受控组件