最近一直在看react源码,react搭建fiber树的逻辑还是比较容易理解的,但是说到任务调度相关的逻辑,就显得比较混乱了。参考了一些资料和react调度器的源码后,决定实现一个简单版的调度器。相信按照本文的思路,你就能理解为什么react需要一个调度器来调度任务。
简单的背景知识:
我们知道现在大部分设备的帧率都是60fps,也就是说浏览器每16.7ms就会绘制一次。如果页面上有一些动画,那么16.7s绘制一次,看起来更流畅。
我们先写一个简单的CSS动画:一个普通的div左右滑动
<!DOCTYPE html >
< html lang = "en" >
<头>
<元字符集=“UTF-8”>
< meta http-equiv = "X-UA-Compatible" 内容 = "IE=edge" >
<元名称=“视口”内容=“宽度=设备宽度,初始比例=1.0”>
<title>文档</ title >
<风格>
#block { 宽度 : 50px ;高度:50px;边距:0 0;背景颜色:#ddd;动画:移动5s线性无限;位置:绝对; } @keyframes 移动 { 0% { 左 : 0 ; } 25% { 左:100px ; } 50% { 左:200px ; } 75% { 左 : 100px ; } 100% { 左:0 ; } }</ style >
</ head >
<身体>
< div id = "块" ></ div >
</ body >
</ html >
复制代码
使用 Google Chrome 的性能记录面板查看:
在主线程上,一帧的时间是16.7ms。让我们放大看看浏览器在一帧时间内做了什么:
要完成一个绘图,您需要执行 Schedule Style Recalculation、Recalculate Style、Layout、Pre-Paint、Paint、Composite Layers。这里我们不详细介绍浏览器在每个阶段做了什么,只注意渲染是在主线程上完成的,由 CPU 完成。通常浏览器会每 16.7ms 绘制一次,但如果在这个事件循环中有任务在执行,则需要等待任务完成才能进行绘制。如果任务耗时过长,就会减少抽奖次数,也就是所谓的“丢帧”。因为我们现在有一个非常简单的页面,没有 js 任务,浏览器每 16.7ms 绘制一次,动画看起来很流畅。
现在让我们添加一个按钮。点击后会创建5个任务。每个任务需要 20 毫秒,并且会立即执行。
<身体>
<按钮ID =“btn”>点击我</ button >
</ body >
复制代码
绑定事件:
常量作品 = [];
常量 btn = 文档。 getElementById('btn');
btn。 onclick = 函数 () {
for ( 让 i = 0; i < 5; i++) {
作品。推(宏任务)
}
冲洗工作();
}
函数宏任务(){
常量开始 = 新日期()。获取时间();
while ( new Date().getTime() - start < 20) {}
}
函数冲洗工作(){
而(作品。长度){
常量工作 = 工作。转移();
工作。调用(空);
}
}
复制代码
当你点击按钮的时候,你会发现滑动的div卡了一会儿。从下图中可以看出,在五个宏任务完成之前,浏览器是不会进行渲染的。在此期间,页面无法更新或响应用户操作。 .
如果有上千个或上百个任务需要点击一个按钮,那么浏览器就会长时间卡顿,这显然是不能接受的。最简单的改造方法是执行一个任务,将后续的任务处理放到下一个事件循环中,让浏览器在这个事件循环中进行绘制。精通浏览器原理的你一定知道可以使用setTimeout来实现:
常量作品 = [];
常量 btn = 文档。 getElementById('btn');
btn。 onclick = 函数 () {
for ( 让 i = 0; i < 50; i++) {
作品。推(宏任务)
}
冲洗工作();
}
函数宏任务(){
常量开始 = 日期。现在();
while ( Date.now() - start < 20) {}
}
函数冲洗工作(){
工作循环();
}
函数工作循环(){
常量工作 = 工作。转移();
如果(工作){
工作。调用(空);
// 只执行一个任务,下一个事件循环会再次处理
设置超时(工作循环,0);
}
}
复制代码
打开控制台分析:
现在您可以看到,现在每个宏任务都没有链接在一起,它们是在不同的事件循环中执行的。每个任务完成后,浏览器都会进行一次绘图,即使有很多任务要执行,动画也不会卡顿。
但是仔细看,后面的宏任务之间的间隔似乎比较大,放大的时候间隔在4ms左右。我们当前一个任务的执行时间是20ms,超过了16.7ms。事实上,页面已经有点卡住了。主线程资源这么紧张,每个事件循环浪费4ms,绝对不能接受。很多人应该都听说过setTimeout的最小延迟限制,这大概就是说虽然你setTimeout是0秒,但实际上嵌套多层后,宏任务至少要4ms左右才会进入任务队列。
setTimeout 不再起作用了,还有其他选择吗?答案是肯定的,我们可以使用 MessageChannel 将任务放入宏任务队列。
MessageChannel的使用就不详细介绍了。简单来说,使用这个api,我们可以监听一个消息事件。当事件被触发时,事件处理程序将被添加到宏任务队列中。对应我们的例子,我们可以在绑定onmessage的时候执行workLoop,只执行workLoop中的一项任务。如果还有未执行的任务,则 postMessage 并在下一个事件循环中继续处理。
常量频道 = 新的 MessageChannel();
常量端口 2 = 通道。端口2;
常量端口 1 = 通道。端口1;
端口 1。 onmessage = 工作循环;
常量作品 = [];
常量 btn = 文档。 getElementById('btn');
btn。 onclick = 函数 () {
for ( 让 i = 0; i < 50; i++) {
作品。推(宏任务)
}
冲洗工作();
}
函数宏任务(){
常量开始 = 日期。现在();
while ( Date.now() - start < 20) {}
}
函数冲洗工作(){
工作循环();
}
函数工作循环(){
常量工作 = 工作。转移();
如果(工作){
工作。调用(空);
端口 2。 postMessage(空);
}
}
复制代码
重新执行分析后,宏观任务基本没有差距:
目前我们最小的任务单元的执行时间是20ms。因为超过16.7ms会导致页面卡死,所以我们实际上应该保证单个任务不能超过16.7ms。假设设计合理,我们最小的任务单元执行时间不会超过 2ms(这里随机设置为 1ms 或 2ms)。然后让我们看看当你点击按钮并执行 1000 个任务时会发生什么。
常量频道 = 新的 MessageChannel();
常量端口 2 = 通道。端口2;
常量端口 1 = 通道。端口1;
端口 1。 onmessage = 工作循环;
常量作品 = [];
常量 btn = 文档。 getElementById('btn');
btn。 onclick = 函数 () {
for ( 让 i = 0; i < 1000; i++) {
作品。推(宏任务)
}
冲洗工作();
}
函数宏任务(){
常量时间 = [ 1, 2];
const zeroOrOne = 数学。回合(数学。随机());
常量开始 = 日期。现在();
while ( Date.now() - start < time[zeroOrOne]) {}
}
函数冲洗工作(){
工作循环();
}
函数工作循环(){
常量工作 = 工作。转移();
如果(工作){
工作。调用(空);
端口 2。 postMessage(空);
}
}
复制代码
分析运行结果,我们可以看到浏览器绘制的帧率仍然不是60fps,而且我们的任务占用主线程的时间太长了。所以我们需要一种机制来在一帧中执行尽可能多的任务,同时为浏览器留出足够的时间来绘制页面并响应用户交互。
最终我们的设计方案是:在一个事件循环中,我们只占用主线程5ms,如果超过5ms,则将主线程的控制权交还给浏览器,在下一个事件中处理任务环形。
具体思路:
声明一个全局队列taskQueue来存储任务;
声明一个全局变量startTime来表示任务调度的开始时间。接收到onmessage事件时,获取当前时间并赋值给startTime,然后开始调度任务;
调度任务:从taskQueue队列中取出一个任务,获取当前时间currentTime,计算currentTime-startTime,如果大于等于5ms,则表示调度任务时长已经达到5ms,跳出循环,如果队列中还有任务,postMessage交给主线程控制,等待下一个事件循环调度任务。
浏览器绘制页面并响应用户交互后,在下一个事件循环中再次调度任务,重新计算 currentTime 和 startTime。这时候,它们之间的差值一定不能超过5ms,取出一个任务执行,然后更新currentTime。再次进入while循环判断currentTime-startTime是否大于5ms,如果大于5ms则交出控制权,否则继续执行下一个任务。
修改后的代码:
常量频道 = 新的 MessageChannel();
常量端口 2 = 通道。端口2;
常量端口 1 = 通道。端口1;
端口 1。 onmessage = performWorkUntilDeadline;
常量任务队列 = [];
让开始时间 = - 1;
常量 frameYieldMs = 5; // 任务的连续执行时间不能超过5ms
让 currentTask = null; // 用于保存当前任务
btn。 onclick = 函数 () {
for ( 让 i = 0; i < 1000; i++) {
任务队列。推(宏任务)
}
// 在下一个事件循环中开始调度任务
端口 2。 postMessage(空);
}
功能 performWorkUntilDeadline() {
开始时间 = 性能。现在(); // 更新开始时间
让 hasMoreWork = true;
尝试 {
hasMoreWork = flushWork();
} 最后 {
当前任务=空;
如果(有更多工作){
端口 2。 postMessage(空);
}
}
}
函数冲洗工作(){
返回工作循环();
}
函数工作循环(){
// 使用 currentTask 全局变量来存储当前任务似乎有点难看。
// 其实就是实现以后的任务优先级和任务队列功能。我不管,就这样写吧。
当前任务=任务队列[0];
而(当前任务){
如果(应该YieldToHost()){
休息;
}
当前任务。调用(空);
任务队列。转移(); // 完成的任务从队列中移除
当前任务=任务队列[0]; // 继续下一个任务
}
如果(当前任务){
// 还有任务需要在下一个事件循环中处理
返回真;
}
}
函数应该YieldToHost() {
// 任务是否应该被挂起
const currentTime = 性能。现在();
如果(当前时间 - 开始时间 < frameYieldMs){
返回假;
}
返回真;
}
函数宏任务(){
常量时间 = [ 1, 2];
const zeroOrOne = 数学。回合(数学。随机());
常量开始 = 性能。现在();
while (performance.now() - start < time[zeroOrOne]) {}
}
复制代码
好吧,我们来看看运行结果:现在浏览器的帧率可以保持在60fps,效果已经很不错了。但是目前我们的任务队列只是一个普通的先进先出队列,并没有实现优先级和任务切割的功能。在下一篇文章中,我们将继续沿用 react 的实现思路,使用 min heap 来实现优先级队列。
版权声明:本文为博主原创文章,遵循CC 4.0 BY-SA版权协议。转载请附上原文出处链接和本声明。
这篇文章的链接: https://homecpp.art/3417/8663/0753
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明
本文链接:https://www.qanswer.top/37460/26531803
标签:浏览器,常量,端口,react,如何,任务,循环,Scheduler,btn From: https://www.cnblogs.com/amboke/p/16704124.html