引言
React18是并发的React,提供了并发的底层机制,带来流畅的用户体验。这种特性是渐进式的,升级到React18不会破坏以前的代码,开发者可以按自己的节奏逐渐使用并发特性。
特性
并发特性影响的是React的渲染过程,这种并发渲染首先体现为可中断的渲染。
可中断
同步渲染意味着,一旦开始渲染就无法中断,直到用户可以在屏幕上看到渲染结果,这是React18以前或者React18以后但是尚未采用并发特性的情况。
在并发渲染下,一个更新可以中断挂起,稍后又继续,甚至是完全被废弃。你不必担心会出现不完全的UI,未完成的更新不会提交到页面上,只有当更新恢复,并且整棵树完成更新,完整的视图才会提交到页面。
可以看一个小示例:下面一个tabs应用,C和Rust对应的内容渲染很快,而Python对应的内容渲染很慢(需要2秒多),点击Python之后,不必等内容渲染出来,可以点击其他的tab,渲染相应的内容。
代码如下:
const languageList = [
{ name: "C", key: "c" },
{ name: "Python", key: "python" },
{ name: "Rust", key: "rust" },
];
// 长度为100的数组
const sleepers = Array(100).fill(0);
let globalKey = 0;
function Languages() {
const [activeLanguage, setActiveLanguage] = useState("c");
const [, startTransition] = useTransition();
const handleTabClick = (id) => {
startTransition(() => setActiveLanguage(id));
};
return (
<div>
<div>
{languageList.map(({ name, key }) => (
<Language
name={name}
key={key}
id={key}
onClick={handleTabClick}
active={activeLanguage === key}
/>
))}
</div>
<Content content={activeLanguage} />
</div>
);
}
function Content({ content }) {
const sleepMs = content === "python" ? 20 : 1;
return (
<>
{sleepers.map(() => (
<Sleeper ms={sleepMs} key={globalKey++} />
))}
<p>{`It's ${content}`}</p>
</>
);
}
function Sleeper({ ms }) {
sleep(ms);
return null;
}
优先级
在并发渲染下,更新不仅是可中断的,还可以拥有不同的优先级,高优先级的更新可以先执行,甚至,高优先级的更新可以打断正在进行的低优先级更新。通常来说,用户的交互通常是更高优先级的,因为用户对此会更敏感。
在如下应用中,点击Toggle会开始一堆“1”的运动,每次会移动到相对于默认位置随机的一个偏移量处,这个变化会一直持续,但是在并发渲染下,更高优先级的用户输入始终能够得到响应,并且会打断运动,当输入停止,运动恢复。
代码如下:
function Priority() {
const [transited, setTransited] = useState(false);
const [value, setValue] = useState("");
const handleClick = () => {
setTransited(!transited);
};
const hanldeChange = (e) => {
setValue(e.target.value);
};
console.log("Priority rendered");
return (
<>
<button onClick={handleClick}>Toggle</button>
<input type="text" onChange={hanldeChange} value={value} />
<MemoExpensiveChildren transited={transited} />
</>
);
}
const ExpensiveChildren = ({ transited = false }) => {
const [transformX, setTrans] = useState("");
useEffect(() => {
if (transited) {
const trans = () => {
startTransition(() => setTrans(Math.random() * 500 - 250));
};
trans();
}
});
console.log("ExpensiveChildren rendered");
return (
<div
style={{
transform: `translateX(${transformX}px)`,
width: "500px",
overflowWrap: "break-word",
}}
>
{Array(400)
.fill(0)
.map((v, i) => (
<ExpensiveChild key={i} />
))}
</div>
);
};
const MemoExpensiveChildren = memo(ExpensiveChildren);
function ExpensiveChild() {
const mounted = useRef(false);
console.log("ExpensiveChild rendered");
if (mounted.current) {
sleep(2);
} else {
mounted.current = true;
}
return 1;
}
中断与恢复
并发渲染下的更新中断与恢复实际上有两种情形:
- 高优先级更新对低优先级更新的打断。
- 不涉及React更新的事件对React更新的打断。
两种情形下,被打断的更新都会恢复,但不是没有区别的。结论是:普通的事件(各种交互)打断更新时,如果不涉及React更新(没有绑定回调函数,或者回调函数里没有setState),那么被打断的更新会从中断点恢复。相反,如果是高优先级更新打断低优先级更新,不管这个高优先级更新是如何触发的(事件回调函数,或者定时器,诸如此类),被打断的低优先级更新需要从root重新更新!
这是可以解释的。React更新的中断与恢复依赖workInProgress指针,如果被中断的更新期间没有新的更新,那么这个指针可以帮助React从中断处恢复。而如果高优先级更新打断了低优先级更新,在高优先级更新完成后,workInProgress指针就指向了null。如果你了解fiber树的双缓存的话,你会知道workInProgress树与current树实际上已经完成了互换,低优先级更新所进行的渲染工作的部分成果已经不复存在了,更新要从头开始。
举一个简单的例子,假设当前页面有耗时的React更新:
- 同步渲染下,鼠标滚轮滑动,页面无响应。
- 并发渲染下,鼠标滚轮滑动,页面有响应,并且更新从中断点恢复。
- 并发渲染下,鼠标滚轮滑动,页面有响应,在鼠标滚动事件中,调用了setState,触发了高优先级的更新,在这次setState对应的更新结束后,原有的更新从头开始。
原理
实现调度器
React的并发渲染基本逻辑就在这样的一个循环中:
function workLoopConcurrent() {
// 执行工作直到需要中断
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
在触发setState后,代码中的函数被层层包裹,作为回调函数经过React调度器Scheduler调度,最终在Scheduler中执行。
有三个地方需要解释:
workInProgress是当前被处理的fiber(可见作者写的fiber树文章),在处理过程中会不断改变,当所有fiber被处理,就形成了workInProgress树,渲染完成,随后这棵树会被提交到页面中。另外,React并发渲染中的中断/恢复就部分是因为workInProgress记录了中断点,而且不完全的树只在内存中,不会展现在页面中。shouldYield由Scheduler提供,判断是否应该中断渲染工作。performUnitOfWork包含具体fiber的渲染,以函数式组件为例,函数的执行发生这里。当然,并不是每一层的performUnitOfWork正好处理一个fiber,实际上,根据fiber在fiber树中的位置,每一轮performUnitOfWork会开始至少一个fiber的工作,完成0个或者多个fiber的工作。
由于并发渲染的核心在于并发,所以performUnitOfWork的实现细节无关紧要。
下面将从简单到复杂逐步展示并发渲染的实现,最终直到React源码的实现。
并发
在操作系统中,并发是指在同一个长时段有多个线程代码在执行。具体而言,每个确定的时刻只有一个线程代码在运行,多个线程交替执行,体现为一个时段内运行了多个线程代码。
在React中,由于javascript是单线程的,所以并发的单元就不是线程,而是工作单元,进一步说就是performUnitOfWork。在两个performUnitOfWork之间,浏览器可以处理包括用户交互在内的任务。
关于React的并发渲染,有两点需要关注:
- 前提是fiber的单元化使得它能够被增量式渲染,从而为调度提供了可能性。
- 实现涉及的是调度的方式,React何以能够使更新被中断,恢复,高优先级如何打断低优先级。
对于前提,这里不作讨论。
并发的简易实现
调度器Scheduler实现了React的并发。然而,因为认识过程是由简入繁的,所以先展示一个最简单的调度器。
javascript是单线程的,React各个工作单元如果被同步处理,那并发渲染实际上是不可能的。然而对于javascript任务队列,每一轮事件循环只会处理一个任务,如果以异步的方式调度React的渲染工作单元,就可以在多个工作单元之间处理其他事件,也就实现了并发渲染。从这一点,我们可以实现一个调度器:
function scheduleCallback(callback) {
setTimeout(callback);
if (workInProgress !== null) {
setTimeout(() => scheduleCallback(callback));
}
}
scheduleCallback(performUnitOfWork);
为了代码的可读性,假设performUnitOfWork内部能访问到workInProgress
你没有看错,这个调度器就是setTimeout。也许循环调用会有点费解,但这就是许多非阻塞任务的实现方式,事实上,React Scheduler源码也是循环调用。异步循环调用使得每个工作单元都是一个异步任务,从而在任务间隙可以处理用户交互等事件。
实现的优化
上述简单的调度器确实能够实现并发渲染,但缺陷也时十分明显,主要有两点:
- setTimeout频繁调用时,即便不指定延迟,也会默认有数毫秒的延迟,这使得整体的渲染时间因为工作单元之间的间隔而变长
- 并发渲染带来可中断的渲染,但并不是任意时刻都存在需要插入的事件。如果可以在恰当的条件下同步处理尽可能多的工作单元,剩余的工作单元作为整体再被异步调用,这样渲染的总体时长会更短。
对于第一点,我们可以把setTimeout优化为MessageChannel,通过postMessage来调度一个onmessage回调函数。
对于第二点,可以设置一个固定时长,在这个时长内,所有的工作单元同步处理,如果超过这个时长,且还有工作单元未处理完成,那么就将剩下的工作整体再以异步的方式调度,如此循环往复,直至所有工作完成。这就是所谓的时间分片。实际上,用户很难感知数毫秒级延迟,时间分片会比异步调度每一个工作单元开销更小。
优化后的调度器是这样的(只处理一个工作 ):
const channel = new MessageChannel()
let startTime = -1
let workCallback = null
function shouldYield() {
const timeElapsed = Date.now() - startTime;
if (timeElapsed < 5) {
return false;
}
return true;
}
function performWorkUntilDeadline() {
// 由处理工作的函数自身判断工作是否完成
workCallback = workCallback()
if (workCallback !== null) {
// 说明工作未完成,中断了继续调度
schedule()
}
}
channel.port1.onmessage = performWorkUntilDeadline
function schedule() {
channel.port2.postMessage()
}
function scheduleCallback(callback) {
startTime = Date.now()
workCallback = callback
schedule()
}
function workLoopConcurrent() {
// 执行工作直到需要中断
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
let callback = null;
if (workInProgress !== null) {
// 工作中断了,未完成
callback = workLoopConcurrent
}
return callback
}
// 现在调度的是工作整体
scheduleCallback(workLoopConcurrent);
在新的调度器里,由调度器向React提供shouldYield判断是否该中断,而React则通过回调函数的返回值告诉调度器工作是否未完成。在React与Scheduler的协作下,一个优化后的调度器能实现更佳的并发渲染能力,具体而言,就是在提供并发渲染能力的同时,优化渲染总时长。
对比图
下面的示例图展示了非并发渲染,并发渲染,以及并发渲染时不同调度器的表现差异:
从React到Scheduler
React Scheduler的实现要复杂不少,而且另外提供了优先级调度的能力。通过优先级调度,React可以做到按优先级顺序更新,甚至是高优先级任务打断低优先级任务。
优先级
关于优先级,要分两个部分表述:
- React的优先级系统。
- Scheduler对优先级的处理。
对于第一点,react的优先级系统是基于lane的,包括了优先级的分类以及分配。分类是静态的,分配是每次更新时动态生成的。
lane是这样定义的:
export const TotalLanes = 31;
export const NoLanes: Lanes = /* */ 0b0000000000000000000000000000000;
export const NoLane: Lane = /* */ 0b0000000000000000000000000000000;
export const SyncHydrationLane: Lane = /* */ 0b0000000000000000000000000000001;
export const SyncLane: Lane = /* */ 0b0000000000000000000000000000010;
export const SyncLaneIndex: number = 1;
export const InputContinuousHydrationLane: Lane = /* */ 0b0000000000000000000000000000100;
export const InputContinuousLane: Lane = /* */ 0b0000000000000000000000000001000;
export const DefaultHydrationLane: Lane = /* */ 0b0000000000000000000000000010000;
export const DefaultLane: Lane = /* */ 0b0000000000000000000000000100000;
export const SyncUpdateLanes: Lane =
SyncLane | InputContinuousLane | DefaultLane;
const TransitionHydrationLane: Lane = /* */ 0b0000000000000000000000001000000;
const TransitionLanes: Lanes = /* */ 0b0000000001111111111111110000000;
const TransitionLane1: Lane = /* */ 0b0000000000000000000000010000000;
const TransitionLane2: Lane = /* */ 0b0000000000000000000000100000000;
const TransitionLane3: Lane = /* */ 0b0000000000000000000001000000000;
const TransitionLane4: Lane = /* */ 0b0000000000000000000010000000000;
const TransitionLane5: Lane = /* */ 0b0000000000000000000100000000000;
const TransitionLane6: Lane = /* */ 0b0000000000000000001000000000000;
const TransitionLane7: Lane = /* */ 0b0000000000000000010000000000000;
const TransitionLane8: Lane = /* */ 0b0000000000000000100000000000000;
const TransitionLane9: Lane = /* */ 0b0000000000000001000000000000000;
const TransitionLane10: Lane = /* */ 0b0000000000000010000000000000000;
const TransitionLane11: Lane = /* */ 0b0000000000000100000000000000000;
const TransitionLane12: Lane = /* */ 0b0000000000001000000000000000000;
const TransitionLane13: Lane = /* */ 0b0000000000010000000000000000000;
const TransitionLane14: Lane = /* */ 0b0000000000100000000000000000000;
const TransitionLane15: Lane = /* */ 0b0000000001000000000000000000000;
const RetryLanes: Lanes = /* */ 0b0000011110000000000000000000000;
const RetryLane1: Lane = /* */ 0b0000000010000000000000000000000;
const RetryLane2: Lane = /* */ 0b0000000100000000000000000000000;
const RetryLane3: Lane = /* */ 0b0000001000000000000000000000000;
const RetryLane4: Lane = /* */ 0b0000010000000000000000000000000;
export const SomeRetryLane: Lane = RetryLane1;
export const SelectiveHydrationLane: Lane = /* */ 0b0000100000000000000000000000000;
const NonIdleLanes: Lanes = /* */ 0b0000111111111111111111111111111;
export const IdleHydrationLane: Lane = /* */ 0b0001000000000000000000000000000;
export const IdleLane: Lane = /* */ 0b0010000000000000000000000000000;
export const OffscreenLane: Lane = /* */ 0b0100000000000000000000000000000;
export const DeferredLane: Lane = /* */ 0b1000000000000000000000000000000;
很明显,lane是一个二进制数,越小的值对应了越高的优先级。lane的一个关键分界线是DefaultLane,包括这个优先级在内的更高优先级是同步的,不可中断的,也就是无法并发渲染的。DefaultLane对应的是首次渲染,定时器里的setState更新,useEffect回调里的setState更新等。更高的优先级则是由dom事件触发的回调里的更新。DefaultLane以后的优先级则是可以并发渲染,可中断的。
至于lane的分配,这是根据setState触发的场景决定的。
对于第二点,Scheduler中的优先级则是由React调度更新时给定的。Scheduler维护一个优先级队列(基于小顶堆),根据React调度更新时传入的回调函数及优先级向优先级队列添加任务(O(logn)),执行时则依据优先级依次取出任务(O(1))。
从React lane到Scheduler优先级队列中的优先级的转化流程是这样的:lane->priorityLevel->expirationTime。
priorityLevel是Scheduler的优先级,是普通的0-5的数值。Scheduler调度任务时会考虑超时问题,不同priorityLevel任务有不同的超时时间,考虑任务的开始时间,会得到一个最终的expirationTime,优先级队列就是根据expirationTime排序确定优先级的。
从setState到Scheduler
React首次渲染是不可中断的,也不需要经过调度器,需要讨论调度器的场景是更新。
React的更新一般由setState或者dispatch触发,这两个api触发更新的流程几乎是一致的,这里以setState为例。
以下是【从setState调用到Scheduler调度更新】的函数执行流程(react-reconciler v0.29.0截至11.15日最新代码):
值得注意的是,如果本次更新是不可中断的,也就是DefaultLane以上的优先级,那么更新不会经过调度器。
scheduleTaskForRootDuringMicrotask与scheduleCallback的源码在下面:
function scheduleTaskForRootDuringMicrotask(
root: FiberRoot,
currentTime: number,
): Lane {
// ...
if (includesSyncLane(nextLanes)) {
// ...
return SyncLane;
} else {
// ...
let schedulerPriorityLevel;
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;
}
const newCallbackNode = scheduleCallback(
schedulerPriorityLevel,
performWorkOnRootViaSchedulerTask.bind(null, root),
);
// ...
}
}
function scheduleCallback(
priorityLevel: PriorityLevel,
callback: RenderTaskFn,
) {
if (__DEV__ && ReactSharedInternals.actQueue !== null) {
// ...
ReactSharedInternals.actQueue.push(callback);
return fakeActCallbackNode;
} else {
return Scheduler_scheduleCallback(priorityLevel, callback);
}
}
performWorkOnRootViaSchedulerTask是执行React更新的函数,scheduleCallback就是在Scheduler提供的Scheduler_scheduleCallback的基础上做了一些针对开发环境的简单的封装。
对源码的分析将集中在Scheduler上。
Scheduler源码解析
任务从调度到执行的流程
Scheduler是一个独立的库,对于React来说是更新的东西,在Scheduler中是任务。也就是说,React的更新会作为任务在Scheduler中执行。以下是一个任务从被调度到被执行的流程图:
因为每个函数的逻辑都比较简单,所以这里直接按顺序介绍每一个函数,当然,略过了与React无关的部分以及一些琐碎的细节。
unstable_scheduleCallback
React所调用的scheduleCallback就是这个函数,它的基本逻辑就是:
- 根据回调函数和优先级生成任务,将其加入优先级任务队列。
- 调度任务。
下面是它的源码(为了方面,下面还加上了其他文件对应配置的代码):
function unstable_scheduleCallback(
priorityLevel: PriorityLevel,
callback: Callback,
options?: {delay: number},
): Task {
var currentTime = getCurrentTime();
var startTime;
// ...计算开始时间,如果没有延时就是currentTime
var timeout;
switch (priorityLevel) {
case ImmediatePriority:
// Times out immediately
timeout = -1;
break;
case UserBlockingPriority:
// Eventually times out
timeout = userBlockingPriorityTimeout;
break;
case IdlePriority:
// Never times out
timeout = maxSigned31BitInt;
break;
case LowPriority:
// Eventually times out
timeout = lowPriorityTimeout;
break;
case NormalPriority:
default:
// Eventually times out
timeout = normalPriorityTimeout;
break;
}
var expirationTime = startTime + timeout;
var newTask: Task = {
id: taskIdCounter++,
callback,
priorityLevel,
startTime,
expirationTime,
sortIndex: -1,
};
// ...
if (startTime > currentTime) {
// ...处理延时任务,React尚未使用
} else {
newTask.sortIndex = expirationTime;
push(taskQueue, newTask);
// ...
if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true;
requestHostCallback();
}
}
return newTask;
}
// SchedulerFeatureFlags.js 超时相关参数
export const userBlockingPriorityTimeout = 250;
export const normalPriorityTimeout = 5000;
export const lowPriorityTimeout = 10000;
// SchedulerPriorities.js Scheduler优先级
export const NoPriority = 0;
export const ImmediatePriority = 1;
export const UserBlockingPriority = 2;
export const NormalPriority = 3;
export const LowPriority = 4;
export const IdlePriority = 5;
这个函数的入参中,priorityLevel在React调用scheduleCallback时由lane转化而来,callback就是React的更新回调函数,可选的option参数在React中尚未使用。
任务Task是一个对象,它的属性中最重要的是callback和sortIndex。前者是最终执行的回调函数,是React更新,后者是真正的最终优先级,实际上就是expirationTime。expirationTime是根据开始时间加上不同优先级priorityLevel对应的超时时间得到。Task最终被加入到优先级队列中,随后56行的requestHostCallback将调度任务。
如果更新是可中断的,并且存在多次更新,又这些更新因为优先级不同而没有被批处理,那么这些更新会被转化成对应数量的任务。
requestHostCallback
这个函数是处理调度的。每个任务都会被加入优先级任务队列,但是异步调度只需要一次,在这一次调度中,多个任务可以依次被处理。
function requestHostCallback() {
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
schedulePerformWorkUntilDeadline();
}
}
这个函数就是单纯地保证MessageChannel的onmessage回调同时只有一个,当然在这一个回调中可以执行多个任务。isMessageLoopRunning是一个全局变量,如果这个变量值为true,则说明已经有异步调度了,新任务实际上之前通过unstable_scheduleCallback被加入优先级任务队列中了,新的任务会和之前调度的任务按照优先级依次被处理。如果isMessageLoopRunning的值为false,则说明还没有调度,需要去调度一次。
schedulePerformWorkUntilDeadline
这个函数是对调度方法的封装,不同环境下的调度方法不一样,在chrome等常见场景下调度是通过MessageChannel的。
let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === 'function') {
// Node.js and old IE.
// ...
} else if (typeof MessageChannel !== 'undefined') {
// DOM and Worker environments.
// We prefer MessageChannel because of the 4ms setTimeout clamping.
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
} else {
// We should only fallback here in non-browser environments.
schedulePerformWorkUntilDeadline = () => {
localSetTimeout(performWorkUntilDeadline, 0);
};
}
从11行可以看到,schedulePerformWorkUntilDeadline其实只是postMessage调用,随后performWorkUntilDeadline会作为onmessage的回调异步执行。
performWorkUntilDeadline
这个函数处理Scheduler工作流,相较于之前的函数,是异步的,基本逻辑是:
- 处理工作
- 如果还有工作(比如工作中断了),继续调度,如此异步循环调用;如果工作都处理完了(对应于优先级任务队列清空),关闭调度,将
isMessageLoopRunning置为false。
const performWorkUntilDeadline = () => {
if (isMessageLoopRunning) {
const currentTime = getCurrentTime();
startTime = currentTime;
let hasMoreWork = true;
try {
hasMoreWork = flushWork(currentTime);
} finally {
if (hasMoreWork) {
schedulePerformWorkUntilDeadline();
} else {
isMessageLoopRunning = false;
}
}
}
};
flushWork
这是对执行任务workLoop的一层封装,主要是处理监控相关逻辑,生产环境下就是执行workLoop。
function flushWork(initialTime: number) {
// ...
try {
if (enableProfiling) {
try {
return workLoop(initialTime);
} catch (error) {
// ...
throw error;
}
} else {
// No catch in prod code path.
return workLoop(initialTime);
}
} finally {
// ...
}
}
workLoop
这里的逻辑主要是:
- 循环取出,执行,弹出优先级任务队列中的任务。
- 循环结束后根据任务队里里是否还有任务,返回true或者false
function workLoop(initialTime: number) {
let currentTime = initialTime;
advanceTimers(currentTime);
currentTask = peek(taskQueue);
while (
currentTask !== null &&
!(enableSchedulerDebugging && isSchedulerPaused)
) {
if (currentTask.expirationTime > currentTime && shouldYieldToHost()) {
// This currentTask hasn't expired, and we've reached the deadline.
break;
}
const callback = currentTask.callback;
if (typeof callback === 'function') {
// ...
const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
// ...
const continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
if (typeof continuationCallback === 'function') {
currentTask.callback = continuationCallback;
// ...
return true;
} else {
// ...
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
advanceTimers(currentTime);
}
} else {
pop(taskQueue);
}
currentTask = peek(taskQueue);
}
// Return whether there's additional work
if (currentTask !== null) {
return true;
} else {
// ...
return false;
}
}
function shouldYieldToHost(): boolean {
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
// The main thread has only been blocked for a really short amount of time;
// smaller than a single frame. Don't yield yet.
return false;
}
// Yield now.
return true;
}
peek,pop,push是Scheduler优先级队列的取出,弹出,和插入方法。
任务的执行就是任务对应的callback的执行,换言之,就是React更新的执行。React与Scheduler依靠Scheduler的shouldYieldToHost以及React的更新函数返回值通信:
shouldYieldToHost告诉React工作是否该中断。- React更新回调函数返回该函数本身或者null通知Scheduler,更新是否被中断。
如果React更新回调函数返回null,那么这个任务对应的更新完成,任务被弹出队列,循环继续;反之,如果返回回调函数本身,则任务中断了,任务不会被弹出,循环因为shouldYieldToHost中断。
React的相关源码是这样的:
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
// ReactFiberRootScheduler.js
function performWorkOnRootViaSchedulerTask(
root: FiberRoot,
didTimeout: boolean,
): RenderTaskFn | null {
// ...
if (root.callbackNode === originalCallbackNode) {
return performWorkOnRootViaSchedulerTask.bind(null, root);
}
return null;
}
React中的shouldYield就是Scheduler导出的shouldYieldToHost,这个函数一方面告诉React中断更新循环,另一方面告诉Scheduler中断任务循环。React更新循环的单元是工作单元,Scheduler任务循环的单元是任务,每个任务的回调函数是整个的React更新。
callbackNode就是Scheduler的任务。
performWorkOnRootViaSchedulerTask是被传入Scheduler的回调函数,它和workLoopConcurrent的关系是这样的:
Scheduler循环中断有以下几种情形:
- 队列中任务已全部执行。
- 任务未超时,但是本轮异步工作执行总时间超过时间片。
任务如果超时,也就是expirationTime小于currentTime,那么这个可中断任务将转化为不可中断任务,不受时间分片的约束。
如shouldYieldToHost所示,任务的中断条件是getCurrentTime() - startTime > frameInterval,在这种情况下循环结束,workLoop返回true以表明还有工作,剩余的工作将在上级函数中被异步调度。
总结
Scheduler维护一个优先级任务队列,它通过异步循环的方式调度这些任务,需要注意的是,存在超时机制,一旦一个任务超时,那它将转化为不可中断任务。
应用
触发条件
并不是所有的更新都能应用并发渲染,当React18以前的代码迁移到React18后,运行逻辑是不变的。下面的源码展示了启用并发渲染,也就是开启时间分片的条件:
export function performWorkOnRoot(
root: FiberRoot,
lanes: Lanes,
forceSync: boolean,
): void {
// ...
const shouldTimeSlice =
!forceSync &&
!includesBlockingLane(lanes) &&
!includesExpiredLane(root, lanes);
let exitStatus = shouldTimeSlice
? renderRootConcurrent(root, lanes)
: renderRootSync(root, lanes);
// ...
}
启用时间分片的条件有三个:
!forceSync,即不强制同步,Scheduler确定任务超时后,会告诉React超时了。!includesBlockingLane(lanes),即不包含阻塞优先级,也就是优先级在defaultLane以下。如果不主动调用并发相关api,更新都是阻塞,不可中断的。!includesExpiredLane(root, lanes),即不包含超时优先级,这是React自身对超时更新的判断,与第一点Scheduler的判断不同。
超时指的是低优先级更新从被调度起,等待更新的时间超过了限制。例如,同时存在多个更新,低优先级的更新要让位于高优先级更新,当某时又有高优先级更新被调度,而低优先级更新此时等待过久,即便在优先级上更低,但还是超时的低优先级更新先执行。
这些条件归总就是两条:没超时、可中断。
相关api
这里不会介绍相关api的用法,也不会探究它们的实现,因为并发特性并不是由这些api实现的,它们仅仅是让开发者使在应用中使用并发特性的工具。相反,下面会简略地陈述这些api与并发特性的关系。
startTransition
startTransition(() => setState(state))会将本次的更新从原来的优先级改为过渡优先级TransitionLane。这样,那次更新就会采用并发渲染。
useTransition
const [isPendding, startTransition] = useTransition()会在使用startTransition时,获得本次是否有正在pendding的过渡更新的信息。
useDeferredValue
const deferredValue = useDeferredValue(value)会将新的value带来的更新标记为延迟优先级DeferredLane(全优先级中最低的),并且只在React包含非紧急更新时才返回新的值。