写一篇关于 React Fiber 的文章, 这个 Flag 立了好久,这也是今年的目标之一。 最近的在掘金的文章得到不少关注和鼓励,给了我不少动力,因此下定决心好好把它写出来。 我会以最通俗的方式将它讲透, 所以这算是一篇科普式的文章。无论你是使用React、仍是Vue,这里面的思想值得学习学习!javascript
一年一度的 React 春晚: React Conf 即将到来,不知道今年会不会有什么惊喜,去年是 React Hooks,前年是 React Fiber... 我得赶在 React Conf 以前发布这篇文章:html
😲 React Fiber 已经出来这么久了, 这文章是老酒装新瓶吧? 对于我来讲,经过这篇文章我从新认识了 React Fiber,它不是一个新东西, 它也是老酒装新瓶,不信你就看吧...前端
🆕 React Fiber 不是一个新的东西,但在前端领域是第一次广为认知的应用。vue
😦 了解它有啥用? React Fiber 代码很复杂,门槛很高,你不了解它,后面 React 新出的 Killer Feature 你可能就更不能理解了java
🤥 我不是升到React v16了吗? 没什么出奇的啊? 真正要体会到 React Fiber 重构效果,可能下个月、可能要等到 v17。v16 只是一个过渡版本,也就是说,如今的React 仍是同步渲染的,一直在跳票、不是说今年第二季度就出来了吗?react
😁 很差意思,一不当心又写得有点长,你就当小说看吧, 代码都是伪代码git
如下文章大纲github
DOS
操做系统
微软 DOS
是一个单任务操做系统
, 也称为’单工操做系统‘. 这种操做系统同一个时间只容许运行一个程序. invalid s在《在没有GUI的时代(只有一个文本界面),人们是怎么运行多个程序的?》 的回答中将其称为: '一种压根没有任务调度的“残疾”操做系统'.golang
在这种系统中,你想执行多个任务,只能等待前一个进程退出,而后再载入一个新的进程。web
直到 Windows 3.x,它才有了真正意义的进程调度器,实现了多进程并发执行。
注意并发和并行不是同一个概念。
现代操做系统都是多任务操做系统. 进程的调度策略若是按照CPU核心数来划分,能够分为单处理器调度和多处理器调度。本文只关注的是单处理器调度,由于它能够类比JavaScript的运行机制。
🔴说白了,为了实现进程的并发,操做系统会按照必定的调度策略,将CPU的执行权分配给多个进程,多个进程都有被执行的机会,让它们交替执行,造成一种“同时在运行”假象, 由于CPU速度太快,人类根本感受不到。实际上在单核的物理环境下同时只能有一个程序在运行。
这让我想起了“龙珠”中的分身术(小时候看过,说错了别喷),实质上是一我的,只不过是他运动速度太快,看起来就像分身了. 这就是所谓的并发(Concurrent)(单处理器)。
相比而言, 火影忍者中的分身术,是物理存在的,他们能够真正实现同时处理多个任务,这就是并行(严格地讲这是Master-Slave
架构,分身虽然物理存在,但应该没有独立的意志)。
因此说🔴并行能够是并发,而并发不必定是并行,两种不能划等号, 并行通常须要物理层面的支持。 关于并发和并行,Go 之父 Rob Pike 有一个很是著名的演讲Concurrency is not parallelism
扯远了,接下来进程怎么调度就是教科书的内容了。若是读者在大学认真学过操做系统原理, 你能够很快理解如下几种单处理器进程调度策略(我就随便科普一下,算送的, 若是你很熟悉这块,能够跳过):
0️⃣ 先到先得(First-Come-First-Served, FCFS)
这是最简单的调度策略, 简单说就是没有调度。谁先来谁就先执行,执行完毕后就执行下一个。不过若是中间某些进程由于I/O阻塞了,这些进程会挂起移回就绪队列(说白了就是从新排队).
FCFS
上面 DOS
的单任务操做系统没有太大的区别。因此很是好理解,由于生活中处处是这样的例子:。
FCFS 对短进程
不利。 短进程即执行时间很是短的进程,能够用饭堂排队来比喻: 在饭堂排队打饭的时候,最烦那些一我的打包好好几份的人,这些人就像长进程
同样,霸占着CPU资源,后面排队只打一份的人会以为很吃亏,打一份的人会以为他们优先级应该更高,毕竟他们花的时间很短,反正你打包那么多份再等一会也是能够的,何须让后面那么多人等这么久...
FCFS 对I/O密集
不利。I/O密集型进程(这里特指同步I/O)在进行I/O操做时,会阻塞休眠,这会致使进程从新被放入就绪队列,等待下一次被宠幸。 能够类比ZF部门办业务: 假设 CPU 一个窗口、I/O 一个窗口。在CPU窗口好不容易排到你了,这时候发现一个不符合条件或者漏办了, 须要去I/O搞一下,Ok 去 I/O窗口排队,I/O执行完了,到CPU窗口又得从新排队。对于这些丢三落四的人很不公平...
因此 FCFS 这种原始的策略在单处理器进程调度中并不受欢迎。
1️⃣ 轮转
这是一种基于时钟的抢占策略,这也是抢占策略中最简单的一种: 公平地给每个进程必定的执行时间,当时间消耗完毕或阻塞,操做系统就会调度其余进程,将执行权抢占过来。
决策模式:
抢占策略
相对应的有非抢占策略
,非抢占策略指的是让进程运行直到结束、阻塞(如I/O或睡眠)、或者主动让出控制权;抢占策略支持中断正在运行的进程,将主动权掌握在操做系统这里,不过一般开销会比较大。
这种调度策略的要点是肯定合适的时间片长度: 太长了,长进程霸占过久资源,其余进程会得不到响应(等待执行时间过长),这时候就跟上述的 FCFS
没什么区别了; 过短了也很差,由于进程抢占和切换都是须要成本的, 并且成本不低,时间片过短,时间可能都浪费在上下文切换上了,致使进程干不了什么实事。
所以时间片的长度最好符合大部分进程完成一次典型交互所需的时间.
轮转策略很是容易理解,只不过肯定时间片长度须要伤点脑筋;另外和FCFS
同样,轮转策略对I/O进程仍是不公平。
2️⃣ 最短进程优先(Shortest Process Next, SPN)
上面说了先到先得
策略对短进程
不公平,最短进程优先
索性就让'最短'的进程优先执行,也就是说: 按照进程的预估执行时间对进程进行优先级排序,先执行完短进程,后执行长进程。这是一种非抢占策略。
这样可让短进程能获得较快的响应。可是怎么获取或者评估进程执行时间呢?一是让程序的提供者提供,这不太靠谱;二是由操做系统来收集进程运行数据,并对它们进程统计分析。例如最简单的是计算它们的平均运行时间。无论怎么说都比上面两种策略要复杂一点。
SPN
的缺陷是: 若是系统有大量的短进程,那么长进程可能会饥饿得不到响应。
另外由于它不是抢占性策略, 尽管如今短进程能够获得更多的执行机会,可是仍是没有解决 FCFS
的问题: 一旦长进程获得CPU资源,得等它执行完,致使后面的进程得不到响应。
3️⃣ 最短剩余时间(Shortest Remaining Time, SRT)
SRT 进一步优化了SPN,增长了抢占机制。在 SPN 的基础上,当一个进程添加到就绪队列时,操做系统会比较刚添加的新进程和当前正在执行的老进程的‘剩余时间’,若是新进程剩余时间更短,新进程就会抢占老进程。
相比轮转的抢占,SRT 没有中断处理的开销。可是在 SPN 的基础上,操做系统须要记录进程的历史执行时间,这是新增的开销。另外长进程饥饿问题仍是没有解决。
4️⃣ 最高响应比优先(HRRN)
为了解决长进程饥饿问题,同时提升进程的响应速率。还有一种最高响应比优先的
策略,首先了解什么是响应比:
响应比 = (等待执行时间 + 进程执行时间) / 进程执行时间
复制代码
这种策略会选择响应比最高的进程优先执行:
5️⃣ 反馈法
SPN、SRT、HRRN都须要对进程时间进行评估和统计,实现比较复杂且须要必定开销。而反馈法采起的是过后反馈的方式。这种策略下: 每一个进程一开始都有相同的优先级,每次被抢占(须要配合其余抢占策略使用,如轮转),优先级就会下降一级。所以一般它会根据优先级划分多个队列。
举个例子:
队列1
队列2
...
队列N
复制代码
新增的任务会推入队列1
,队列1
会按照轮转策略
以一个时间片为单位进行调度。短进程能够很快获得响应,而对于长进程可能一个时间片处理不完,就会被抢占,放入队列2
。
队列2
会在队列1
任务清空后被执行,有时候低优先级队列可能会等待好久才被执行,因此通常会给予必定的补偿,例如增长执行时间,因此队列2
的轮转时间片长度是2。
反馈法仍然可能致使长进程饥饿,因此操做系统能够统计长进程的等待时间,当等待时间超过必定的阈值,能够选择提升它们的优先级。
没有一种调度策略是万能的, 它须要考虑不少因素:
这二者在某些状况下是对立的,提升了响应,可能会减低公平性,致使饥饿。短进程、长进程、I/O进程之间要取得平衡也很是难。
上面这些知识对本文来讲已经足够了,现实世界操做系统的进程调度算法比教科书上说的要复杂的多,有兴趣读者能够去研究一下 Linux
相关的进程调度算法,这方面的资料也很是多, 例如《Linux进程调度策略的发展和演变》。
JavaScript 是单线程运行的,并且在浏览器环境屁事很是多,它要负责页面的JS解析和执行、绘制、事件处理、静态资源加载和处理, 这些任务能够类比上面’进程‘。
这里特指Javascript 引擎是单线程运行的。 严格来讲,Javascript 引擎和页面渲染引擎在同一个
渲染线程
,GUI 渲染和 Javascript执行 二者是互斥的. 另外异步 I/O 操做底层实际上多是多线程的在驱动。
它只是一个'JavaScript',同时只能作一件事情,这个和 DOS
的单任务操做系统同样的,事情只能一件一件的干。要是前面有一个傻叉任务长期霸占CPU,后面什么事情都干不了,浏览器会呈现卡死的状态,这样的用户体验就会很是差。
对于’前端框架‘来讲,解决这种问题有三个方向:
Vue 选择的是第1️⃣, 由于对于Vue来讲,使用模板
让它有了不少优化的空间,配合响应式机制可让Vue能够精确地进行节点更新, 读者能够去看一下今年Vue Conf 尤雨溪的演讲,很是棒!;而 React 选择了2️⃣ 。对于Worker 多线程渲染方案也有人尝试,要保证状态和视图的一致性至关麻烦。
React 为何要引入 Fiber 架构? 看看下面的火焰图,这是React V15 下面的一个列表渲染资源消耗状况。整个渲染花费了130ms, 🔴在这里面 React 会递归比对VirtualDOM树,找出须要变更的节点,而后同步更新它们, 一鼓作气。这个过程 React 称为 Reconcilation
(中文能够译为协调
).
在 Reconcilation 期间,React 会霸占着浏览器资源,一则会致使用户触发的事件得不到响应, 二则会致使掉帧,用户能够感知到这些卡顿。
这样说,你可能没办法体会到,经过下面两个图片来体会一下(图片来源于:Dan Abramov 的 Beyond React 16 演讲, 推荐看一下👍. 另外很是感谢淡苍 将一个相似的DEMO 分享在了 CodeSandbox上🎉,你们自行体验):
同步模式下的 React:
优化后的 Concurrent
模式下的 React:
React 的 Reconcilation 是CPU密集型的操做, 它就至关于咱们上面说的’长进程‘。因此初衷和进程调度同样,咱们要让高优先级的进程或者短进程优先运行,不能让长进程长期霸占资源。
因此React 是怎么优化的? 划重点, 🔴为了给用户制造一种应用很快的'假象',咱们不能让一个程序长期霸占着资源. 你能够将浏览器的渲染、布局、绘制、资源加载(例如HTML解析)、事件响应、脚本执行视做操做系统的'进程',咱们须要经过某些调度策略合理地分配CPU资源,从而提升浏览器的用户响应速率, 同时兼顾任务执行效率。
🔴因此 React 经过Fiber 架构,让本身的Reconcilation 过程变成可被中断。 '适时'地让出CPU执行权,除了可让浏览器及时地响应用户的交互,还有其余好处:
这就是为何React 须要 Fiber 😏。
对于 React 来讲,Fiber 能够从两个角度理解:
Fiber 也称协程、或者纤程。 笔者第一次接触这个概念是在学习 Ruby 的时候,Ruby就将协程称为 Fiber。后来发现不少语言都有相似的机制,例如Lua 的Coroutine
, 还有前端开发者比较熟悉的 ES6
新增的Generator
。
本文不纠结 Processes, threads, green threads, protothreads, fibers, coroutines: what's the difference?
🔴 其实协程和线程并不同,协程自己是没有并发或者并行能力的(须要配合线程),它只是一种控制流程的让出机制。要理解协程,你得和普通函数一块儿来看, 以Generator为例:
普通函数执行的过程当中没法被中断和恢复:
const tasks = []
function run() {
let task
while (task = tasks.shift()) {
execute(task)
}
}
复制代码
而 Generator
能够:
const tasks = []
function * run() {
let task
while (task = tasks.shift()) {
// 🔴 判断是否有高优先级事件须要处理, 有的话让出控制权
if (hasHighPriorityEvent()) {
yield
}
// 处理完高优先级事件后,恢复函数调用栈,继续执行...
execute(task)
}
}
复制代码
React Fiber 的思想和协程的概念是契合的: 🔴React 渲染的过程能够被中断,能够将控制权交回浏览器,让位给高优先级的任务,浏览器空闲后再恢复渲染。
那么如今你应该有如下疑问:
答1️⃣: 没错, 主动让出机制
一是浏览器中没有相似进程的概念,’任务‘之间的界限很模糊,没有上下文,因此不具有中断/恢复的条件。二是没有抢占的机制,咱们没法中断一个正在执行的程序。
因此咱们只能采用相似协程这样控制权让出机制。这个和上文提到的进程调度策略都不一样,它有更一个专业的名词:合做式调度(Cooperative Scheduling), 相对应的有抢占式调度(Preemptive Scheduling)
这是一种’契约‘调度,要求咱们的程序和浏览器紧密结合,互相信任。好比能够由浏览器给咱们分配执行时间片(经过requestIdleCallback
实现, 下文会介绍),咱们要按照约定在这个时间内执行完毕,并将控制权还给浏览器。
这种调度方式颇有趣,你会发现这是一种身份的对调,之前咱们是老子,想怎么执行就怎么执行,执行多久就执行多久; 如今为了咱们共同的用户体验统一了战线, 一切听由浏览器指挥调度,浏览器是老子,咱们要跟浏览器申请执行权,并且这个执行权有期限,借了后要按照约定归还给浏览器。
固然你超时不还浏览器也拿你没办法 🤷... 合做式调度的缺点就在于此,全凭自律,用户要挖大坑,谁都拦不住。
答2️⃣: requestIdleCallback API
上面代码示例中的 hasHighPriorityEvent()
在目前浏览器中是没法实现的,咱们没办法判断当前是否有更高优先级的任务等待被执行。
只能换一种思路,经过超时检查的机制来让出控制权。解决办法是: 肯定一个合理的运行时长,而后在合适的检查点检测是否超时(好比每执行一个小任务),若是超时就中止执行,将控制权交换给浏览器。
举个例子,为了让视图流畅地运行,能够按照人类能感知到最低限度每秒60帧的频率划分时间片,这样每一个时间片就是 16ms。
其实浏览器提供了相关的接口 —— requestIdleCallback
API:
window.requestIdleCallback(
callback: (dealine: IdleDeadline) => void,
option?: {timeout: number}
)
复制代码
IdleDeadline
的接口以下:
interface IdleDealine {
didTimeout: boolean // 表示任务执行是否超过约定时间
timeRemaining(): DOMHighResTimeStamp // 任务可供执行的剩余时间
}
复制代码
单从名字上理解的话, requestIdleCallback
的意思是让浏览器在'有空'的时候就执行咱们的回调,这个回调会传入一个期限,表示浏览器有多少时间供咱们执行, 为了避免耽误事,咱们最好在这个时间范围内执行完毕。
那浏览器何时有空?
咱们先来看一下浏览器在一帧(Frame,能够认为事件循环的一次循环)内可能会作什么事情:
浏览器在一帧内可能会作执行下列任务,并且它们的执行顺序基本是固定的:
上面说理想的一帧时间是 16ms
(1000ms / 60),若是浏览器处理完上述的任务(布局和绘制以后),还有盈余时间,浏览器就会调用 requestIdleCallback
的回调。例如
可是在浏览器繁忙的时候,可能不会有盈余时间,这时候requestIdleCallback
回调可能就不会被执行。 为了不饿死,能够经过requestIdleCallback的第二个参数指定一个超时时间。
另外不建议在
requestIdleCallback
中进行DOM
操做,由于这可能致使样式从新计算或从新布局(好比操做DOM后立刻调用getBoundingClientRect
),这些时间很难预估的,颇有可能致使回调执行超时,从而掉帧。
目前 requestIdleCallback
目前只有Chrome支持。因此目前 React 本身实现了一个。它利用MessageChannel
模拟将回调延迟到'绘制操做'以后执行:
const el = document.getElementById('root')
const btn = document.getElementById('btn')
const ch = new MessageChannel()
let pendingCallback
let startTime
let timeout
ch.port2.onmessage = function work() {
// 在绘制以后被执行
if (pendingCallback) {
const now = performance.now()
// 经过now - startTime能够计算出requestAnimationFrame到绘制结束的执行时间
// 经过这些数据来计算剩余时间
// 另外还要处理超时(timeout),避免任务被饿死
// ...
if (hasRemain && noTimeout) {
pendingCallback(deadline)
}
}
}
// ...
function simpleRequestIdleCallback(callback, timeout) {
requestAnimationFrame(function animation() {
// 在绘制以前被执行
// 记录开始时间
startTime = performance.now()
timeout = timeout
dosomething()
// 调度回调到绘制结束后执行
pendingCallback = callback
ch.port1.postMessage('hello')
})
}
复制代码
任务优先级
上面说了,为了不任务被饿死,能够设置一个超时时间. 这个超时时间不是死的,低优先级的能够慢慢等待, 高优先级的任务应该率先被执行. 目前 React 预约义了 5 个优先级, 这个我在[《谈谈React事件机制和将来(react-events)》]中也介绍过:
Immediate
(-1) - 这个优先级的任务会同步执行, 或者说要立刻执行且不能中断UserBlocking
(250ms) 这些任务通常是用户交互的结果, 须要即时获得反馈Normal
(5s) 应对哪些不须要当即感觉到的任务,例如网络请求Low
(10s) 这些任务能够放后,可是最终应该获得执行. 例如分析通知Idle
(没有超时时间) 一些没有必要作的任务 (e.g. 好比隐藏的内容), 可能会被饿死答3️⃣: 太麻烦
官方在《Fiber Principles: Contributing To Fiber》 也做出了解答。主要有两个缘由:
上面理解可能有出入,建议看一下原文
可能都没看懂,简单就是 React 尝试过用 Generator 实现,后来发现很麻烦,就放弃了。
Fiber的另一种解读是’纤维‘: 这是一种数据结构或者说执行单元。咱们暂且无论这个数据结构长什么样,🔴将它视做一个执行单元,每次执行完一个'执行单元', React 就会检查如今还剩多少时间,若是没有时间就将控制权让出去.
上文说了,React 没有使用 Generator 这些语言/语法层面的让出机制,而是实现了本身的调度让出机制。这个机制就是基于’Fiber‘这个执行单元的,它的过程以下:
假设用户调用 setState
更新组件, 这个待更新的任务会先放入队列中, 而后经过 requestIdleCallback
请求浏览器调度:
updateQueue.push(updateTask);
requestIdleCallback(performWork, {timeout});
复制代码
如今浏览器有空闲或者超时了就会调用performWork
来执行任务:
// 1️⃣ performWork 会拿到一个Deadline,表示剩余时间
function performWork(deadline) {
// 2️⃣ 循环取出updateQueue中的任务
while (updateQueue.length > 0 && deadline.timeRemaining() > ENOUGH_TIME) {
workLoop(deadline);
}
// 3️⃣ 若是在本次执行中,未能将全部任务执行完毕,那就再请求浏览器调度
if (updateQueue.length > 0) {
requestIdleCallback(performWork);
}
}
复制代码
workLoop
的工做大概猜到了,它会从更新队列(updateQueue)中弹出更新任务来执行,每执行完一个‘执行单元
‘,就检查一下剩余时间是否充足,若是充足就进行执行下一个执行单元
,反之则中止执行,保存现场,等下一次有执行权时恢复:
// 保存当前的处理现场
let nextUnitOfWork: Fiber | undefined // 保存下一个须要处理的工做单元
let topWork: Fiber | undefined // 保存第一个工做单元
function workLoop(deadline: IdleDeadline) {
// updateQueue中获取下一个或者恢复上一次中断的执行单元
if (nextUnitOfWork == null) {
nextUnitOfWork = topWork = getNextUnitOfWork();
}
// 🔴 每执行完一个执行单元,检查一次剩余时间
// 若是被中断,下一次执行仍是从 nextUnitOfWork 开始处理
while (nextUnitOfWork && deadline.timeRemaining() > ENOUGH_TIME) {
// 下文咱们再看performUnitOfWork
nextUnitOfWork = performUnitOfWork(nextUnitOfWork, topWork);
}
// 提交工做,下文会介绍
if (pendingCommit) {
commitAllWork(pendingCommit);
}
}
复制代码
画个流程图吧!
Fiber 的核心内容已经介绍完了,如今来进一步看看React 为 Fiber 架构作了哪些改造, 若是你对这部份内容不感兴趣能够跳过。
上文中提到 React 16 以前,Reconcilation 是同步的、递归执行的。也就是说这是基于函数’调用栈‘的Reconcilation算法,所以一般也称它为Stack Reconcilation
. 你能够经过这篇文章《从Preact中了解React组件和hooks基本原理》 来回顾一下历史。
栈挺好的,代码量少,递归容易理解, 至少比如今的 React Fiber架构好理解😂, 递归很是适合树这种嵌套数据结构的处理。
只不过这种依赖于调用栈的方式不能随意中断、也很难被恢复, 不利于异步处理。 这种调用栈,不是程序所能控制的, 若是你要恢复递归现场,可能须要从头开始, 恢复到以前的调用栈。
所以首先咱们须要对React现有的数据结构进行调整,模拟函数调用栈
, 将以前须要递归进行处理的事情分解成增量的执行单元,将递归转换成迭代.
React 目前的作法是使用链表
, 每一个 VirtualDOM 节点内部如今使用 Fiber
表示, 它的结构大概以下:
export type Fiber = {
// Fiber 类型信息
type: any,
// ...
// ⚛️ 链表结构
// 指向父节点,或者render该节点的组件
return: Fiber | null,
// 指向第一个子节点
child: Fiber | null,
// 指向下一个兄弟节点
sibling: Fiber | null,
}
复制代码
用图片来展现这种关系会更直观一些:
使用链表结构只是一个结果,而不是目的,React 开发者一开始的目的是冲着模拟调用栈去的。这个不少关于Fiber 的文章都有说起, 关于调用栈的详细定义参见Wiki:
调用栈最常常被用于存放子程序的返回地址。在调用任何子程序时,主程序都必须暂存子程序运行完毕后应该返回到的地址。所以,若是被调用的子程序还要调用其余的子程序,其自身的返回地址就必须存入调用栈,在其自身运行完毕后再行取回。除了返回地址,还会保存
本地变量
、函数参数
、环境传递
(Scope?)
React Fiber 也被称为虚拟栈帧(Virtual Stack Frame), 你能够拿它和函数调用栈类比一下, 二者结构很是像:
函数调用栈 | Fiber | |
---|---|---|
基本单位 | 函数 | Virtual DOM 节点 |
输入 | 函数参数 | Props |
本地状态 | 本地变量 | State |
输出 | 函数返回值 | React Element |
下级 | 嵌套函数调用 | 子节点(child) |
上级引用 | 返回地址 | 父节点(return) |
Fiber 和调用栈帧同样, 保存了节点处理的上下文信息,由于是手动实现的,因此更为可控,咱们能够保存在内存中,随时中断和恢复。
有了这个数据结构调整,如今能够以迭代的方式来处理这些节点了。来看看 performUnitOfWork
的实现, 它其实就是一个深度优先的遍历:
/** * @params fiber 当前须要处理的节点 * @params topWork 本次更新的根节点 */
function performUnitOfWork(fiber: Fiber, topWork: Fiber) {
// 对该节点进行处理
beginWork(fiber);
// 若是存在子节点,那么下一个待处理的就是子节点
if (fiber.child) {
return fiber.child;
}
// 没有子节点了,上溯查找兄弟节点
let temp = fiber;
while (temp) {
completeWork(temp);
// 到顶层节点了, 退出
if (temp === topWork) {
break
}
// 找到,下一个要处理的就是兄弟节点
if (temp.sibling) {
return temp.sibling;
}
// 没有, 继续上溯
temp = temp.return;
}
}
复制代码
你能够配合上文的 workLoop
一块儿看,Fiber 就是咱们所说的工做单元,performUnitOfWork
负责对 Fiber
进行操做,并按照深度遍历的顺序返回下一个 Fiber。
由于使用了链表结构,即便处理流程被中断了,咱们随时能够从上次未处理完的Fiber
继续遍历下去。
整个迭代顺序和以前递归的同样, 下图假设在 div.app
进行了更新:
text(hello)
中断了,那么下一次就会从 p
节点开始处理
这个数据结构调整还有一个好处,就是某些节点异常时,咱们能够打印出完整的’节点栈‘,只须要沿着节点的return
回溯便可。
若是你如今使用最新的 React 版本(v16), 使用 Chrome 的 Performance 工具,能够很清晰地看到每次渲染有两个阶段:Reconciliation
(协调阶段) 和 Commit
(提交阶段).
我在以前的多篇文章中都有说起: 《本身写个React渲染器: 以 Remax 为例(用React写小程序)》
除了Fiber 工做单元的拆分,两阶段的拆分也是一个很是重要的改造,在此以前都是一边Diff一边提交的。先来看看这二者的区别:
⚛️ 协调阶段: 能够认为是 Diff 阶段, 这个阶段能够被中断, 这个阶段会找出全部节点变动,例如节点新增、删除、属性变动等等, 这些变动React 称之为'反作用
(Effect)' . 如下生命周期钩子会在协调阶段被调用:
⚛️ 提交阶段: 将上一个阶段计算出来的须要处理的**反作用(Effects)**一次性执行了。这个阶段必须同步执行,不能被打断. 这些生命周期钩子在提交阶段被执行:
也就是说,在协调阶段若是时间片用完,React就会选择让出控制权。由于协调阶段执行的工做不会致使任何用户可见的变动,因此在这个阶段让出控制权不会有什么问题。
须要注意的是:由于协调阶段可能被中断、恢复,甚至重作,⚠️React 协调阶段的生命周期钩子可能会被调用屡次!, 例如 componentWillMount
可能会被调用两次。
所以建议 协调阶段的生命周期钩子不要包含反作用. 索性 React 就废弃了这部分可能包含反作用的生命周期方法,例如componentWillMount
、componentWillUpdate
. v17后咱们就不能再用它们了, 因此现有的应用应该尽快迁移.
如今你应该知道为何'提交阶段'必须同步执行,不能中断的吧? 由于咱们要正确地处理各类反作用,包括DOM变动、还有你在componentDidMount
中发起的异步请求、useEffect 中定义的反作用... 由于有反作用,因此必须保证按照次序只调用一次,何况会有用户能够察觉到的变动, 不容差池。
关于为何要拆分两个阶段,这里有更详细的解释。
接下来就是就是咱们熟知的Reconcilation
(为了方便理解,本文不区分Diff和Reconcilation, 二者是同一个东西)阶段了. 思路和 Fiber 重构以前差异不大, 只不过这里不会再递归去比对、并且不会立刻提交变动。
首先再进一步看一下Fiber
的结构:
interface Fiber {
/** * ⚛️ 节点的类型信息 */
// 标记 Fiber 类型, 例如函数组件、类组件、宿主组件
tag: WorkTag,
// 节点元素类型, 是具体的类组件、函数组件、宿主组件(字符串)
type: any,
/** * ⚛️ 结构信息 */
return: Fiber | null,
child: Fiber | null,
sibling: Fiber | null,
// 子节点的惟一键, 即咱们渲染列表传入的key属性
key: null | string,
/** * ⚛️ 节点的状态 */
// 节点实例(状态):
// 对于宿主组件,这里保存宿主组件的实例, 例如DOM节点。
// 对于类组件来讲,这里保存类组件的实例
// 对于函数组件说,这里为空,由于函数组件没有实例
stateNode: any,
// 新的、待处理的props
pendingProps: any,
// 上一次渲染的props
memoizedProps: any, // The props used to create the output.
// 上一次渲染的组件状态
memoizedState: any,
/** * ⚛️ 反作用 */
// 当前节点的反作用类型,例如节点更新、删除、移动
effectTag: SideEffectTag,
// 和节点关系同样,React 一样使用链表来将全部有反作用的Fiber链接起来
nextEffect: Fiber | null,
/** * ⚛️ 替身 * 指向旧树中的节点 */
alternate: Fiber | null,
}
复制代码
Fiber 包含的属性能够划分为 5 个部分:
🆕 结构信息 - 这个上文咱们已经见过了,Fiber 使用链表的形式来表示节点在树中的定位
节点类型信息 - 这个也容易理解,tag表示节点的分类、type 保存具体的类型值,如div、MyComp
节点的状态 - 节点的组件实例、props、state等,它们将影响组件的输出
🆕 反作用 - 这个也是新东西. 在 Reconciliation 过程当中发现的'反作用'(变动需求)就保存在节点的effectTag
中(想象为打上一个标记). 那么怎么将本次渲染的全部节点反作用都收集起来呢? 这里也使用了链表结构,在遍历过程当中React会将全部有‘反作用’的节点都经过nextEffect
链接起来
🆕 替身 - React 在 Reconciliation 过程当中会构建一颗新的树(官方称为workInProgress tree,WIP树),能够认为是一颗表示当前工做进度的树。还有一颗表示已渲染界面的旧树,React就是一边和旧树比对,一边构建WIP树的。 alternate 指向旧树的同等节点。
如今能够放大看看beginWork
是如何对 Fiber 进行比对的:
function beginWork(fiber: Fiber): Fiber | undefined {
if (fiber.tag === WorkTag.HostComponent) {
// 宿主节点diff
diffHostComponent(fiber)
} else if (fiber.tag === WorkTag.ClassComponent) {
// 类组件节点diff
diffClassComponent(fiber)
} else if (fiber.tag === WorkTag.FunctionComponent) {
// 函数组件节点diff
diffFunctionalComponent(fiber)
} else {
// ... 其余类型节点,省略
}
}
复制代码
宿主节点比对:
function diffHostComponent(fiber: Fiber) {
// 新增节点
if (fiber.stateNode == null) {
fiber.stateNode = createHostComponent(fiber)
} else {
updateHostComponent(fiber)
}
const newChildren = fiber.pendingProps.children;
// 比对子节点
diffChildren(fiber, newChildren);
}
复制代码
类组件节点比对也差很少:
function diffClassComponent(fiber: Fiber) {
// 建立组件实例
if (fiber.stateNode == null) {
fiber.stateNode = createInstance(fiber);
}
if (fiber.hasMounted) {
// 调用更新前生命周期钩子
applybeforeUpdateHooks(fiber)
} else {
// 调用挂载前生命周期钩子
applybeforeMountHooks(fiber)
}
// 渲染新节点
const newChildren = fiber.stateNode.render();
// 比对子节点
diffChildren(fiber, newChildren);
fiber.memoizedState = fiber.stateNode.state
}
复制代码
子节点比对:
function diffChildren(fiber: Fiber, newChildren: React.ReactNode) {
let oldFiber = fiber.alternate ? fiber.alternate.child : null;
// 全新节点,直接挂载
if (oldFiber == null) {
mountChildFibers(fiber, newChildren)
return
}
let index = 0;
let newFiber = null;
// 新子节点
const elements = extraElements(newChildren)
// 比对子元素
while (index < elements.length || oldFiber != null) {
const prevFiber = newFiber;
const element = elements[index]
const sameType = isSameType(element, oldFiber)
if (sameType) {
newFiber = cloneFiber(oldFiber, element)
// 更新关系
newFiber.alternate = oldFiber
// 打上Tag
newFiber.effectTag = UPDATE
newFiber.return = fiber
}
// 新节点
if (element && !sameType) {
newFiber = createFiber(element)
newFiber.effectTag = PLACEMENT
newFiber.return = fiber
}
// 删除旧节点
if (oldFiber && !sameType) {
oldFiber.effectTag = DELETION;
oldFiber.nextEffect = fiber.nextEffect
fiber.nextEffect = oldFiber
}
if (oldFiber) {
oldFiber = oldFiber.sibling;
}
if (index == 0) {
fiber.child = newFiber;
} else if (prevFiber && element) {
prevFiber.sibling = newFiber;
}
index++
}
}
复制代码
上面的代码很粗糙地还原了 Reconciliation 的过程, 可是对于咱们理解React的基本原理已经足够了.
这里引用一下Youtube: Lin Clark presentation in ReactConf 2017 的Slide,来还原 Reconciliation 的过程. Lin Clark 这个演讲太经典了,几乎全部介绍 React Fiber 的文章都会引用它的Slide. 偷个懒,我也用下:
这篇文章《React Fiber》 用文字版解释了Link Clark Slide.
上图是 Reconciliation 完成后的状态,左边是旧树,右边是WIP树。对于须要变动的节点,都打上了'标签'。 在提交阶段,React 就会将这些打上标签的节点应用变动。
WIP 树
构建这种技术相似于图形化领域的'双缓存(Double Buffering)'技术, 图形绘制引擎通常会使用双缓冲技术,先将图片绘制到一个缓冲区,再一次性传递给屏幕进行显示,这样能够防止屏幕抖动,优化渲染性能。
放到React 中,WIP树就是一个缓冲,它在Reconciliation 完毕后一次性提交给浏览器进行渲染。它能够减小内存分配和垃圾回收,WIP 的节点不彻底是新的,好比某颗子树不须要变更,React会克隆复用旧树中的子树。
双缓存技术还有另一个重要的场景就是异常的处理,好比当一个节点抛出异常,仍然能够继续沿用旧树的节点,避免整棵树挂掉。
Dan 在 Beyond React 16 演讲中用了一个很是恰当的比喻,那就是Git 功能分支,你能够将 WIP 树想象成从旧树中 Fork 出来的功能分支,你在这新分支中添加或移除特性,即便是操做失误也不会影响旧的分支。当你这个分支通过了测试和完善,就能够合并到旧分支,将其替换掉. 这或许就是’提交(commit)阶段‘的提交一词的来源吧?:
接下来就是将全部打了 Effect 标记的节点串联起来,这个能够在completeWork
中作, 例如:
function completeWork(fiber) {
const parent = fiber.return
// 到达顶端
if (parent == null || fiber === topWork) {
pendingCommit = fiber
return
}
if (fiber.effectTag != null) {
if (parent.nextEffect) {
parent.nextEffect.nextEffect = fiber
} else {
parent.nextEffect = fiber
}
} else if (fiber.nextEffect) {
parent.nextEffect = fiber.nextEffect
}
}
复制代码
最后了,将全部反作用提交了:
function commitAllWork(fiber) {
let next = fiber
while(next) {
if (fiber.effectTag) {
// 提交,偷一下懒,这里就不展开了
commitWork(fiber)
}
next = fiber.nextEffect
}
// 清理现场
pendingCommit = nextUnitOfWork = topWork = null
}
复制代码
上文只是介绍了简单的中断和恢复机制,咱们从哪里跌倒就从哪里站起来,在哪一个节点中断就从哪一个节点继续处理下去。 也就是说,到目前为止:⚠️更新任务仍是串行执行的,咱们只是将整个过程碎片化了. 对于那些须要优先处理的更新任务仍是会被阻塞。我我的以为这才是 React Fiber 中最难处理的一部分。
实际状况是,在 React 获得控制权后,应该优先处理高优先级的任务。也就是说中断时正在处理的任务,在恢复时会让位给高优先级任务,本来中断的任务可能会被放弃或者重作。
可是若是不按顺序执行任务,可能会致使先后的状态不一致。 好比低优先级任务将 a
设置为0,而高优先级任务将 a
递增1, 两个任务的执行顺序会影响最终的渲染结果。所以要让高优先级任务插队, 首先要保证状态更新的时序。
解决办法是: 全部更新任务按照顺序插入一个队列, 状态必须按照插入顺序进行计算,但任务能够按优先级顺序执行, 例如:
红色表示高优先级任务。要计算它的状态必须基于前序任务计算出来的状态, 从而保证状态的最终一致性:
最终红色的高优先级任务 C
执行时的状态值是a=5,b=3
. 在恢复控制权时,会按照优先级先执行 C
, 前面的A
、 B
暂时跳过
上面被跳过任务不会被移除,在执行完高优先级任务后它们仍是会被执行的。由于不一样的更新任务影响的节点树范围多是不同的,举个例子 a
、b
可能会影响 Foo
组件树,而 c
会影响 Bar
组件树。因此为了保证视图的最终一致性, 全部更新任务都要被执行。
首先 C
先被执行,它更新了 Foo
组件
接着执行 A
任务,它更新了Foo
和 Bar
组件,因为 C
已经以最终状态a=5, b=3
更新了Foo
组件,这里能够作一下性能优化,直接复用C的更新结果, 没必要触发从新渲染。所以 A
仅需更新 Bar
组件便可。
接着执行 B
,同理能够复用 Foo 更新结果。
道理讲起来都很简单,React Fiber 实际上很是复杂,无论执行的过程怎样拆分、以什么顺序执行,最重要的是保证状态的一致性和视图的一致性,这给了 React 团队很大的考验,以至于如今都没有正式release出来。
前面说了一大堆,从操做系统进程调度、到浏览器原理、再到合做式调度、最后谈了React的基本改造工做, 地老天荒... 就是为了上面的小人能够在练就凌波微步, 它脚下的坑是浏览器的调用栈。
React 开启 Concurrent Mode
以后就不会挖大坑了,而是一小坑一坑的挖,挖一下休息一下,有紧急任务就优先去作。
开启 Concurrent Mode
后,咱们能够获得如下好处(详见Concurrent Rendering in React):
Suspense
下降加载状态(load state)的优先级,减小闪屏。 好比数据很快返回时,能够没必要显示加载状态,而是直接显示出来,避免闪屏;若是超时没有返回才显式加载状态。可是它确定不是完美的,由于浏览器没法实现抢占式调度,没法阻止开发者作傻事的,开发者能够为所欲为,想挖多大的坑,就挖多大的坑。
为了共同创造美好的世界,咱们要严律于己,该作的优化还须要作: 纯组件、虚表、简化组件、缓存...
尤雨溪在今年的Vue Conf一个观点让我印象深入:若是咱们能够把更新作得足够快的话,理论上就不须要时间分片了。
时间分片并无下降总体的工做量,该作的仍是要作, 所以React 也在考虑利用CPU空闲或者I/O空闲期间作一些预渲染。因此跟尤雨溪说的同样:React Fiber 本质上是为了解决 React 更新低效率的问题,不要指望 Fiber 能给你现有应用带来质的提高, 若是性能问题是本身形成的,本身的锅仍是得本身背.
本文之因此能成文,离不开社区上优质的开源项目和资料。
迷你 Fiber 实现:
React 如今的代码库太复杂了! 并且一直在变更和推翻本身,Hax 在 《为何社区里那些类 React 库至今没有选择实现 Fiber 架构?》 就开玩笑说: Fiber 性价比略低... 到了这个阶段,竞品太多,facebook 就搞一个 fiber 来做为护城河……
这种工程量不是通常团队能Hold住的, 若是你只是想了解 Fiber,去读 React 的源码性价比也很低,不妨看看这些 Mini 版实现, 感觉其精髓,不求甚解:
优秀的文章 & 演讲
本文只是对React Fiber进行了简单的科普,实际上React 的实现比本文复杂的多,若是你想深刻理解React Fiber的,下面这些文章不容错过:
自荐React 相关文章
回顾一下今年写的关于 React 的相关文章
Concurrent模式预览(推荐):
往期文章:
本文讲了 React 如何优化 CPU 问题,React 野心远不在于此, I/O 方向的优化也在实践,例如 Suspend... 还有不少没讲完,后面的文章见!
问卷调查,你以为这种文章风格怎样?
多选,下方评论,👍点赞走起
改了一个正经一点的网名:sx(傻叉) -> 荒山 ⛰