Skip to content

第六章:Fiber,把递归调用栈变成可调度的数据

旧 React 的 reconciler 可以批处理更新、跳过未变化子树,也能为 DOM 和 Native 复用组件模型。但它有一个无法通过微优化消除的限制:协调过程借用 JavaScript 调用栈保存进度。

只要一次更新开始递归遍历深树,浏览器就要等这段同步代码返回,才能处理输入、动画和绘制。React 可以让每个函数更快,却不能在普通函数调用中间把整个栈交还浏览器,然后稍后可靠恢复。要获得可中断、可取消和有优先级的 render,工作本身必须成为数据。

压力不是“DOM diff 太慢”

用户感知性能取决于是否及时响应,而不只取决于总计算时间。一个 30ms 更新即使比另一方案总耗时更少,也可能跨过两帧,让输入和动画卡顿;把它分成多个小片段,总耗时略增,却可以在片段间让出主线程。

React 团队把未来目标描述为 cooperative scheduling:高优先级交互可以打断低优先级渲染;不可见或预加载工作可以延后;过时的中间树可以丢弃;最终 DOM 提交仍应保持一致。

这些要求意味着 reconciler 不能再把“调用到哪一层”藏在 VM 栈里。

第一步:先做一个没有输出的 Renderer

2016 年 5 月的 PR #6690:New Reconciler Infra 并没有一次提交完整 Fiber。它建立新旧 reconciler 目录和 Noop renderer。最早的 Fiber 只有 parentchildsibling、输入输出和少量状态;reconciler 保存 nextUnitOfWork,在 deadline 仍有时间时执行一个单元,否则重新安排 callback。

这个原型的重要性不在功能完整,而在控制流已经翻转:

text
以前:父函数调用子函数,JS 栈保存返回位置
以后:work loop 读取一个 Fiber,算出下一个 Fiber

一旦“下一步做什么”由对象指针表示,React 就能在任意 unit 边界停止。Noop renderer 则让团队在没有 DOM 噪音的情况下测试顺序、优先级和协调语义。

史料事实:PR 中的测试已经模拟 flushLowPri(7) 先让出,再用更大 deadline 继续完成。它是实验代码,却直接表达 Fiber 的核心目的。

Fiber 是组件,也是待完成的工作

Fiber 节点不是 React element 的简单副本。它把一个组件在某次协调中的工作状态具体化,典型字段包括:

  • tag:函数组件、class、host component、root、Suspense 等工作类型。
  • typekey:帮助决定身份和更新方式。
  • returnchildsibling:用单向链表表示树和返回路径。
  • pendingPropsmemoizedPropsmemoizedState:区分新输入与上次完成的输入。
  • updateQueue:保存待处理状态更新或 effect。
  • lanes:后来用于表示优先级集合。
  • alternate:连接 current 与 work-in-progress 两个版本。

“Fiber”这个名字可以理解为一条可独立调度的工作纤维。一个组件在屏幕上只有一个当前身份,但协调期间可能有另一个 work-in-progress Fiber 表示候选未来。

双缓冲:永远只保留两棵候选树

PR #6981 引入 alternate 和 fiber pooling。团队意识到不需要为每次 render 保留无限版本:通常只需要当前已经提交的树,以及正在构建的下一棵树。若工作完成,root 指针切到新树;若工作被放弃,当前树仍可继续代表 UI。

这类似图形系统中的 double buffering:用户只看到已完成画面,后台准备下一帧。React 还复用 alternate 对象,避免每次更新都重新分配整棵树。

text
current fiber  <──── alternate ────>  work-in-progress fiber
已提交、可见                             可暂停、可丢弃

双缓冲带来清晰的隔离,也增加实现复杂度:同一逻辑组件可能对应两个 Fiber;字段究竟属于 current 还是 WIP;何时可以从旧树复制;中断后哪些更新仍有效,都必须精确处理。

Begin / Complete:手写一套可恢复调用栈

Fiber work loop 大致分两种动作:

  1. beginWork 处理当前节点,计算/协调 children,若有 child 就继续向下。
  2. 子树完成后,completeWork 创建或准备宿主输出,再沿 sibling 或 return 返回。

这正是递归 DFS 的显式版本。child 相当于进入递归,sibling 相当于处理同层下一个节点,return 相当于函数返回。因为每一步都更新 workInProgress 指针,循环可以在节点之间检查是否应让出。

Fiber 工作循环

关键点是,可中断的是 render/reconciliation,不是任意宿主 mutation。若 React 在一半组件更新 DOM 后暂停,用户会看到混合的新旧 UI,layout effect 与事件也会失去一致性。因此 Fiber 必须把“计算下一棵树”和“把结果提交出去”分开。

Side-effect list 与 Commit Phase

PR #7154 加入 host side effects:先在整棵树协调完成后收集带副作用的 Fiber,再调用宿主环境。历史实现用 firstEffectlastEffectnextEffect 连接链表,避免 commit 时重新遍历所有节点;后来实现细节有所调整,但阶段分离保留下来。

render phase 可以:

  • 调用组件并生成 children。
  • 计算 state、context 与 bailout。
  • 标记 Placement、Update、Deletion 等 effect。
  • 被暂停、重启或丢弃。

commit phase 则:

  • 修改 DOM/原生视图。
  • 更新 ref。
  • 调用 layout lifecycle/effect。
  • 安排 passive effect。

commit 通常保持同步和原子性,因为屏幕不能长期停留在一半提交状态。Concurrent React 的“并发”主要发生在 commit 之前。

优先级从早期模型演化为 Lanes

Fiber 最早区分 high/low priority,后来使用 expiration time,把优先级映射成到期时间。这个模型能表达“越快到期越紧急”,但当多个相关更新、Suspense retry、hydration 和 transition 交织时,单个标量不够灵活。

React 最终采用 lanes:用位掩码表示多组工作。同步输入、连续输入、默认更新、多个 transition、retry、idle、offscreen 等占据不同 bit。root 可同时拥有多条 pending lane,scheduler 选择当前最高优先级且未被阻塞的一组;某些 lane 还能 entangle,确保相关更新一起完成。

Lanes 不只是“数字换成 bit”。它允许一次 render 处理工作集合,并保留其他集合;也允许同一 Fiber 上不同优先级更新共存。代价是调度状态和队列重放逻辑显著复杂化。

Fiber 不是多线程

Fiber 常被解释为“React 自己的线程”,容易造成误解。在浏览器主线程中,用户组件 render 仍运行在 JavaScript 线程上。React 能做的是协作式切片:在工作单元之间检查时间,主动返回事件循环;高优先级更新到来时,丢弃或暂停低优先级进度。

因此:

  • 一个昂贵、不可分割的用户函数仍可能阻塞。
  • commit 中的大量 DOM/layout 工作仍可能阻塞。
  • React 不会自动并行执行两个组件函数。
  • “并发”表示多个版本的 UI 工作可以在时间上交错,而非 CPU 同时执行。

为什么必须重写,而不是在旧 reconciler 上打补丁

旧栈实现的递归结构与 lifecycle 调用顺序紧密耦合。要中断它,需要捕获 VM 栈或把每个递归函数改造成 continuation,最终仍会得到类似 Fiber 的显式状态机。与此同时,多 renderer、错误边界、fragment 和异步优先级都需要更通用的工作表示。

Fiber 的风险在于,它重写了 React 最核心、最隐蔽的部分:state 更新顺序、ref、context、错误、生命周期、DOM diff 和 server rendering 都可能受影响。项目采用长期并行实现、feature flag、Noop tests、Facebook 内部流量、beta/RC 和兼容层来降低风险。

本文判断:Fiber 的真正成就不是某项 API,而是把 React 从“同步树遍历算法”变成“可承载多种策略的工作运行时”。Suspense、Transitions、选择性 hydration、Offscreen/Activity 都是在购买这次重写创造的选择权。

到 2017 年,Fiber 已接近能够替换旧内核。但一项架构能运行测试,不等于能替换数万组件的生产系统。下一章看 React 16 如何把换心手术包装成一次看似普通的 major release,以及新内核怎样迫使 lifecycle 契约重新审视。

Engineering history, reconstructed from primary sources.