本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
本篇是 React 基础与进阶系列第 10 篇,关注专栏
前言
在上篇《React 之 requestIdleCallback 来了解一下》,我们讲解了 requestIdleCallback 这个 API,它可以实现在浏览器空闲的时候执行代码,这就与 React 的理念非常相似,都希望执行的时候不影响到关键事件,比如动画和输入响应,但因为兼容性、requestIdleCallback 定位于执行后台和低优先级任务、执行频率等问题,React 在具体的实现中并未采用 requestIdleCallback,本篇我们来讲讲 React 是如何实现类似于 requestIdleCallback,在空闲时期执行代码的效果,并由此讲解时间切片的背后实现。
setTimeout 模拟
MDN 提供了一种使用 setTimeout 的兜底实现:
window.requestIdleCallback = window.requestIdleCallback || function(handler) {
let startTime = Date.now();
return setTimeout(function() {
handler({
didTimeout: false,
timeRemaining: function() {
return Math.max(0, 50.0 - (Date.now() - startTime));
}
});
}, 1);
}
但这个并不是 polyfill ,因为它在功能上并不相同。使用 setTimeout() 并不能像 requestIdleCallback 一样做到在空闲时段执行代码。
postMessage 介绍
那如何实现空闲时期执行呢?React 早期采用的是 requestAnimationFrame + postMessage 来实现的,requestAnimationFrame 我们在 《React 之 requestAnimationFrame 执行机制探索》介绍过,我们简单说说 postMessage。
引用 MDN 的介绍:
window.postMessage() 方法可以安全地实现跨源通信。
它的简单使用示例如下:
window.addEventListener("message", (e) => {console.log(e.data)}, false);
window.postMessage('Hello World!')
React v16 实现(rAF + postMessage)
那 React 是怎么实现的呢?我们可以查看 React 16.0.0 版本里的实现,这里为了方便理解,将代码极度简化了:
// Polyfill requestIdleCallback.
var scheduledRICCallback = null;
var frameDeadline = 0;
// 假设 30fps,一秒就是 33ms
var activeFrameTime = 33;
var frameDeadlineObject = {
timeRemaining: function() {
return frameDeadline - performance.now();
}
};
var idleTick = function(event) {
scheduledRICCallback(frameDeadlineObject);
};
window.addEventListener('message', idleTick, false);
var animationTick = function(rafTime) {
frameDeadline = rafTime + activeFrameTime;
window.postMessage('__reactIdleCallback$1', '*');
};
var rIC = function(callback) {
scheduledRICCallback = callback;
requestAnimationFrame(animationTick);
return 0;
};
逻辑很简单,rIC
就是 requestIdleCallback 的简写,rIC
函数执行的时候,调用 requestAnimationFrame(animationTick)
,animationTick 会被传入当前帧执行的时间(rafTime),我们假设要保持最低的 30fps,一帧就是 33ms,我们就可以得知这帧最晚应该在 frameDeadline = rafTime + activeFrameTime
前结束。
然后我们通过 postMessage 进行通信,通信的内容并不重要,重点是浏览器会被推入一个宏任务,也就是 idleTick
函数,我们通过 frameDeadline - performance.now()
就可以算出这帧还剩多少时间,然后将包含这个信息的对象(frameDeadlineObject)传入 callback 函数,由此实现了 requestIdleCallback 的模拟。
那你可能会问,“空闲时期执行”这个效果怎么就实现了呢?我们之想要实现空闲时期执行,想要达到的效果是不阻碍浏览器对动画和用户输入的处理,我们没有直接执行 callback 回调,而是用 postMessage 推入到任务列表中,浏览器就可以响应更高优先级的动画或者用户输入,等执行完再去我们推入的任务。这效果不就一样达成了吗?
那你可能会问,这不就是延时执行?为什么不用 setTimeout 呢?
确实也可以,但当你设置 setTimeout(fn, 0)
的时候,虽然想表达的意思是马上执行,但浏览器在实现的时候会有一个最低 4ms 的间隔,如果你想在浏览器中实现 0ms 延时的定时器,应该借助 postMessage,这也是 MDN 文档给的建议。
取消使用 rAF
随着代码的不断变更,实现又发生了一些变化,比如 React 在 v16.2.0 版本取消了使用 requestAnimationFrame,为什么会取消 rAF 呢?这个 PR 进行了回答。
回顾之前的实现,我们是通过猜测这帧的结束时间,然后在这帧大概结束的时候再让出线程,但实际上没有必要这样实现。我们的想法是,将 React 的更新做成一个任务列表,我们不直接遍历这个任务列表,而是每执行几个任务,就调用 postMessage,告诉浏览器,我不遍历任务了,线程给你,你先忙,等处理完再接着遍历任务列表中剩下的任务。这样我们就不用管这帧是什么时候结束。为了方便起见,我们将这种实现方式叫做 message loop。
而且为了提高性能和电池寿命,在大多数浏览器里,当 requestAnimationFrame() 运行在后台标签页或者隐藏的<iframe>
里时,requestAnimationFrame() 会被暂停调用以提升性能和电池寿命。这对 React 的执行也会有一定的影响。
那 message loop 具体是怎么实现的呢?我们先额外介绍一下 MessageChannel
MessageChannel 介绍
web 通信(web messaging)有两种方式,一种是跨文档通信(cross-document messaging),也就是我们熟知的 window.postMessage()
,常被用于与 iframe 之间的通信,一种是通道通信(channel messaging),也就是我们现在要介绍的。
MessageChannel 接口允许我们创建一个新的消息通道,并通过它的两个 MessagePort 属性发送数据,使用示例如下:
var channel = new MessageChannel();
channel.port1.onmessage = (e) => {console.log(e.data)}
channel.port2.postMessage('Hello World')
了解过事件循环的同学应该知道宏任务与微任务,它们又被称为“macro task”和“micro task” ,像 promise 就属于微任务,setTimeout 属于宏任务,MessageChannel 跟 setTimeout 一样,也属于宏任务。知道这点就够了。
React v18 实现(message loop)
写这篇文章的时候,React 的版本是 v18.2.0,代码为了方便阅读,同样做了简化:
// 记录 callback
let scheduledHostCallback;
let isMessageLoopRunning = false;
let getCurrentTime = () => performance.now();
let startTime;
// rIC 更名为 requestHostCallback
function requestHostCallback(callback) {
scheduledHostCallback = callback;
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
schedulePerformWorkUntilDeadline();
}
}
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
let schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
function performWorkUntilDeadline() {
if (scheduledHostCallback !== null) {
const currentTime = getCurrentTime();
startTime = currentTime;
const hasTimeRemaining = true;
let hasMoreWork = true;
try {
hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
} finally {
if (hasMoreWork) {
schedulePerformWorkUntilDeadline();
} else {
isMessageLoopRunning = false;
scheduledHostCallback = null;
}
}
} else {
isMessageLoopRunning = false;
}
};
可以看到在当前的实现中,原来的 rIC
改名为 requestHostCallback
,requestHostCallback 函数执行的时候,会调用 schedulePerformWorkUntilDeadline
,它只做了一件事情,就是 postMessage
,通知浏览器等忙完自己的事情,再执行 performWorkUntilDeadline
。
performWorkUntilDeadline 中会执行 scheduledHostCallback(hasTimeRemaining, currentTime)
,scheduledHostCallback 就是传入 requestHostCallback 的 callback 函数,它返回一个布尔值告诉 performWorkUntilDeadline 是否还有更多任务,如果有,那就调用 schedulePerformWorkUntilDeadline
,告诉浏览器等会再接着干,由此实现了任务小量执行,不断让出线程,从而保证浏览器能够及时处理动画或者用户输入。
那 scheduledHostCallback 到底是什么呢?我们可以知道它就是调用 requestHostCallback 时传入的函数,但这个被传入的函数执行的内容是什么呢?
既然都已经看了 React 的 Scheduler 源码,来都来了,我们就再多看一点。
我们会发现 requestHostCallback 虽然有三处被调用,但传入的函数都是 flushWork 这个函数:
requestHostCallback(flushWork);
所以 performWorkUntilDeadline 中执行的 scheduledHostCallback(hasTimeRemaining, currentTime)
,其实就相当于执行 flushWork(hasTimeRemaining, currentTime)
flushWork 做了什么呢?我们将 flushWork 相关的代码拿出来,这里同样做了简化:
function flushWork(hasTimeRemaining, initialTime) {
return workLoop(hasTimeRemaining, initialTime);
}
var currentTask;
function workLoop(hasTimeRemaining, initialTime) {
currentTask = taskQueue[0];
while (currentTask != null) {
if (
currentTask.expirationTime > initialTime &&
(!hasTimeRemaining || shouldYieldToHost())
) {
break;
}
const callback = currentTask.callback;
callback();
taskQueue.shift()
currentTask = taskQueue[0];
}
if (currentTask !== null) {
return true;
} else {
return false;
}
}
// 默认的时间切片
const frameInterval = 5;
function shouldYieldToHost() {
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
return false;
}
return true;
}
我们可以看到在 flushWork 中,我们调用的是 workLoop 函数,在 workLoop 中,我们取出任务列表中的第一个任务,然后判断任务是否过期,以及是否应该让出线程(shouldYieldToHost)
在 shouldYieldToHost 中,getCurrentTime()
表示当前的时间,startTime
我们是在收到 postMessage 的消息时执行 performWorkUntilDeadline 函数的时间,performWorkUntilDeadline 中,我们执行 scheduledHostCallback(hasTimeRemaining, currentTime)
,也就是 flushWork(hasTimeRemaining, initialTime)
,也就是 workLoop(hasTimeRemaining, initialTime)
,workLoop 中我们又进行了 shouldYieldToHost 的判断。
那你可能要说,从 performWorkUntilDeadline 到 shouldYieldToHost,这中间全是同步操作,就没隔多少时间呀,确实如此,执行第一个任务的时候,getCurrentTime() 和 startTime 确实没有隔多少时间,耗时的任务是 taskQueue 中的任务,也就是 const callback = currentTask.callback; callback()
这里。
等跑完第一个任务,我们又会判断一次 shouldYieldToHost,如果还不超过 5ms(默认的时间切片时间),我们就接着执行任务,再判断 shouldYieldToHost,直到任务列表空了,或者过了 5ms。
如果过了 5ms,发现还有任务,hasMoreWork 是 true,然后会执行 schedulePerformWorkUntilDeadline
,也就是再执行一个 port.postMessage(null)
,这样就让出了线程,让浏览器能够处理动画和用户输入,等浏览器处理完,它会执行 port1.onmessage 推入的 performWorkUntilDeadline
任务,在 performWorkUntilDeadline
中又接着遍历执行任务列表,就这样每执行 5ms 的任务,就让出线程,直到完成任务列表中的所有任务。
时间切片
现在我们终于得知了 React 时间切片的真相。
React 把 React 的更新操作做成了一个个任务,塞进了 taskQueue,也就是任务列表,如果直接遍历执行这个任务列表,纯同步操作,执行期间,浏览器无法响应动画或者用户的输入,于是借助 MessageChannel,依然是遍历执行任务,但当每个任务执行完,就会判断过了多久,如果没有过默认的切片时间(5ms),那就再执行一个任务,如果过了,那就调用 postMessage,让出线程,等浏览器处理完动画或者用户输入,就会执行 onmessage 推入的任务,接着遍历执行任务列表。
一个完整可用的简化版
// 用于模拟代码执行耗费时间
const sleep = delay => {
for (let start = Date.now(); Date.now() - start <= delay;) {}
}
// performWorkUntilDeadline 的执行时间,也就是一次批量任务执行的开始时间,通过现在的时间 - startTime,来判断是否超过了切片时间
let startTime;
let scheduledHostCallback;
let isMessageLoopRunning = false;
let getCurrentTime = () => performance.now();
const taskQueue = [{
expirationTime: 1000000,
callback: () => {
sleep(30);
console.log(1)
}
}, {
expirationTime: 1000000,
callback: () => {
sleep(30);
console.log(2)
}
}, {
expirationTime: 1000000,
callback: () => {
sleep(30);
console.log(3)
}
}]
function requestHostCallback(callback) {
scheduledHostCallback = callback;
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
schedulePerformWorkUntilDeadline();
}
}
const channel = new MessageChannel();
const port = channel.port2;
function performWorkUntilDeadline() {
if (scheduledHostCallback !== null) {
const currentTime = getCurrentTime();
startTime = currentTime;
const hasTimeRemaining = true;
let hasMoreWork = true;
try {
hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
} finally {
console.log('hasMoreWork', hasMoreWork)
if (hasMoreWork) {
schedulePerformWorkUntilDeadline();
} else {
isMessageLoopRunning = false;
scheduledHostCallback = null;
}
}
} else {
isMessageLoopRunning = false;
}
};
channel.port1.onmessage = performWorkUntilDeadline;
let schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
function flushWork(hasTimeRemaining, initialTime) {
return workLoop(hasTimeRemaining, initialTime);
}
let currentTask;
function workLoop(hasTimeRemaining, initialTime) {
currentTask = taskQueue[0];
while (currentTask != null) {
console.log(currentTask)
if (
currentTask.expirationTime > initialTime &&
(!hasTimeRemaining || shouldYieldToHost())
) {
break;
}
const callback = currentTask.callback;
callback();
taskQueue.shift()
currentTask = taskQueue[0];
}
if (currentTask != null) {
return true;
} else {
return false;
}
}
const frameInterval = 5;
function shouldYieldToHost() {
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
return false;
}
return true;
}
requestHostCallback(flushWork)
最后
从讲解 FPS 到 requestAnimationFrame 再到 requestIdleCallback,再到本篇的时间切片,算是为 React 系列的第一个大篇章 —— Scheduler 篇拉开了序幕。我们开始讲 React 的重要组成部分,Scheduler(调度器)。
React 系列
- React 之 createElement 源码解读
- React 之元素与组件的区别
- React 之 Refs 的使用和 forwardRef 的源码解读
- React 之 Context 的变迁与背后实现
- React 之 Race Condition
- React 之 Suspense
- React 之从视觉暂留到 FPS、刷新率再到显卡、垂直同步再到16ms的故事
- React 之 requestAnimationFrame 执行机制探索
- React 之 requestIdleCallback 来了解一下
React 系列的预热系列,带大家从源码的角度深入理解 React 的各个 API 和执行过程,全目录不知道多少篇,预计写个 50 篇吧。