React
运行时,如果把别的部分比喻成我们的肢体用来执行具体的动作,那么scheduler
就相当于我们的大脑,调度中心位于scheduler
包中,理解清楚scheduler
为我们理解react
的工作流程有很大的裨益。
前言
我们都知道react
可以运行在node
环境中和浏览器环境中,所以在不同环境下实现requesHostCallback
等函数的时候采用了不同的方式,其中在node
环境下采用setTimeout
来实现任务的及时调用,浏览器环境下则使用MessageChannel
。这里引申出来一个问题,react
为什么放弃了requesIdleCallback
和setTimeout
而采用MessageChannel
来实现。这一点我们可以在这个PR[1]中看到一些端倪
由于
requestIdleCallback
依赖于显示器的刷新频率,使用时需要看vsync cycle(指硬件设备的频率)
的脸色
MessageChannel
方式也会有问题,会加剧和浏览器其它任务的竞争
为了尽可能每帧多执行任务,采用了5ms间隔的消息
event
发起调度,也就是这里真正有必要使用postmessage
来传递消息
对于浏览器在后台运行时
postmessage
和requestAnimationFrame
、setTimeout
的具体差异还不清楚,假设他们拥有同样的优先级,翻译不好见下面原文
I'm also not sure to what extent message events are throttled when the tab is backgrounded, relative to
requestAnimationFrame
orsetTimeout
. I'm starting with the assumption thatmessage
events fire with at least the same priority as timers, but I'll need to confirm.
由此我们可以看到实现方式并不是唯一的,可以猜想。react
团队做这一改动可能是react
团队更希望控制调度的频率,根据任务的优先级不同,提高任务的处理速度,放弃本身对于浏览器帧的依赖。优化react
的性能(concurrent
)
什么是Messagechannel
见MDN[2]
调度的实现
调度中心比较重要的函数在SchedulerHostConfig.default.js中
该js文件一共导出了8个函数
export let requestHostCallback;//请求及时回调
export let cancelHostCallback;
export let requestHostTimeout;
export let cancelHostTimeout;
export let shouldYieldToHost;
export let requestPaint;
export let getCurrentTime;
export let forceFrameRate;
调度相关
请求或取消调度
requestHostCallback
详情见:源码[3]
cancelHostCallbac
详情见:源码[4]
requestHostTimeout
详情见:源码[5]
requestHostTimeout
详情见:源码[6]
这几个函数的代码量非常少,它们的作用就是用来通知消息请求调用或者注册异步任务等待调用。下面我们具体看下scheduler的整个流程
ScheduleCallback 注册任务
这个函数注册了一个任务并开始调度。
function unstable_scheduleCallback(priorityLevel, callback, options) {
var currentTime = getCurrentTime();
// 确定当前时间 startTime 和延迟更新时间 timeout
var startTime;
if (typeof options === 'object' && options !== null) {
var delay = options.delay;
if (typeof delay === 'number' && delay > 0) {
startTime = currentTime + delay;
} else {
startTime = currentTime;
}
} else {
startTime = currentTime;
}
// 根据优先级不同timeout不同,最终导致任务的过期时间不同,而任务的过期时间是用来排序的唯一条件
// 所以我们可以理解优先级最高的任务,过期时间越短,任务执行的靠前
var timeout;
switch (priorityLevel) {
case ImmediatePriority:
timeout = IMMEDIATE_PRIORITY_TIMEOUT;
break;
case UserBlockingPriority:
timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
break;
case IdlePriority:
timeout = IDLE_PRIORITY_TIMEOUT;
break;
case LowPriority:
timeout = LOW_PRIORITY_TIMEOUT;
break;
case NormalPriority:
default:
timeout = NORMAL_PRIORITY_TIMEOUT;
break;
}
var expirationTime = startTime + timeout;
var newTask = {
id: taskIdCounter++,
// 任务本体
callback,
// 任务优先级
priorityLevel,
// 任务开始的时间,表示任务何时才能执行
startTime,
// 任务的过期时间
expirationTime,
// 在小顶堆队列中排序的依据
sortIndex: -1,
};
if (enableProfiling) {
newTask.isQueued = false;
}
// 如果是延迟任务则将 newTask 放入延迟调度队列(timerQueue)并执行 requestHostTimeout
// 如果是正常任务则将 newTask 放入正常调度队列(taskQueue)并执行 requestHostCallback
if (startTime > currentTime) {
// This is a delayed task.
newTask.sortIndex = startTime;
push(timerQueue, newTask);
if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
// All tasks are delayed, and this is the task with the earliest delay.
if (isHostTimeoutScheduled) {
// Cancel an existing timeout.
cancelHostTimeout();
} else {
isHostTimeoutScheduled = true;
}
// Schedule a timeout.
// 会把handleTimeout放到setTimeout里,在startTime - currentTime时间之后执行
// 待会再调度
requestHostTimeout(handleTimeout, startTime - currentTime);
}
} else {
newTask.sortIndex = expirationTime;
// taskQueue是最小堆,而堆内又是根据sortIndex(也就是expirationTime)进行排序的。
// 可以保证优先级最高(expirationTime最小)的任务排在前面被优先处理。
push(taskQueue, newTask);
if (enableProfiling) {
markTaskStart(newTask, currentTime);
newTask.isQueued = true;
}
// Schedule a host callback, if needed. If we're already performing work,
// wait until the next time we yield.
// 调度一个主线程回调,如果已经执行了一个任务,等到下一次交还执行权的时候再执行回调。
// 立即调度
if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true;
requestHostCallback(flushWork);
}
}
return newTask;
}
requestHostCallback 调度任务
开始调度任务,在这里我们可以看到scheduleHostCallback
这个变量被赋值成为了flushWork
见上段代码90行。
const channel = new MessageChannel();
const port = channel.port2;
// 收到消息之后调用performWorkUntilDeadline来处理
channel.port1.onmessage = performWorkUntilDeadline;
requestHostCallback = function(callback) {
scheduledHostCallback = callback;
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
port.postMessage(null);
}
};
performWorkUntilDeadline
可以看到这个函数主要的逻辑设置deadline为当前时间加上5ms 对应前言提到的5ms,同时开始消费任务并判断是否还有新的任务以决定后续的逻辑
const performWorkUntilDeadline = () => {
if (scheduledHostCallback !== null) {
const currentTime = getCurrentTime();
// Yield after `yieldInterval` ms, regardless of where we are in the vsync
// cycle. This means there's always time remaining at the beginning of
// the message event.
// yieldInterval 5ms
deadline = currentTime + yieldInterval;
const hasTimeRemaining = true;
try {
// scheduledHostCallback 由requestHostCallback 赋值为flushWork
const hasMoreWork = scheduledHostCallback(
hasTimeRemaining,
currentTime,
);
if (!hasMoreWork) {
isMessageLoopRunning = false;
scheduledHostCallback = null;
} else {
// If there's more work, schedule the next message event at the end
// of the preceding one.
port.postMessage(null);
}
} catch (error) {
// If a scheduler task throws, exit the current browser task so the
// error can be observed.
port.postMessage(null);
throw error;
}
} else {
isMessageLoopRunning = false;
}
// Yielding to the browser will give it a chance to paint, so we can
// reset this.
needsPaint = false;
};
flushWork 消费任务
可以看到消费任务的主要逻辑是在workLoop
这个循环中实现的,我们在React
工作循环一文中有提到的任务调度循环。
function flushWork(hasTimeRemaining, initialTime) {
// 1. 做好全局标记, 表示现在已经进入调度阶段
isHostCallbackScheduled = false;
isPerformingWork = true;
const previousPriorityLevel = currentPriorityLevel;
try {
// 2. 循环消费队列
return workLoop(hasTimeRemaining, initialTime);
} finally {
// 3. 还原标记
currentTask = null;
currentPriorityLevel = previousPriorityLevel;
isPerformingWork = false;
}}
workLoop 任务调度循环
function workLoop(hasTimeRemaining, initialTime) {
let currentTime = initialTime;
advanceTimers(currentTime);
// 获取taskQueue中最紧急的任务
currentTask = peek(taskQueue);
while (
currentTask !== null &&
!(enableSchedulerDebugging && isSchedulerPaused)
) {
if (
currentTask.expirationTime > currentTime &&
(!hasTimeRemaining || shouldYieldToHost())
) {
// This currentTask hasn't expired, and we've reached the deadline.
// 当前任务没有过期,但是已经到了时间片的末尾,需要中断循环
break;
}
const callback = currentTask.callback;
if (typeof callback === 'function') {
currentTask.callback = null;
currentPriorityLevel = currentTask.priorityLevel;
const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
markTaskRun(currentTask, currentTime);
const continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
if (typeof continuationCallback === 'function') {
// 检查callback的执行结果返回的是不是函数,如果返回的是函数,则将这个函数作为当前任务新的回调。
// concurrent模式下,callback是performConcurrentWorkOnRoot,其内部根据当前调度的任务
// 是否相同,来决定是否返回自身,如果相同,则说明还有任务没做完,返回自身,其作为新的callback
// 被放到当前的task上。while循环完成一次之后,检查shouldYieldToHost,如果需要让出执行权,
// 则中断循环,走到下方,判断currentTask不为null,返回true,说明还有任务,回到performWorkUntilDeadline
// 中,判断还有任务,继续port.postMessage(null),调用监听函数performWorkUntilDeadline,
// 继续执行任务
currentTask.callback = continuationCallback;
markTaskYield(currentTask, currentTime);
} else {
if (enableProfiling) {
markTaskCompleted(currentTask, currentTime);
currentTask.isQueued = false;
}
if (currentTask === peek(taskQueue)) {
pop(taskQueue);
}
}
advanceTimers(currentTime);
} else {
pop(taskQueue);
}
currentTask = peek(taskQueue);
}
// Return whether there's additional work
// return 的结果会作为 performWorkUntilDeadline 中hasMoreWork的依据
// 高优先级任务完成后,currentTask.callback为null,任务从taskQueue中删除,此时队列中还有低优先级任务,
// currentTask = peek(taskQueue) currentTask不为空,说明还有任务,继续postMessage执行workLoop,但它被取消过,导致currentTask.callback为null
// 所以会被删除,此时的taskQueue为空,低优先级的任务重新调度,加入taskQueue
if (currentTask !== null) {
return true;
} else {
const firstTimer = peek(timerQueue);
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
return false;
}
}
解读:workLoop
本身是一个大循环,这个循环非常重要。此时实现了时间切片和fiber树的可中断渲染。首先我们明确一点task
本身采用最小堆根据sortIndex
也即expirationTime
。并通过
peek
方法从taskQueue
中取出来最紧急的任务。
每次while循环的退出就是一个时间切片,详细看下while
循环退出的条件,可以看到一共有两种方式可以退出
队列被清空:这种情况就是正常下情况。见49行从
taskQueue
队列中获取下一个最紧急的任务来执行,如果这个任务为null
,则表示此任务队列被清空。退出workLoop
循环
任务执行超时:在执行任务的过程中由于任务本身过于复杂在执行task.callback之前就会判断是否超时(
shouldYieldToHost
)。如果超时也需要退出循环交给performWorkUntilDeadline
发起下一次调度,与此同时浏览器可以有空闲执行别的任务。因为本身MessageChannel
监听事件是一个异步任务,故可以理解在浏览器执行完别的任务后会继续执行performWorkUntilDeadline
。
这段代码中还包含了十分重要的逻辑(见19~36行),这段代码是实现可中断渲染的关键。具体它们是怎么工作的呢以concurrent
模式下performConcurrentWorkOnRoot
举例:
function performConcurrentWorkOnRoot(root) {
//省略无关代码
const originalCallbackNode = root.callbackNode;
// 省略无关代码
ensureRootIsScheduled(root, now());
if (root.callbackNode === originalCallbackNode) {
// The task node scheduled for this root is the same one that's
// currently executed. Need to return a continuation.
return performConcurrentWorkOnRoot.bind(null, root);
}
return null;
}
这段代码中我们可以看到,在callbackNode === originalCallBackNode
的时候会返回performConcurrentWorkOnRoot
本身,也即workLoop
中19~36行中的continuationCallback
。那么我们可以大概猜测callbackNode
值在ensureRootIsScheduled
函数中被修改了
ensureRootIsScheduled
从这里我们可以看到,callbackNode 是如何被赋值并且修改的。详细见15行,43行注释
function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
const existingCallbackNode = root.callbackNode;
// Check if any lanes are being starved by other work. If so, mark them as
// expired so we know to work on those next.
markStarvedLanesAsExpired(root, currentTime);
// Determine the next lanes to work on, and their priority.
const nextLanes = getNextLanes(
root,
root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
);
// This returns the priority level computed during the `getNextLanes` call.
const newCallbackPriority = returnNextLanesPriority();
// 在fiber树构建、更新完成后。nextLanes会赋值为NoLanes 此时会将callbackNode赋值为null, 表示此任务执行结束
if (nextLanes === NoLanes) {
// Special case: There's nothing to work on.
if (existingCallbackNode !== null) {
cancelCallback(existingCallbackNode);
root.callbackNode = null;
root.callbackPriority = NoLanePriority;
}
return;
}
// 节流防抖
// Check if there's an existing task. We may be able to reuse it.
if (existingCallbackNode !== null) {
const existingCallbackPriority = root.callbackPriority;
if (existingCallbackPriority === newCallbackPriority) {
// The priority hasn't changed. We can reuse the existing task. Exit.
return;
}
// The priority changed. Cancel the existing callback. We'll schedule a new
// one below.
cancelCallback(existingCallbackNode);
}
// Schedule a new callback.
let newCallbackNode;
if (newCallbackPriority === SyncLanePriority) {
// Special case: Sync React callbacks are scheduled on a special
// internal queue
// 开始调度返回newCallbackNode,也即scheduler中的task.
newCallbackNode = scheduleSyncCallback(
performSyncWorkOnRoot.bind(null, root),
);
} else if (newCallbackPriority === SyncBatchedLanePriority) {
newCallbackNode = scheduleCallback(
ImmediateSchedulerPriority,
performSyncWorkOnRoot.bind(null, root),
);
} else {
const schedulerPriorityLevel = lanePriorityToSchedulerPriority(
newCallbackPriority,
);
newCallbackNode = scheduleCallback(
schedulerPriorityLevel,
performConcurrentWorkOnRoot.bind(null, root),
);
}
// 更新标记
root.callbackPriority = newCallbackPriority;
root.callbackNode = newCallbackNode;
}
到这里我们管中窥豹看到了中断渲染原理是如何做的,以及注册调度任务部分、节流防抖部分的代码。下面我们总结下:
时间切片原理:
消费任务队列的过程中, 可以消费1~n
个 task, 甚至清空整个 queue
. 但是在每一次具体执行task.callback
之前都要进行超时检测, 如果超时可以立即退出循环并等待下一次调用。
可中断渲染原理:
在时间切片的基础之上, 如果单个callback
执行的时间过长。就需要task.callback
在执行的时候自己判断下是否超时,所以concurrent
模式下,fiber树每构建完一个单元都会判断是否超时。如果超时则退出循环并返回回调,等待下次调用,完成之前没有完成的fiber
树构建。
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
附言:
其实上面的workLoop
中还有3个相对重要的函数没分析,这里我们简单看下
advanceTimers & handleTimeout
function advanceTimers(currentTime) {
// Check for tasks that are no longer delayed and add them to the queue.
// 检查过期任务队列中不应再被推迟的,放到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);
}
}
function handleTimeout(currentTime) {
// 这个函数的作用是检查timerQueue中的任务,如果有快过期的任务,将它
// 放到taskQueue中,执行掉
// 如果没有快过期的,并且taskQueue中没有任务,那就取出timerQueue中的
// 第一个任务,等它的任务快过期了,执行掉它
isHostTimeoutScheduled = false;
// 检查过期任务队列中不应再被推迟的,放到taskQueue中
advanceTimers(currentTime);
if (!isHostCallbackScheduled) {
if (peek(taskQueue) !== null) {
isHostCallbackScheduled = true;
requestHostCallback(flushWork);
} else {
const firstTimer = peek(timerQueue);
if (firstTimer !== null) {
requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
}
}
}
}
shouldYieldToHost
shouldYieldToHost = function() {
const currentTime = getCurrentTime();
if (currentTime >= deadline) {
// There's no time left. We may want to yield control of the main
// thread, so the browser can perform high priority tasks. The main ones
// are painting and user input. If there's a pending paint or a pending
// input, then we should yield. But if there's neither, then we can
// yield less often while remaining responsive. We'll eventually yield
// regardless, since there could be a pending paint that wasn't
// accompanied by a call to `requestPaint`, or other main thread tasks
// like network events.
if (needsPaint || scheduling.isInputPending()) {
// There is either a pending paint or a pending input.
return true;
}
// There's no pending input. Only yield if we've reached the max
// yield interval.
return currentTime >= maxYieldInterval;
} else {
// There's still time left in the frame.
return false;
}
};
总结:
到这里我们大致阐述了react
Scheduler
任务调度循环的流程,以及时间切片和可中断渲染的原理。这部分是react
的核心,此外甚至在注册调度任务之前还做了节流和防抖等操作。由此我们看的核心的代码并不总是庞大的。respesct!!!
参考资料
[1]
PR: https://github.com/facebook/react/pull/16214
[2]见MDN: https://developer.mozilla.org/zh-CN/docs/Web/API/MessageChannel
[3]源码: https://github.com/facebook/react/blob/v17.0.2/packages/scheduler/src/forks/SchedulerHostConfig.default.js#L224-L230
[4]源码: https://github.com/facebook/react/blob/v17.0.2/packages/scheduler/src/forks/SchedulerHostConfig.default.js#L232-L234
[5]源码: https://github.com/facebook/react/blob/v17.0.2/packages/scheduler/src/forks/SchedulerHostConfig.default.js#L236-L240
[6]源码: https://github.com/facebook/react/blob/v17.0.2/packages/scheduler/src/forks/SchedulerHostConfig.default.js#L242-L245
- END -
关于奇舞团
奇舞团是 360 集团最大的大前端团队,代表集团参与 W3C 和 ECMA 会员(TC39)工作。奇舞团非常重视人才培养,有工程师、讲师、翻译官、业务接口人、团队 Leader 等多种发展方向供员工选择,并辅以提供相应的技术力、专业力、通用力、领导力等培训课程。奇舞团以开放和求贤的心态欢迎各种优秀人才关注和加入奇舞团。