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