Title: 【第 3667 期】React Fiber 原理解析:从递归渲染到可中断调度 | BestBlogs.dev
URL Source: https://www.bestblogs.dev/article/ef34ebc0
Published Time: 2026-03-11 01:01:00
Markdown Content: Sanku 2026-03-11 09:01 福建
前言
React Fiber 是 React 架构中的一次关键升级,它解决了早期递归渲染无法中断、任务无法区分优先级的问题。通过将渲染过程拆分为可调度的小任务,并引入 Fiber 数据结构和调度机制,React 可以在渲染过程中暂停、恢复并优先处理用户交互,从而提升界面响应速度。本文将从 JavaScript 调用栈的限制出发,解释 React 为什么需要 Fiber,以及它是如何实现可中断渲染与时间切片的。
今日前端早读课文章由@Sanku分享,@飘飘编译。
译文从这开始~~
> 理解 React Fiber 为什么存在,以及它如何让 React 能够暂停任务、设置优先级
不久前,我在刷 Twitter 时看到了一条帖子。
> @infinterenders: > > react fiber 其实就是那种“很多人以为自己懂了”的东西——直到真正去看底层实现的时候,突然就会觉得这像是某种生活在 JavaScript 里的外星技术。大家经常重复那些很表面的说法,比如“fiber 是新的 diff 算法”。
这是一条非常不错的帖子。它总结了很多关于 React 的内容。在帖子里他提到了一段话,让我开始认真思考,也激起了我进一步了解 React 的兴趣。
> React 终于摆脱了 JavaScript 调用栈的束缚。它不再采用递归渲染,而是通过逐个遍历 Fiber 来实现迭代。这突然意味着 React 可以暂停。就像在渲染中途直接停止,放下一切,让浏览器重新绘制,处理输入,放松一下,喘口气,然后从巨大的 UI 树中中断的地方继续。再也不用担心“哎呀,我深陷递归无法自拔”。因为 React 不再使用内置的调用栈,而是自行实现。
“React 终于摆脱了 JS 调用栈的限制”——嗯?
这点很有意思,因为问题在于:JavaScript 的调用栈到底有什么问题,以至于 React 必须摆脱它? 【第3659期】JavaScript 显式资源管理来了:用 using 告别手写 try/finally
要理解解决方案,我们必须先理解问题:到底是什么限制了 React 的性能?React 团队又提出了怎样的解决思路?
#### 单线程的故事
我们都知道,JavaScript 是一种单线程编程语言。在每个执行上下文中,只有一个线程和一个调用栈,用于逐行处理代码。它从上到下开始处理。
function doSomethingHeavy() { for (let i = 0; i < 1_000_000_000; i++) {} // 阻塞线程 console.log("Finished heavy task");}doSomethingHeavy();do_important_task(); // 重要任务
从这个例子可以看到,一旦开始执行 doSomethingHeavy 这个任务,它就会一直运行直到结束。假设下面还有一个更重要的任务,那么这个重要任务只有在 doSomethingHeavy 的循环执行完之后才会开始。
这就是一个典型的同步程序。JavaScript 当然也支持异步编程,你很可能使用过 async/await。这些机制是通过 事件循环(event loop) 来实现的。 【第2344期】Javascript是如何工作的:事件循环及异步编程的出现和 5 种更好的 async/await 编程方式
你可能会说,如果我们希望 do_important_task 先执行,只需要把doSomethingHeavy()和do_important_task()的顺序交换即可。确实,在这个例子里我们知道哪个任务更重要,因为函数是我们自己写的,我们可以决定执行顺序。
但如果情况是 不确定的(nondeterministic) 呢?
假设程序正在执行一个非常耗时的任务时,突然出现了一个更加紧急的任务。问题是:你无法在程序执行到一半时强行中断它去处理新的任务。你也无法突然清空当前调用栈,然后立刻去处理别的事情。
来看一个示例:
App.js
import { useState } from "react";export default function BlockingDemo() {const [count, setCount] = useState(0);const [isBlocked, setIsBlocked] = useState(false);const blockThread = () => { setIsBlocked(true); setTimeout(() => { const start = Date.now(); let dummy = 0; // 阻塞线程 3 秒 while (Date.now() - start < 3000) { dummy += Math.random(); } setIsBlocked(false); }, 0);};return ( <div className="container"> <div className="count">{count}</div> <div className="buttons"> <button onClick={blockThread}> {isBlocked ? "Processing.." : "Start Work"} </button> <button onClick={() => setCount(count + 1)}> Click +1 </button> </div> </div>);}
!Image 2
可以试一下这个例子:先点击 Start Work,然后在这 3 秒期间点击Click +1。由于 Start Work 会让线程阻塞 3 秒,即使你点击了Click +1,计数也不会立刻增加,而是必须等 3 秒结束后才会更新。
这种体验会让界面显得非常卡顿,用户体验也会很差。
#### React 15
在 React 15 中,reconciler(协调器)是通过递归函数调用来遍历组件树的。一旦渲染过程开始,就无法中途停止。
【第3656期】深入理解 React 的 useEffectEvent:彻底告别闭包陷阱
function updateComponent(component) { const children = component.render(); children.forEach(child => { updateComponent(child); // ← 递归调用 });}
每调用一次updateComponent,都会在 JavaScript 的调用栈(call stack)上压入一个新的栈帧。如果组件树有 1000 个组件,那么调用栈里就会有 1000 层嵌套的函数调用。
假设页面上有一个输入框,用户在输入框里输入的内容会实时显示在页面上。用户输入第一个字符s时,React 会开始渲染流程,不断调用updateComponent。
由于是递归调用,调用栈会不断堆积函数调用。但如果在渲染进行到一半时,用户又输入了一个字符a,问题就出现了:React 无法停下来。它不能说:“稍等,用户又输入了新的内容,我重新开始渲染。”
JavaScript 本身没有机制可以暂停调用栈、保存当前状态、然后稍后再恢复执行。因此 React 必须先完成对s的整个处理流程,之后才会意识到用户又输入了a。
每一次按键都会触发一次完整的 reconciliation(协调过程)。而在每次执行时,React 都被困在递归调用中,这时新的输入事件只能不断排队等待。
这就是为什么早期的 React 应用在某些情况下会显得卡顿(laggy)。
#### 优先级问题
还有第二个问题:React 过去会把所有更新都当作同等重要的任务处理。
例如:
* 按钮点击
* 后台数据请求
* 动画更新
* 日志记录
这些更新在 React 看来优先级是一样的。
但实际上并不是这样。有些更新是非常紧急的(比如用户输入、点击),而有些更新是可以稍后再处理的(例如统计分析、数据预取)。然而 React 以前无法表达这种优先级差异,因为一旦 reconciliation 开始,就必须一直执行到结束。所有任务互相阻塞,优先级完全一样。 【第3654期】不再重置!在 React / Next.js 中实现跨页面“持续进化”的动画效果
举个例子:假设你从服务器获取了一份数据,比如 500 个商品列表。数据返回后,React 开始把这 500 个商品渲染到页面上。当渲染到一半(比如已经渲染了 250 个商品)时,用户在搜索框里输入了一个字母。
这时 React 应该怎么做?
正确的行为应该是:先停止渲染商品列表,优先处理用户输入,立刻更新输入框。
因为用户最关心的是:输入的内容是否立即出现在屏幕上。
商品列表的更新其实可以稍微延迟一点。比如搜索结果晚 100ms 出现,用户几乎不会察觉。
但如果用户输入的字符 100ms 后才显示出来,界面就会显得很卡,甚至像坏了一样。
#### 我们需要什么样的解决方案
在寻找解决方案之前,我们需要明确问题是什么。
使用递归 reconciliation 的模式存在两个主要问题:
* 1、渲染过程无法在中途暂停(因为调用栈不可中断)
* 2、所有更新都被当作同样优先级的任务
因此理想的解决方案应该具备以下能力:
* 可以在渲染过程中暂停
* 当出现更高优先级任务时,先处理高优先级任务
* 然后从刚才暂停的位置继续执行
为什么必须在调用栈层面暂停?
原因在于:浏览器的事件机制就是这样工作的。
只有当调用栈为空时,浏览器才会把宏任务队列(macro task queue)中的事件(例如点击、键盘输入)推入调用栈执行。
换句话说,浏览器就像是在说:“先把你手里的工作做完,然后我再把新的任务交给你。”
如果 React 的递归函数执行时间太长,调用栈一直不清空,那么浏览器就无法把新的点击或输入事件放进调用栈。
这就是界面卡顿的根本原因。
#### 解决方案
React 团队最终意识到,问题不仅仅在于 reconciliation 算法本身,更在于这个算法的执行方式。
简单的性能优化无法解决问题,因为 JavaScript 中的递归本质上是不可中断的。
与其试图让 JavaScript 的调用栈做一些它本来就不擅长的事情,React 团队决定彻底放弃递归模型。
他们不再让 JavaScript 引擎直接驱动整个 reconciliation 过程,而是在它之上构建了一层新的抽象。
这个抽象就是 Fiber。 【第3262期】React的Fiber架构原理
Fiber 并不能控制调用栈——事实上,没有任何东西可以控制它,因为调用栈是 JavaScript 语言本身的一部分。
Fiber 能控制的是:渲染工作的组织方式和执行方式。
以前的方式是:一次性递归完成整个渲染过程。
现在 React 会把整个 reconciliation 过程拆分成很多小的工作单元(units of work)。
执行流程变成:
* 1、React 处理一个小任务
* 2、把控制权交还给浏览器
* 3、调用栈清空
* 4、浏览器处理用户事件(点击、输入、重绘等)
* 5、React 再回来继续处理下一个任务
整个过程会被分成很多很短的时间片(time slices)。
大致流程是:
* React 工作大约~5ms
* 然后主动让出控制权
* 浏览器有机会处理其它任务
* 接着 React 再继续之前的工作
旧版的 Scheduler(大约 React 18 之前的一些实现)在某些路径上使用了固定约 5ms 的时间片策略。而现代 React 的 scheduler 包是基于帧(frame)并且动态调整的。
它会尝试在下一帧截止时间之前完成任务:
* 60fps:约16.7ms
* 120fps:约8.3ms
只有在检测到更高优先级事件或认为安全时,React 才会提前让出控制权。
#### Fiber 作为一种数据结构
React 放弃了递归模型。但如果不再使用调用栈来控制流程,就必须有别的方式来记录当前在组件树中的执行位置。
React 需要一种自己可控的数据结构:它可以在这个结构中遍历、在中途暂停,并且稍后再继续执行。这个结构还必须足够灵活。
这就是 Fiber。
实际上,Fiber 在不同语境下既可以指一种数据结构,也可以指一套算法。
当你编写 JSX 组件时,React 并不会直接把它们渲染到页面上,而是先在内存中构建一棵完整的 UI 树表示。
const fiber ={type:'div',// 这个节点是什么类型child: h1Fiber,// 指向第一个子节点sibling: buttonFiber,// 指向下一个兄弟节点 return: AppFiber,// 指向父节点// ... 还有 React 需要的很多其他信息};
每一个 fiber 都代表 UI 的一个节点:可能是一个组件、一个 div、一个 button 等等。
注意 fiber 对象里的三个属性:child、sibling、return
这些其实都是指针,用于把不同的 fiber 连接起来,形成类似链表结构的树形关系。
关键点在于:React 完全掌控这个数据结构。它只是内存中的普通对象,因此 React 可以:
* 按任何方式遍历它
* 在任何位置暂停
* 在任何位置继续执行
这就是 Fiber 的核心意义。
#### Fiber 的遍历方式
##### 步骤 1:移动到子节点
当 React 完成某个 fiber 的工作后,首先会检查这个 fiber 是否有 child。
如果有,那么这个 child fiber 就会成为下一个需要处理的工作单元。
例如在示例中,当 React 完成 form 这个 fiber 的处理后,就会移动到它的子节点。
##### 步骤 2:移动到兄弟节点
如果当前 fiber 没有子节点,React 就会查看是否存在 sibling。
在示例中,input 这个 fiber 没有子节点,于是 React 会移动到它的 sibling。
同样的逻辑会继续发生:遍历 label,然后向下到 span,再到 a。
##### 步骤 3:向上寻找新的工作
如果一个 fiber 既没有子节点也没有兄弟节点,React 就会通过return指针向上回到父节点。
此时 React 会寻找最近一个还存在未访问 sibling 的父节点。
在示例中,当 React 走到某个既没有 child 也没有 sibling 的节点时,它就会回到 form 这个 fiber。
如果父节点也没有 sibling,React 就会继续向上移动,直到找到有 sibling 的节点,或者到达根节点。
##### 步骤 4:到达根节点
遍历会一直持续,直到 React 回到 root fiber,并且没有任何 fiber 需要继续处理。
此时,render 阶段就完成了。
#### 时间切片(Time Slicing)是如何工作的
假设你在输入框里输入内容,这会触发 500 条搜索结果的重新渲染。
过程大致是这样的:浏览器会给 React 大约 5 毫秒的执行时间。时间非常短。
React 开始遍历 fiber 树:
* 先处理第一个 fiber,比如输入框组件
* 更新它
* 然后通过child指针移动到下一个 fiber
* 继续处理
* 再移动到下一个
就这样不断向前。
当处理了几个 fiber(可能是 10 个或 15 个)后,React 会检查时间:“我已经工作 5ms 了吗?”
如果答案是 是的,React 就会停止执行。
它不会把整棵树一次性处理完,而是:
* 停下来
* 记录当前处理到哪个 fiber
* 退出函数
* 清空调用栈
这时浏览器重新获得控制权。
浏览器可以:
* 处理新的键盘输入
* 处理滚动事件
* 处理点击
* 重新绘制页面
当浏览器准备好后,会再次调用 React:“你可以再执行 5ms 了。”
React 会拿出刚才保存的指针:“好的,我刚刚处理到 fiber#15。”
然后从那里继续往下处理。
整个过程会不断重复:
* React 工作 → 浏览器处理事件
* React 工作 → 浏览器处理事件
两者不断配合,直到 500 个 fiber 全部处理完成。
因为浏览器可以在 5ms 内响应每一次按键,所以你的输入看起来是即时响应的。而 React 的渲染则在后台分批完成,不会阻塞界面。
#### 总结
这基本就是 Fiber 的核心思想。
在网上的一些讨论中,经常有人问:为什么 React 要使用 Fiber 架构?像 Vue、SolidJS 等框架使用的是更偏 响应式(reactive) 的方式。
确实,像 Vue 或 SolidJS 这样的框架采用的是 响应式信号(signals)或更细粒度的依赖追踪,只更新真正发生变化的部分。在某些场景下,这种方式会更快。
但这种方式也有自己的权衡。
React 的设计重点在于:
* 灵活性
* 可预测性
* 向后兼容
在 React 中:组件仍然是 state 的纯函数映射,开发者的心智模型保持简单,而 Fiber 会在幕后负责复杂的调度工作。
归根结底,React 依然坚持它最核心的理念:
函数就是 UI 在某个状态下的表现形式。
关于本文
译者:@飘飘
作者:@Sanku
原文:https://inside-react.vercel.app/blog/understanding-why-react-fiber-exists
这期前端早读课
对你有帮助,帮”赞“一下,