对于 React Scheduler
,它通过将任务切片并异步执行,避免了阻塞浏览器的主线程。
很多人其实都看到过类似的文章了,甚至说去手写调度器,都写的很不错,所以本文将从一个新的角度探讨 React Scheduler
,揭示它是如何利用几个简单的 API 实现这一壮举的。
React Scheduler
解析
首先,让我们回顾一下浏览器的任务类型:宏任务和微任务,以及它们是如何在事件循环中执行的。
在浏览器的一次事件循环中,包括宏任务、微任务和页面渲染三个部分。如果我们假设屏幕的刷新率是 60FPS,那么大约每 16ms 就会执行一次事件循环。
这 16ms 内我们还要留些事件给浏览器处理页面渲染。而剩余的时间我们就需要尽可能的充分利用。
那么我们怎么知道还剩多少空余时间呢,可以使用 requestIdleCallback
方法,它会插入一个函数,并在浏览器空闲的时候执行,同时传入一个参数包含剩下的空余时间。
但是实际上并没有去使用 requestIdleCallback
去处理,为什么没有使用 requestIdleCallback
去实现呢?
-
兼容性问题
虽然大部分主流浏览器都支持该方法,但是 Safari 等部分浏览器却仍不支持。
requestIdleCallback – Web API | MDN -
不确定性
requestIdleCallback
只会在浏览器空闲的时候去调用,这意味着它的执行有很大的不确定性,有可能会有较高的延迟。这对于一些高响应的任务,是没办法处理的。 -
一致性
requestIdleCallback
属于浏览器 API,脱离了浏览器之后没办法使用。
有了这个时间,我们就可以去执行空余时间这么长的任务了,但是我们怎么去确定我们的任务去执行多长时间呢?
时间分片
当然,这个我们肯定确认不了,这里我们就需要提到 React Scheduler 的一个精髓:时间分片。
它将大任务分割成无数个小任务,并在每次执行时判断是否还有空余时间,如果有,则继续执行;如果没有,则在下一次事件循环中继续。
而任务的细分属于代码层面的内容,实现的方式没有限制,但宏观上就是分成了一个个小任务。简单示例一下:
for (let i = 0; i < 1000000000000; i++) {
console.log(`执行第 ${i + 1} 个任务`)
}
let curTask = 0
function task() {
console.log(`执行第 ${curTask} 个任务`)
}
拆分完成之后,我们就可以实现上面的时间分片了,但这里还有一个问题,这次事件循环里的时间被充分利用了,但是怎么到下一个事件循环还能继续执行呢?
再回头看一下事件循环的执行顺序,宏任务 → 微任务 → 页面渲染,那么我们就需要在宏任务中插入一条任务来保持分片任务能继续往下执行,这里就需要用到 MessageChannel
了。
至于为什么使用 MessageChannel
可以看看这篇文章:
React Scheduler 为什么使用 MessageChannel 实现React Scheduler 为什么使用 – 掘金 (juejin.cn)
利用 MessageChannel
实现宏任务的插入,我们就能给 时间分片 实现一个闭环,我们来看看吧。
const yieldInterval = 5
let deadline
let scheduleCallback
function task(curTask) {
console.log(`执行第 ${curTask} 个任务`)
}
const taskQueue = Array.from({ length: 1000000 }).map((_, i) => () => task(i))
const init = () => {
const channel = new MessageChannel()
const port = channel.port2
const shouldYieldToHost = () => {
return performance.now() >= deadline
}
channel.port1.onmessage = () => {
if (taskQueue.length > 0) {
deadline = performance.now() + yieldInterval
while (!shouldYieldToHost() && taskQueue.length > 0) {
taskQueue.shift()()
}
if (taskQueue.length > 0) {
port.postMessage(null)
}
}
}
scheduleCallback = () => {
port.postMessage(null)
}
}
init()
scheduleCallback()
功能上就是把之前理论的东西做了实践,包括空余时间的使用,宏任务的调度…
这里通过 performance.now()
来实现空余时间的执行,而没有使用 requestIdleCallback
,原因后面会解释。
到这里,其实核心的知识都了解的差不多了,主要就是
- 任务分片
- 宏任务调度(保证能持续执行)
- 充分利用浏览器空余时间
最终实现了 React Scheduler
的 时间分片 效果。
React Scheduler
精简版 —— Solid
最近在研究 Solid 的过程中,看到了 Solid 对于 React Scheduler 的引用,并稍作修改;并没有实现像 lane 车道等一下内容,只是实现了一个最精简的版本。
同时 Solid 也在 transition
等延迟处理的一下内容中用到了这一块内容。
接下来,我们来看一个关于 React Scheduler 精简版的实现。
基本原理和上面的实现差不多,只是多了些细节的把控。
分析
Solid 的实现与上述原理基本一致,但增加了一些细节控制。以下是初始化函数 setupScheduler
的实现( 对应 init
):
function setupScheduler() {
const channel = new MessageChannel(),
port = channel.port2;
scheduleCallback = () => port.postMessage(null);
channel.port1.onmessage = () => {
if (scheduledCallback !== null) {
const currentTime = performance.now();
deadline = currentTime + yieldInterval;
const hasTimeRemaining = true;
try {
const hasMoreWork = scheduledCallback(hasTimeRemaining, currentTime);
if (!hasMoreWork) {
scheduledCallback = null;
} else port.postMessage(null);
} catch (error) {
port.postMessage(null);
throw error;
}
}
};
if (
navigator &&
(navigator as NavigatorScheduling).scheduling &&
(navigator as NavigatorScheduling).scheduling.isInputPending
) {
const scheduling = (navigator as NavigatorScheduling).scheduling;
shouldYieldToHost = () => {
const currentTime = performance.now();
if (currentTime >= deadline) {
if (scheduling.isInputPending!()) {
return true;
}
return currentTime >= maxYieldInterval;
} else {
return false;
}
};
} else {
shouldYieldToHost = () => performance.now() >= deadline;
}
}
前半部分的原理和之前的基本一致,利用 MessageChannel
去调度宏任务,这里的任务执行是通过 scheduledCallback
去处理的。
后半部分的话,是处理 shouldYieldToHost
方法,用于判断是否需要让出主线程。
Scheduling
这里 Solid 进行了特殊处理,利用 navigator.scheduling
去获取浏览器的调度信息再进行处理,更好的利用时间去处理任务,但 navigator.scheduling
目前还属于实验性 API。
isInputPending
尤其是里面的 isInputPending
方法,可以看一下 MDN 的介绍:
Scheduling
接口的isInputPending
方法允许您检查事件队列中是否有待处理的输入事件,这表明用户正在尝试与页面交互。 如果您要运行任务队列,并且您希望定期让位于主线程以允许用户交互,以便应用程序尽可能保持响应式和高性能,则此功能非常有用。isInputPending
允许你只在有 input 待处理时让步,而不必以任意间隔进行。
有了此方法,可以最大限度去调度任务,同时在观察到用户对页面进行交互后把线程交还给浏览器。
接下来,我看看 scheduledCallback
的实现,也就是任务的执行逻辑(对应 while
):
function flushWork(hasTimeRemaining: boolean, initialTime: number) {
isCallbackScheduled = false;
isPerformingWork = true;
try {
return workLoop(hasTimeRemaining, initialTime);
} finally {
currentTask = null;
isPerformingWork = false;
}
}
里面主要是对状态做了一些处理,主要看 workLoop
函数。
function workLoop(hasTimeRemaining: boolean, initialTime: number) {
let currentTime = initialTime;
currentTask = taskQueue[0] || null;
while (currentTask !== null) {
if (currentTask.expirationTime > currentTime && (!hasTimeRemaining || shouldYieldToHost!())) {
break;
}
const callback = currentTask.fn;
if (callback !== null) {
currentTask.fn = null;
const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
callback(didUserCallbackTimeout);
currentTime = performance.now();
if (currentTask === taskQueue[0]) {
taskQueue.shift();
}
} else taskQueue.shift();
currentTask = taskQueue[0] || null;
}
return currentTask !== null;
}
接下来,我们看看任务是怎么加入队列的:requestCallback
export function requestCallback(fn: () => void, options?: { timeout: number }): Task {
if (!scheduleCallback) setupScheduler();
let startTime = performance.now(),
timeout = maxSigned31BitInt;
if (options && options.timeout) timeout = options.timeout;
const newTask: Task = {
id: taskIdCounter++,
fn,
startTime,
expirationTime: startTime + timeout
};
enqueue(taskQueue, newTask);
if (!isCallbackScheduled && !isPerformingWork) {
isCallbackScheduled = true;
scheduledCallback = flushWork;
scheduleCallback!();
}
return newTask;
}
整体过程就是创建任务 → 加入任务队列 → 调度任务。
最后来看看 enqueue
里对任务做了什么处理。
function enqueue(taskQueue: Task[], task: Task) {
function findIndex() {
let m = 0;
let n = taskQueue.length - 1;
while (m <= n) {
const k = (n + m) >> 1;
const cmp = task.expirationTime - taskQueue[k].expirationTime;
if (cmp > 0) m = k + 1;
else if (cmp < 0) n = k - 1;
else return k;
}
return m;
}
taskQueue.splice(findIndex(), 0, task);
}
可以看出来,任务队列整体是按照过期时间进行排序的。
通过二分的方式,找到合适的位置进行插入即可。
其实整体看下来也没什么,和之前给的那个案例的原理是完全一致的,只是多了细节的处理。
只要了解了原理,其他这些额外的内容都是很好理解的。
最后贴一下 Solid 实现的 React Scheduler 的精简版的完整代码,可以梳理一下整体过程:
export interface Task {
id: number;
fn: ((didTimeout: boolean) => void) | null;
startTime: number;
expirationTime: number;
}
type NavigatorScheduling = Navigator & {
scheduling: { isInputPending?: () => boolean };
};
let taskIdCounter = 1,
isCallbackScheduled = false,
isPerformingWork = false,
taskQueue: Task[] = [],
currentTask: Task | null = null,
shouldYieldToHost: (() => boolean) | null = null,
yieldInterval = 5,
deadline = 0,
maxYieldInterval = 300,
scheduleCallback: (() => void) | null = null,
scheduledCallback: ((hasTimeRemaining: boolean, initialTime: number) => boolean) | null = null;
const maxSigned31BitInt = 1073741823;
function setupScheduler() {
const channel = new MessageChannel(),
port = channel.port2;
scheduleCallback = () => port.postMessage(null);
channel.port1.onmessage = () => {
if (scheduledCallback !== null) {
const currentTime = performance.now();
deadline = currentTime + yieldInterval;
const hasTimeRemaining = true;
try {
const hasMoreWork = scheduledCallback(hasTimeRemaining, currentTime);
if (!hasMoreWork) {
scheduledCallback = null;
} else port.postMessage(null);
} catch (error) {
port.postMessage(null);
throw error;
}
}
};
if (
navigator &&
(navigator as NavigatorScheduling).scheduling &&
(navigator as NavigatorScheduling).scheduling.isInputPending
) {
const scheduling = (navigator as NavigatorScheduling).scheduling;
shouldYieldToHost = () => {
const currentTime = performance.now();
if (currentTime >= deadline) {
if (scheduling.isInputPending!()) {
return true;
}
return currentTime >= maxYieldInterval;
} else {
return false;
}
};
} else {
shouldYieldToHost = () => performance.now() >= deadline;
}
}
function enqueue(taskQueue: Task[], task: Task) {
function findIndex() {
let m = 0;
let n = taskQueue.length - 1;
while (m <= n) {
const k = (n + m) >> 1;
const cmp = task.expirationTime - taskQueue[k].expirationTime;
if (cmp > 0) m = k + 1;
else if (cmp < 0) n = k - 1;
else return k;
}
return m;
}
taskQueue.splice(findIndex(), 0, task);
}
export function requestCallback(fn: () => void, options?: { timeout: number }): Task {
if (!scheduleCallback) setupScheduler();
let startTime = performance.now(),
timeout = maxSigned31BitInt;
if (options && options.timeout) timeout = options.timeout;
const newTask: Task = {
id: taskIdCounter++,
fn,
startTime,
expirationTime: startTime + timeout
};
enqueue(taskQueue, newTask);
if (!isCallbackScheduled && !isPerformingWork) {
isCallbackScheduled = true;
scheduledCallback = flushWork;
scheduleCallback!();
}
return newTask;
}
export function cancelCallback(task: Task) {
task.fn = null;
}
function flushWork(hasTimeRemaining: boolean, initialTime: number) {
isCallbackScheduled = false;
isPerformingWork = true;
try {
return workLoop(hasTimeRemaining, initialTime);
} finally {
currentTask = null;
isPerformingWork = false;
}
}
function workLoop(hasTimeRemaining: boolean, initialTime: number) {
let currentTime = initialTime;
currentTask = taskQueue[0] || null;
while (currentTask !== null) {
if (currentTask.expirationTime > currentTime && (!hasTimeRemaining || shouldYieldToHost!())) {
break;
}
const callback = currentTask.fn;
if (callback !== null) {
currentTask.fn = null;
const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
callback(didUserCallbackTimeout);
currentTime = performance.now();
if (currentTask === taskQueue[0]) {
taskQueue.shift();
}
} else taskQueue.shift();
currentTask = taskQueue[0] || null;
}
return currentTask !== null;
}
1、本站所有资源均从互联网上收集整理而来,仅供学习交流之用,因此不包含技术服务请大家谅解!
2、本站不提供任何实质性的付费和支付资源,所有需要积分下载的资源均为网站运营赞助费用或者线下劳务费用!
3、本站所有资源仅用于学习及研究使用,您必须在下载后的24小时内删除所下载资源,切勿用于商业用途,否则由此引发的法律纠纷及连带责任本站和发布者概不承担!
4、本站站内提供的所有可下载资源,本站保证未做任何负面改动(不包含修复bug和完善功能等正面优化或二次开发),但本站不保证资源的准确性、安全性和完整性,用户下载后自行斟酌,我们以交流学习为目的,并不是所有的源码都100%无错或无bug!如有链接无法下载、失效或广告,请联系客服处理!
5、本站资源除标明原创外均来自网络整理,版权归原作者或本站特约原创作者所有,如侵犯到您的合法权益,请立即告知本站,本站将及时予与删除并致以最深的歉意!
6、如果您也有好的资源或教程,您可以投稿发布,成功分享后有站币奖励和额外收入!
7、如果您喜欢该资源,请支持官方正版资源,以得到更好的正版服务!
8、请您认真阅读上述内容,注册本站用户或下载本站资源即您同意上述内容!
原文链接:https://www.shuli.cc/?p=21326,转载请注明出处。