React源码学习准备工作①——什么是Fiber

引言

在 React 16 以前,React的更新方式是这样的:一次性、不可中断的深度递归更新。这种更新方式:

  • 采用调用递归树来遍历组件树
  • 根节点开始递归render
  • 中间过程不能暂停、不能打断
  • 一旦开始,就必须一次性执行到结束

这种更新方式被称作:Stack Reconciler(调用栈调和器)。 其优点是显而易见的:实现简单,逻辑清晰,理论上来说性能不会太差,小更新速度非常快,不可打断的一次性能保证渲染的一致性,包括UI的一致性和生命周期顺序的固定可靠。

但是,也正是因为这种不可打断的一次性,带来了非常致命的问题:大组件树会卡死主线程。

React 的更新逻辑是运行在 JS 主线程中的,但是 JS 主线程除了负责 React 的更新,还需要去负责:

  • UI渲染
  • 用户输入事件
  • 动画
  • 网络回调
  • 等等

如果我们需要渲染一个巨型的组件树, React 就会一次性递归计算组件树的所有节点,占用了 JS 主线程,并且中途无法暂停,从而导致 JS 堵塞,页面卡死,用户的交互事件也没有反应------因为主线程都被哪去给 React 渲染组件树了。

不仅于此,Raact 阻塞进程还会导致浏览器无法响应更高优先级的任务,在 React 16 以前,React 不会让用户输入等更高优先级的任务去插队响应,JS 线程必须等待当前组件树 render 完成,才能去响应其他任务------给用户的体验就是:页面卡顿、掉帧、输出延迟

此外,因为递归栈形式的遍历不能中断,因此也很难去保存当前执行的上下文,记录执行到哪一步从而恢复执行,这种方式完全无法实现渐进式渲染,包括分片等功能自然也无法实现。

总结:React 16 以前使用的Stack Reconciler,优点是实现简单、一次性渲染保证一致性;缺点是不能中断、不能分片、不能恢复,导致长任务会阻塞主线程,引发页面卡顿,因此被Fiber替代。

React 开发团队也正是为了实现 "可中断、可恢复"的渲染,从而引入了Fiber的概念。

Fiber 的定义

什么是 Fiber

一句话概括:Fiber 是 React 用来执行"可中断更新"的一种数据结构(FiberNode)+调度机制(Workloop)。

Fiber 既是一个轻量级数据结构(描述组件的工作单元),又是一个任务调度系统(让更新分片执行,不卡主线程)。

可以这么说,Fiber=数据结构+调度系统

Fiber 的引入使得渲染过程可以中断,并且根据需要重新调度任务,这种可中断可分片的优化使得 React 可以更好利用 JS 主线程的空闲时间,优化性能,提升用户体验。

Fiber 是一种数据结构

为什么这么说呢?我们可以看看FiberNode的源码。

tsx 复制代码
export class FiberNode {
  constructor(tag, pendinfProps, key, mode){
    this.tag = tag;
    this.key = key;
    this.elementType = null;
    this.type = null;
    this.stateNode = null;

    // 树形结构
    this.return = null;
    this.child = null;
    this.sibling = null;

    // props
    this.pendingProps = pendingProps;
    this,memoizedProps = null;

    // 状态
    this.memoizedState = null;
    this.updateQueue = null;

    // Effect 相关
    this.flags = NoFlags;
    this.subtreeFlags = NoFlags;
    this.deletions = null;

    // 调度 lane
    this.lanes = NoLanes;
    this.childLanes = NoLanes;

    // 双缓存树
    this.alternate = null;
  }
}

我们可以重点关注以下几个内容。

1. child / sibling / return

Fiber 是一个非常典型的链表结构+树结构的混合,而非一棵标准的多叉树。

tsx 复制代码
child → 第一个子节点  
sibling → 右兄弟节点  
return → 父节点  

2. alternate

Fiber 使用的是"双缓存"机制,alternate 是双缓存树的核心。

Fiber 会同时维护两棵 Fiber 树:

  • 一棵是当前页面正在用的树(current)
  • 另一棵是正在计算下一帧UI的树(workInProgress)

计算完成后,就用一次性"切换指针"的方式把新的树换成 current,渲染过程可以实现无闪烁、可中断、不卡顿。双缓存依赖 alternate 形成两棵树,alternate 相当于两棵树切换的桥梁,能让 React 同时维护 current 树和 workInProgress 树,并在它们之间随时切换,无需重新创建整棵树。

每个 Fiber 节点都有两个版本,alternate 字段去记录它们的映射关系:

tsx 复制代码
currentFiber.alternate = workInProgressFiber
workInProgressFiber.alternate = currentFiber

这种桥梁关系可以使 React 快速从 current 得到对应的 WIP,计算下一帧UI树时也不需要 new 新的树,而是直接复用已有的节点,在更新的过程中也会保存上一次的状态。

这里延伸一下:alternate 为什么可以让 Fiber 重用已有的节点?这里可以直接参考源码:

tsx 复制代码
if (current.alternate !== null) {
  // reuse
  workInProgress = current.alternate;
} else {
  // create a new fiber
}

这意味着,在 current.alternate 存在时,Fiber 会直接复用已有的节点,而不是创建一个新的Tree Node,这将大大减少性能。

这也意味着,WIP 的构建是可以随时产生随时停止的,只需要根据 current 即可随时构造,那么,在 JS 线程中,一旦有更高优先级的任务发生,WIP 可以立即被丢弃,等待更高优先级的任务完成,然后再重新算,期间不会对 current 造成任何影响,用户只能看到 current 的内容,页面体验也不会受到影响。

在 WIP 构建完成后,UI 切换并不是重新创建,而是"调换指针"。

tsx 复制代码
root.current = finishedWork;

Alternate 保证两棵树的节点是一一对应的,那么只需要切换指针,就能实现将 WIP 推到页面上。

用一句话总结:Alternate 是双缓存的核心,因为它让 React 能维持 current 和 workInProgress 两棵并行的 Fiber 树。所有的更新都在WIP上进行,最终通过 alternate 快速切换指针使页面更新。 这种方式带来了可中断、可恢复、可丢弃、低卡顿的并发渲染能力。

3. lans

React 17 内部引入 lanes 取代 expirationTime,但 lanes 的真实能力在 React 18 并发模式中才真正向开发者开放。

Lans 模型决定:

  • 哪些任务可以中断
  • 哪些任务可以合并
  • 哪些任务需要优先执行

这是并发模式的基础。

Fiber 是一种调度系统

说 Fiber 是一种调度系统,本质上是抓住了 Fiber 的设计核心:Fiber 不是一个单纯的树结构,而是一个可以拆分任务、按优先级执行、暂停和恢复的工作调度引擎。上文提到的 lans 很好地揭示了这样的一种行为。

为什么说 Fiber 是一种调度系统

1. 可中断/可恢复

React 16 以前,渲染是"递归+同步"的渲染:一旦开始渲染,就无法停止。Fiber 支持将渲染工作拆分成小单元,渲染间隙可以将 JS 线程让给优先级更高的任务,等待任务完成后再回来继续渲染。

这种机制非常像操作系统到的 CPU 调度:Fiber 是任务线程/进程,React 根据需求去调度。

2. 优先级机制

操作系统的 CPU 调度会提到优先级这个概念,根据优先级安排任务调度顺序,保证高优先级的任务优先被执行。

Fiber 也可以实现类似的优先级调度机制,支持不同优先级的任务,实现原理就是上文提到的 Lans,通过优先级调度系统,React 可以"插队"执行高优先级任务,例如在渲染过程中来了一个用户交互任务,React 可以暂停渲染先去处理交互任务,完成后再返回继续执行渲染任务。

上下文的储存也会保证不会丢失以前的渲染结果。这个优先级机制是并发渲染的基础。

3. 时间分片

Fiber 可以通过"时间分片"的思想,将大的渲染任务分解为小的任务,每一帧都只完成部分任务。这样 JS 主线程不会长时间被渲染任务占据,浏览器调度 API 在此基础上可以决定什么时候继续渲染任务,这样可以保证用户交互、动画等高优先级的任务不会被低优先级的渲染任务卡住。

4. 调度器

React 有一个单独的调度层(Scheduler),与 Fiber 结合,从而实现决定何时执行任务。这个调度层支持优先级任务、超时、挂起、恢复等,是一个真正的任务调度系统。

5. 可撤销/可放弃任务

如果正在执行的渲染任务优先级低于高优先级的用户交互任务,则当前的渲染任务可以被暂停,断点执行时机可以被推后或者放弃。渲染进度会被保存,下次可以从中断处恢复,这样,React 的渲染就不是一次性的必须全部成功或者全部失败,而是有"弹性"的。

从调度系统角度看 Fiber 的优势

一句话总结:更好地实现可中断、可恢复、低卡顿的渲染与页面交互。

  • 更好响应页面交互:高优先级的用户交互任务被优先执行,低优先级任务可以被打断,使用户的页面使用体验更流畅。
  • 提升页面性能:渲染任务被分片分时渲染,减少了 React 长任务占用 JS 主线程的时间,浏览器可以及时进行重绘/回流。
  • 支持并发特性:Fiber 是 React 并发模式的基础,调度系统为并发渲染提供了核心与基石。
  • 错误恢复能力更强:Fiber 会记住渲染的上下文,渲染任务中断或放弃不会影响现有页面与整个渲染任务,可以根据上下文恢复渲染任务。

Fiber 如何实现渲染

这里我们要研究实现渲染的关键函数。

1. 初次渲染:创建双缓存树

初次渲染时没有 alternate,于是 React 会创建一棵新的 workInProgress Fiber 树。

这里可以去看一看 Fiber 的源码 ReactFiber.js

tsx 复制代码
function createWorkInProgress(current, pendingProps) {
  let workInProgress = current.alternate;
  if (workInProgress === null) {
    // 初次渲染:current 没有 alternate,会去创建一个新的 Fiber
    workInProgress = new FiberNode(current.tag, pendingProps, current.key);
    workInProgress.stateNode = current.stateNode;
    workInProgress.alternate = current;
    current.alternate = workInProgress;
  }
  return workInProgress;
}

初次渲染之后,current 和 WIP 两棵树就建好了,彼此通过 alternate 连接,相互复用,减少了后续节点构建的压力(因为后续就复用了)。

2. 更新阶段:利用双缓存机制构建新的UI

更新流程发生在 ReactFiberWorkLoop.js 中。

核心步骤如下:

  1. 从 current 树中获取对应 Fiber;
  2. 通过 createWorkInProgress 创建或复用 WIP 树;
  3. 所有的计算、diff、effect 都在 WIP 树种进行;
  4. 完成后执行 commit 阶段;
  5. 用改变指针的方式交换两棵树的角色:WIP 变成新的 current。

① Render 阶段:构建 WIP 树

循环由 performUnitOfWork(WIP) 驱动实现。

tsx 复制代码
function performUnitOfWork(unitOfWork) {
  const current = unitOfWork.alternate;
  let next = beginWork(current, unitOfWork);
  unitOfWork.memoizedProps = unitOfWork.pendingProps;

  if (next === null) {
    completeUnitOfWork(unitOfWork);
  }
  return next;
}

渲染都在WIP上计算,如果节点有子节点,那就进入子节点继续处理,直到节点没有子节点了(叶子节点),则向上归并进入 complete 阶段。

② Commit 阶段:切换缓存树

commit 阶段有两个特点:

  1. 同步执行,不可中断
  2. 会真正地更新 DOM/UI

在 commitLayoutEffects 阶段,React 会在浏览器执行绘制(paint)之前执行所有 Layout 副作用,统一在同一帧内完成,以避免 layout thrashing(布局抖动)。

由 commitRoot 执行角色交换,交换方式是指针指向修改,commit 阶段又被拆成三个阶段。来自ReactFiberWorkLoop.js

tsx 复制代码
function commitRoot(root) {
  const finishedWork = root.finishedWork;
  root.finishedWork = null;

  // 阶段1:mutation 前
  commitBeforeMutationEffects(root, finishedWork);

  // 阶段2:mutation
  commitMutationEffects(root, finishedWork);
  root.current = finishedWork ;
  
  // 阶段3:layout 阶段
  commitLayoutEffects(root, finishedWork);
}

阶段一:mutation 前

该阶段发生在更新 DOM 前,用于为 mutation 做准备。

tsx 复制代码
function commitBeforeMutationEffects(root, firstChild) {
  let nextEffect = firstChild;
  while (nextEffect !== null) {
    const flags = nextEffect.flags;
    if (flags & Snapshot) {
      commitBeforeMutationEffectOnFiber(nextEffect);
    }
    nextEffect = nextEffect.nextEffect;
  }
}

在这个阶段,React 会遍历所有带 Snapshot 标记的 Fiber,Snapshot 标记对应 getSnapshotBeforeUpdate。

这么做的目的是让我们在 DOM 更新前拿到更新前的 DOM 的真实状态的"快照",如果在更新后再获取,就不完全准确了。

React 是一个增量更新的 Fiber 树,不是一个单纯的 DOM 树,可能会出现如下情况:

  • 多个组件有 Snapshot 副作用
  • 有些嵌套组件会产生 Snapshot
  • 某些组件的子组件没有更新,但子组件仍然需要 Snapshot

因此 React 不能只处理当前节点,它需要确保:每一个 Fiber 节点,只要在本次更新中被标记为 Snapshot,都必须在 DOM 更新前执行快照。

在这个阶段,只处理 Snapshot,不需要考虑其它flags,因为其它 flags 不是必须在 DOM 更新前执行。比如:

  • Placement(插入 DOM):mutation 阶段
  • Update(更新属性):mutation 阶段
  • Deletion(删除DOM):mutation 阶段
  • useEffect:Layout 后异步执行

总结:如果被问到 commitBeforeMutationEffects 要遍历所有 Snapshot Fiber 的原因,可以这么回答:

因为 Snapshot 对应 getSnapshotBeforeUpdate,它的语义要求"读取旧 DOM 状态"。

所以必须在 mutation 阶段(DOM 更新)之前执行。

React 通过遍历 effectList 上所有带 Snapshot 标记的 Fiber,确保所有组件在 DOM 被修改之前完成快照读取。

这保证了组件在 update 阶段能获得精准的 DOM 变化前的状态,使滚动恢复、光标位置保存、布局测量等成为可能。

阶段二:mutation

这个阶段是真正更新 DOM 的阶段,所有的 DOM 变动都在这一步完成。

tsx 复制代码
function commitMutationEffects(root, finishedWork) {
  let nextEffect = finishedWork

  while (nextEffect !== null) {
    const flags = nextEffect.flags

    // 删除节点
    if (flags & Deletion) {
      commitDeletion(root, nextEffect)
    }

    // 插入或移动节点
    if (flags & Placement) {
      commitPlacement(nextEffect)
    }

    // 更新属性或文本
    if (flags & Update) {
      commitWork(nextEffect)
    }

    nextEffect = nextEffect.nextEffect
  }
}

React 用 flags 标记 effect,常见的与 mutation 相关的 flags 有:

  • Placement:需要插入(mount)或移动节点
  • Deletion:需要删除(unmount)节点
  • Update:更新属性/文本
  • Snapshot:在 before-mutation 阶段处理(已在前面完成)
  • Ref:需要更新/清理 ref(通常在 layout 阶段也会处理)
  • Passive:useEffect(但 cleanup 在 mutation 阶段需要先执行其 cleanup,再在 layout/after 写入)

处理顺序也有先后,遵循Deletion → Placement → Update 的顺序,这样可以保证 DOM 父节点的稳定性。

阶段三:layout 阶段

在 DOM 更新后执行:

  • 类组件的 componentDidMount
  • 类组件的 componentDidUpdate
  • hook 的 useLayoutEffect create 部分
  • 调用 ref 回调
tsx 复制代码
function commitLayoutEffects(root, finishedWork) {
  let nextEffect = finishedWork;
  
  while (nextEffect !== null) {
    if (nextEffect.flags & Update) {
      commitLayoutEffectOnFiber(root, nextEffect);
    }
    nextEffect = nextEffect.nextEffect;
  }
}

主要作用就是:执行所有需要"看到最新 DOM"的副作用,从而实现同步、强制、阻塞,保证顺序一致,且能访问最新 DOM。

主流程如下:

tsx 复制代码
function commitLayoutEffects(finishedWork, root, committedLanes) {
  // ① 清理上次的 passive destroy(useEffect cleanup)
  flushSyncCallbacksOnlyInLegacyMode();

  // ② 遍历 layoutEffects
  commitLayoutEffectsOnFiber(root, finishedWork, committedLanes);
}

function commitLayoutEffectsOnFiber(root, finishedWork, lanes) {
  let nextEffect = finishedWork.firstEffect;
  while (nextEffect !== null) {
    // 执行 layout 的 work
    commitLayoutEffectOnFiber(
      root,
      nextEffect.alternate,
      nextEffect,
      lanes
    );
    nextEffect = nextEffect.nextEffect;
  }
}

其中,我们重点关注一下commitLayoutEffectsOnFiber,这个阶段是根据 flags 分类处理副作用:

tsx 复制代码
function commitLayoutEffectOnFiber(root, current, finishedWork, lanes) {
  const flags = finishedWork.flags;

  if (flags & Update) {
    // 处理 class 组件的 didUpdate 生命周期
  }

  if (flags & Callback) {
    // setState 的回调
  }

  if (flags & Ref) {
    // 安装 ref
  }

  // 重点:处理 Layout effects
  if (flags & LayoutMask) {
    commitHookEffectListMount(HookLayout, finishedWork)
  }
}

其中 LayoutMask 包括 useLayoutEffect 的销毁和创建。对于 useLayoutEffect,React会先执行 destory,再执行 create,这里的先后顺序永远不会被打破。

强调!useLayoutEffect 必须同步执行?

因为这一阶段的 DOM 已经更新,依然可以同步读取,但是还没有渲染在浏览器上,这时 useLayoutEffect 可以进行 DOM 的尺寸测量和位置计算、修改 DOM ,以及注入同步逻辑。 React 故意将 useLayoutEffect 安排在渲染浏览器之前,从而避免渲染在页面的 DOM 会出现闪烁,这是一个同步行为。

如果时会被问到这样的问题------为什么 useEffect 不在这个阶段执行?我们可以这么回答:

因为 useLayoutEffect 是同步 + 阻塞的,而 useEffect 是异步 + 不阻塞渲染的(调度到微任务/宏任务),所以 useEffect 不会在这个阶段的layout 执行,而是在下一轮事件循环执行。

以上阶段内容总结:

commitLayoutEffects 是 React commit 阶段的"布局阶段",在 DOM 已更新但浏览器尚未绘制时执行。
它同步执行 useLayoutEffect 的 destroy 与 create、类组件的 didMount/didUpdate、ref 赋值等所有需要访问最新 DOM 的副作用。
React 通过 effect list 遍历仅更新的 Fiber,保证副作用按严格顺序执行,并确保 DOM 状态一致性

补充:为什么 Fiber 是"最小可工作单元"

因为 Fiber 以前是不可中断、不可暂停的"递归调用栈",Fiber 的出现把递归变成了"可迭代的单链表结构",每个 FiberNode 本质上是一个工作单元,这就是 Fiber "可中断"的根本原因。

React Fiber 的本质就是:把组件树从递归改写成可遍历的链表,让渲染变成可拆分的工作单元,从而可以暂停、恢复、丢弃、重做。

总结

React 16 引入 Fiber,本质上是用一套"可中断、可恢复"的调度系统来替代原先不可中断的 Stack Reconciler(递归调用栈)。Fiber 既是一种轻量级的数据结构(FiberNode),又是一套完整的渲染与调度机制(WorkLoop + Scheduler)。它通过:

  • 双缓存机制
  • 单链表化的树节点FiberNode
  • 可分片与可中断的工作单元
  • 基于优先级的调度模型

让 React 能够在渲染期间暂停、恢复、放弃任务,并在合适的时机继续执行,不去长时间的占据 JS 主线程,从而避免卡顿。

Fiber 的引入彻底改变了 React 的渲染模型,使得:

  • 渲染变得可控、不再阻塞主线程
  • 更高优先级的用户交互可以"插队"
  • 更新可以分片完成(时间切片)
  • DOM 更新被拆解成 before-mutation → mutation → layout 三阶段,更可预测、更加精细

可以说:Fiber 是 React 并发特性的基础,也是 React 性能优化的核心。

React 从同步递归 → 可调度的 Fiber 架构,是一次从"函数调用"到"任务调度"的根本性转变,为后续的 Concurrent Mode、Suspense 等特性奠定了全部基础。

以上为个人在学习过程中的一些理解和感悟,部分有参考,如有不足,欢迎指正。

相关推荐
Wect2 小时前
学习React-DnD:实现多任务项拖拽-useDrop处理
前端·react.js
小肚肚肚肚肚哦8 小时前
🎮 从 NES 到现代 Web —— 像素风组件库 Pixel UI React 版本,欢迎大家一起参与这个项目
前端·vue.js·react.js
九年义务漏网鲨鱼8 小时前
【Agentic RL 专题】五、深入浅出Reasoning and Acting (ReAct)
前端·react.js·大模型·智能体
鹏多多10 小时前
React的useRef的深度解析与应用指南
前端·javascript·react.js
u***j32412 小时前
前端组件通信方式,Vue与React对比
前端·vue.js·react.js
im_AMBER12 小时前
React 18 用 State 响应输入
前端·react.js·前端框架
努力往上爬de蜗牛21 小时前
react native 实现选择图片或者拍照上传(多张)
javascript·react native·react.js
谢尔登21 小时前
【React】React组件的渲染过程分为哪几个阶段?
前端·javascript·react.js
我有一棵树1 天前
React 中 useRef 和 useState 的使用场景区别
前端·javascript·react.js