教你如何实现react Scheduler(一)

最近一直在看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>

posted @ 2022-09-18 03:28  哈哈哈来了啊啊啊  阅读(198)  评论(0)    收藏  举报