我们知道React V18中,提出的最重要的概念就是并发。
这里我们要解释下并发,并发是具备处理多个任务的能力,但是不是同一时刻执行,而是交替执行。也就是说每次只能执行一个。
React 中的并发
我们知道js是单线程语言,JS引擎和渲染引擎在一个进程中,如果JS线程长时间占用了主线程,渲染进行就会等待,如果界面长时间不更新,用户的操作不能及时响应,看上去就会卡顿。
所以React想要能够优先的处理像高优先级的任务(如用户交互,点击,滚动等),同时中断延后低优先级的任务。
所以要想实现并发,React需要达到两个目标:
- js暂停,将主线程还给浏览器,让浏览器能够有序的重新渲染页面
- 需要执行暂停中的js任务。
浏览器的事件循环
我们知道浏览器的事件循环机制是从事件队列中取出一个任务执行,如果还没有到浏览器更新渲染的时间,就继续从任务队列中取出任务执行, 如果达到了浏览器渲染的时间,就会执行浏览器的渲染任务,依次循环。
这里提到了浏览器的渲染时间,解释一下:我们知道浏览器的页面也是一帧一帧画出来的,只要画的够快,就不会让人的视觉上感到卡顿。浏览器绘制一帧需要16.6ms。 这16.6ms中需要做很多事情:用户的输入,执行js脚本,处理窗口的变更,滚动,媒体查询,动画,然后执行requestAnimationFrame 的回调,最后布局绘制。如果还有时间就会执行requestIdleCallback 的回调函数。
所以我们要想让用户不感觉到卡顿,那就是让浏览器到底渲染时间的时候,就将主线程交给渲染进程。
这时候我们就会把目光放在了requestIdleCallback上了,这不正是我们想要的目的吗? 既要执行任务,又不耽误浏览器渲染。
requestIdleCallback
先看看MDN的解释:
window.requestIdleCallback()
方法插入一个函数,这个函数将在浏览器空闲时期 被调用。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间timeout
,则有可能为了在超时前执行函数而打乱执行顺序。
看到这个解释,和React的理念十分相似,很可惜的是React并没有使用这个API,因为兼容太差了。
接下来就看看React是如何实现的呢?
MessageChannle
MessageChannle 接口本身是用来发送消息的, 允许我们创建一个消息通道, 通过两个MessagePort来进行消息的发送和接收。
我们现在再回顾以下React的目的:
- js暂停,将主线程还给浏览器,让浏览器能够有序的重新渲染页面
- 需要执行暂停中的js任务。
这里就会想到事件循环, 我们可以将没有执行完的js 放在任务队列, 下次事件循环再将事件循环中取出。
那如何将没有执行完的任务放在任务队列中呢?
这里需要产生一个宏任务, MessageChannel能够产生宏任务。这样就达到我们插队的目的了。
用法:
ini
const channel = new MessageChannel();
const output = document.querySelector(".output");
const iframe = document.querySelector("iframe");
// 等待 iframe 加载
iframe.addEventListener("load", onLoad);
function onLoad() {
// 在 port1 上监听消息
channel.port1.onmessage = onMessage;
// 将 port 2 传输到 iframe
iframe.contentWindow.postMessage("来自主页的您好!", "*", [channel.port2]);
}
// 处理 port 1 收到的消息
function onMessage(e) {
output.innerHTML = e.data;
}
为什么不使用setTimeOut(fn,0)这种方式呢?
因为setTimeout再层级超过5层之后, timeout小于4ms则会设置为10ms。 所以React没有选择setTimeout来产生宏任务。
为什么不使用requestAnimationFrame
因为这个只能在重新渲染之前才能执行一次。这样就会导致没开始渲染的时候, 就继续从任务队列中取消息。并且兼容性有问题, Safati和edge 是在页面渲染完成之后执行。
为什么不包装成微任务?
这是因为和微任务的执行机制有关系, 微任务队列会在清空整个队列之后才会结束。那么微任务会在页面更新前一直执行,达不到将主线程还给主线程目的。
Scheduler
上文中提到了React现在完成了如何对事件队列的插队,下一步就需要知道 React 怎么判断插队
任务优先级
首先Scheduler给任务定义了任务优先级(每个等级有延迟时间,计算任务的开始时间),延时时间最长,优先级最低,可以看看源码(V18):

可以看到React对任务分的很细(V16中只有几个)。
时间切片
为了避免单个任务占用主线程时间过长,引入了时间片的概念, 每个任务会被分配一个时间片,在时间片内可以持续执行, 一旦时间片用完,任务会主动让出主线程。
React 创建两个任务队列, 普通任务队列和延时队列。 当有任务需要调度时, 会创建一个包含回调函数,优先级,开始事件,过期时间的任务对象, 如果任务时间还未开始,会被加到延时队列, 如果已经开始会被加到普通任务队列。
Scheduler依赖浏览器的事件循环机制 来触发任务的调度, 当主线程空闲时,会启动调度循环,循环开始时,会先判断延时队列中查看是否已经到达开始时间的任务,如果有就会将这个任务放在普通任务队列中。
Scheduler会在普通任务队列中选择优先级最高(也就是过期时间最早)的任务进行执行,在执行任务的回调函数中会检查是否有时间片的限制(5ms:源码中的常量), 如果时间片用完,任务会被暂停,放回普通任务队列中。
执行完毕之后 会将任务从任务队列中移除,依次循环。
React 实际源码简化(Scheduler 核心逻辑):
scss
// React 源码中的时间片管理(简化)
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
if (workInProgress !== null) {
// 时间片用尽,重新调度
scheduleCallback();
}
}
// 检查是否应让出主线程
function shouldYield() {
return performance.now() >= deadline;
}
React 时间切片与任务优先级模拟
scss
// 简化版任务队列(React Scheduler 核心逻辑)
const taskQueue = [];
const frameInterval = 5; // 5ms时间片
function workLoop(deadline) {
while (taskQueue.length > 0 && deadline.timeRemaining() > 0) {
const task = taskQueue.pop();
task.execute(); // 执行任务
}
if (taskQueue.length > 0) {
// 继续调度剩余任务
requestIdleCallback(workLoop);
}
}
// 添加任务(带优先级)
function scheduleTask(task, priority) {
taskQueue.push({ ...task, priority });
taskQueue.sort((a, b) => a.priority - b.priority); // 按优先级排序
requestIdleCallback(workLoop);
}
// 示例:高优先级任务(如用户输入)
scheduleTask({
execute: () => console.log('High priority task'),
}, 1);
// 低优先级任务(如数据分析)
scheduleTask({
execute: () => console.log('Low priority task'),
}, 2);
总结
React 将更新的操作包装成一个个任务,放在队列里维护,如果直接同步遍历,在这个期间,浏览器就没有其他时间响应用户的输入等高优先级的任务。 于是借助了MessageChannel 包装成异步任务,依然是遍历任务,只是每次执行任务的时候,判断是否过了被分配的事件(5ms),如果时间没有了,就在调用postMessage 让出主线程,等浏览器完成高优先级任务后,重新执行 onMessage 再推入,继续遍历任务列表。
参考文章: