精读源码《React18》:Concurrent Mode
前言
React
团队花了2年时间重构成Fiber
架构,目的就是为了Concurrent Mode
做铺垫。花这么大的精力要打造的Concurrent Mode
到底是什么呢?
What
Concurrent Mode
(并发更新)是React
一种新的更新模式,包含一组新的功能特性,其中最重要的特性就是: 异步可中断更新。这个特性带来的好处就是让应用一直保持响应,也就是让用户不会觉得页面卡顿,带来更好的交互体验。
并发和并行
肯定有人会把并发和并行的概念搞混了(小声bb:我之前就是)。
- 并发指的是:多个任务,在同一时间段内同时发生,多个任务会有互相抢占资源的情况。
- 并行指的是:多个任务,在同一时间点上同时发生,多个任务之间不会互相抢占资源。
举两个🌰:
- 一个人在跑步,这时他的鞋带松了,他停下来系鞋带,然后继续跑步。这是并发执行。
- 一个人在跑步,但同时他还戴着耳机听歌。这是并行执行。
在我们的计算机上, 只有多个cpu
的情况下才可能发生并行。而我们看到的很多同时发生的事情,其实都是并发执行。
在这里,我们千万不要理解Concurrent Mode
是在同时执行多个任务哦,它是在一段时间内,可以执行多个任务(因为可以打断低优先级任务,插队高优先级任务)。
流程图
Why
我们为什么需要Concurrent Mode
?
在使用Concurrent Mode之前的React在进行交互更新时都是同步更新,一旦渲染开始就不能被终止。也就是在更新完成前,页面会卡在那,用户是无法进行交互的。正常情况更新可以很快完成,用户无感知,但是当更新耗时较就,页面卡顿会给用户极不好的体验。
Concurrent Mode
通过使渲染可中断来解决这个限制。
How
Concurrent Mode
这种中断渲染的行为,虽然能打破React
天花板,解锁未来可能,但也给React
团队带来了不小的挑战:
- 怎样做到中断渲染?
- 怎样定义任务的重要程度和执行顺序?
- 何时中断任务,怎样划分时间片?
- ...
个人总结主要通过三个方面完成:
- 颗粒化更新节点来解决递归不可中断问题;
- 任务增加优先级来解决任务执行顺序;
- 创建任务调度机制来解决时间分片和任务中断,任务恢复;
对应到React的实现就是:Fiber架构
,lane模型
,scheduler任务调度
.
Fiber架构
在重构Fiber
架构之前,React
是没办法解决这些问题的。因为在此之前,React
的渲染更新主要是通过对比更新前后的虚拟DOM
,找出不同进行更新,而对比的过程因为虚拟DOM
树结构的限制,只能采用递归更新,我们知道递归一旦开始,中途就无法中断。
那Fiber架构为什么能解决这个问题呢?
- 每个Fiber节点对应一个React Element,保存有该组件的所有基本状态信息;
- 每个Fiber节点保存有该组件的更新信息;
因为Fiber
节点承载了基本状态和更新信息,这样React
就可以将Fiber
节点视为最小的工作单元,可以实现Fiber
节点这种粒度的更新,因为粒度的细化也就使得异步可中断更新成为了可能。
Fiber节点的基本状态保存了它的父节点,子节点,兄弟节点信息,这样可以将之前的递归遍历改变为循环遍历,使渲染中断成为可能。
想了解更多关于Fiber架构可戳:Fiber
lane模型
lane
模型主要解决的是任务优先级问题。
我们想中断渲染的本质是想让有更高优先级的任务可以中断低优先级任务来插队执行。那怎么定义任务优先级呢,lane
模型通过31位的位运算符来定义:
// lane使用31位二进制来表示优先级车道共31条, 位数越小(1的位置越靠右)表示优先级越高
export const TotalLanes = 31;
// 没有优先级
export const NoLanes: Lanes = /* */ 0b0000000000000000000000000000000;
export const NoLane: Lane = /* */ 0b0000000000000000000000000000000;
// 同步优先级,表示同步的任务一次只能执行一个,例如:用户的交互事件产生的更新任务
export const SyncLane: Lane = /* */ 0b0000000000000000000000000000001;
// 连续触发优先级,例如:滚动事件,拖动事件等
export const InputContinuousHydrationLane: Lane = /* */ 0b0000000000000000000000000000010;
export const InputContinuousLane: Lanes = /* */ 0b0000000000000000000000000000100;
// 默认优先级,例如使用setTimeout,请求数据返回等造成的更新
export const DefaultHydrationLane: Lane = /* */ 0b0000000000000000000000000001000;
export const DefaultLane: Lanes = /* */ 0b0000000000000000000000000010000;
// 过渡优先级,例如: Suspense、useTransition、useDeferredValue等拥有的优先级
const TransitionHydrationLane: Lane = /* */ 0b0000000000000000000000000100000;
const TransitionLanes: Lanes = /* */ 0b0000000001111111111111111000000;
const TransitionLane1: Lane = /* */ 0b0000000000000000000000001000000;
const TransitionLane2: Lane = /* */ 0b0000000000000000000000010000000;
const TransitionLane3: Lane = /* */ 0b0000000000000000000000100000000;
...
可以看到React定义任务的优先级:
同步任务 > 连续触发事件任务 > setTimeout,请求更新任务 > 过渡任务(React18新特性)
事件优先级
具体同步任务和连续触发事件任务:
export function getEventPriority(domEventName: DOMEventName): * {
switch (domEventName) {
case 'cancel':
case 'click':
case 'copy':
case 'dragend':
case 'dragstart':
case 'drop':
...
case 'focusin':
case 'focusout':
case 'input':
case 'change':
case 'textInput':
case 'blur':
case 'focus':
case 'select':
// 同步优先级
return DiscreteEventPriority;
case 'drag':
case 'mousemove':
case 'mouseout':
case 'mouseover':
case 'scroll':
...
case 'touchmove':
case 'wheel':
case 'mouseenter':
case 'mouseleave':
// 连续触发优先级
return ContinuousEventPriority;
...
default:
return DefaultEventPriority;
}
}
调度优先级
调度优先级分为四种:
-
Immediate:立即执行,最高优先级。
-
render-blocking:会阻塞渲染的优先级,优先级类似
requestAnimationFrame
。如果这种优先级任务不能被执行,就可能导致 UI 渲染被 block。 -
default:默认优先级,普通的优先级。优先级可以理解为
setTimeout(0)
的优先级。 -
idle:比如通知等任务,用户看不到或者不在意的。
switch (lanesToEventPriority(nextLanes)) {
case DiscreteEventPriority:
schedulerPriorityLevel = ImmediateSchedulerPriority;
break;
case ContinuousEventPriority:
schedulerPriorityLevel = UserBlockingSchedulerPriority;
break;
case DefaultEventPriority:
schedulerPriorityLevel = NormalSchedulerPriority;
break;
case IdleEventPriority:
schedulerPriorityLevel = IdleSchedulerPriority;
break;
default:
schedulerPriorityLevel = NormalSchedulerPriority;
break;
}
React
根据lane
模型来给不同的任务分配优先级,具体做法是:
看过我之前文章的应该知道,React更新主要是创建一个update
对象,而update
的lane
会记录任务更新的优先级。
任务饥饿
任务饥饿是讲一个低优先级的任务一直被高优先级的任务插队,导致这个任务已经过了执行期限依然没有得到执行,在这种情况下,React会将该任务置为同步渲染任务,在下次更新时立即执行。
任务插队
当执行任务调度之前,会调用ensureRootIsScheduled()
。它会判断当前是否存在饥饿任务和更高优先级的任务。如果有更高优先级的任务,会中断当前任务执行,转而开始执行高优先级的任务。
Q:那低优先级任务如果被高优先级的任务插队怎样呢?
A:会直接被取消掉。因为高优先级的任务执行结果可能会影响到低优先级任务。
Q:那低优先级任务如果没有被影响会丢失吗?
A:不会,因为当高优先级任务执行完,页面重新渲染会再次走render
,commit
流程,此时低优先级任务会再次加入任务队列。
任务调度
任务队列
scheduler
中有两种任务队列,taskQueue
和timerQueue
,他们分别存放未过期任务和已过期任务。
前面讲到任务调度优先级分为四种,因此他们对应的timeout
也不同:
// Times out immediately
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
// Eventually times out
var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
调度优先级反应到任务身上就是过期时间,过期时间的计算就是:
expirationTime = currentTime + timeout
很明显,立即执行任务的timeout
是-1,所以它在添加的那一刻就已经过期了,React
就视为它应该立即执行。
// 如果delay大于0,startTime = currentTime + delay
startTime = currentTime
expirationTime = startTime + timeout
if (startTime > currentTime) {
// This is a delayed task.
newTask.sortIndex = startTime;
push(timerQueue, newTask);
} else {
newTask.sortIndex = expirationTime;
push(taskQueue, newTask);
}
taskQueue
依据任务的过期时间expirationTime
排序,过期时间越早表示任务优先级越高。
timerQueue
依据任务的开始时间startTime
排序,开始时间越早优先级越高。
在开始调度的时候,因为timerQueue
都是过期任务,会先清空timerQueue
,将它们加入到taskQueue
中。
let timer = peek(timerQueue);
while (timer !== null) {
if (timer.callback === null) {
// Timer was cancelled.
pop(timerQueue);
} else if (timer.startTime <= currentTime) {
// Timer fired. Transfer to the task queue.
pop(timerQueue);
timer.sortIndex = timer.expirationTime;
push(taskQueue, timer);
if (enableProfiling) {
markTaskStart(timer, currentTime);
timer.isQueued = true;
}
} else {
// Remaining timers are pending.
return;
}
timer = peek(timerQueue);
}
时间切片
React
根据浏览器的requestIdleCallback
写了一个库scheduler
。
它规定一个时间切片为5ms
export const frameYieldMs = 5;
正常情况下,在一个时间切片里,任务循环调度,每调度完成一个任务都会检查时间片,当到达时间切片长度中断渲染交出控制权。
交出控制权的前提是:
当前任务队列中没有过期任务;
借用知乎大佬@淡苍的一张图,Concurrent Mode
只能中断render
阶段任务,不能中断commit
阶段任务。
因为commit阶段会直接进行DOM更新,DOM更新是无法中断的,必须一次性执行完成。
交出控制权的方法是通过创建一个宏任务,了解JS
事件循环机制的同学就会知道,宏任务是一个异步任务,它不会阻塞浏览器的运行。
React
创建宏任务有三种策略:
优先顺序:setImmediate
,MessageChannel
,setTimeout
。
交出控制权后,如果浏览器当前存在事件响应,更高优先级任务或者其他代码需要执行。
禁用时间切片
- 存在阻塞渲染的任务;
- 存在饥饿任务;
当存在上面两种情况时,直接走同步渲染,而不是Concurrent Mode
。
任务恢复
当任务中断交出控制权后,浏览器空闲时间就会执行我们上面中断任务时创建的宏任务,此时React
就会重新创建任务循环,继续执行任务队列里的其他任务。
总结
本文主要从Fiber
架构,lane
模型,scheduler
任务调度三个角度来尝试阐述Concurrent Mode
的主要特性:异步可中断更新。
总结起来是
通过
Fiber
架构完成从递归遍历更新到循环遍历更新的跨越,让render
阶段可中断;通过
lane
模型给不同任务事件添加优先级来定义任务的重要程度;通过
scheduler
完成任务分片执行和中断恢复;
题外话
在此之前,前端框架主要解决的还是工程效率,开发效率问题,而无关乎用户体验。但是Concurrent Mode
做了一次勇敢的尝试。
没有贴太多源码,主要是源码太多,贴一点源码更容易让读者云山雾罩。
转载自:https://juejin.cn/post/7129779193998475301