事件循环、dom渲染、宏任务和微任务,他们仨有啥关系?一文全解析!(javascript学习)

803 阅读8分钟

1.事件循环是什么

image.png

MDN中的描述是这样的:JavaScript 有一个基于事件循环的并发模型,事件循环负责执行代码、收集和处理事件以及执行队列中的子任务

1.2像绕圈跑步一样的事件循环

我们可以进行这样类比:

循环≈圆形跑道

任务≈跑道中的各种障碍

任务执行器≈跑步的人

通俗一点来讲,可以将事件循环理解为一个一直不停在绕圈跑步的人,在跑步过程中,每隔一段距离就有可能出现一道关卡拦着这个人,比如独木桥、沙坑、矮墙等障碍,需要这人拿下这些障碍后才能继续前进,跑者需要做的事情就是一直不停的绕这个这个圈跑步,遇到关卡就执行这个关卡的任务

1.3跑步过程中不断出现的障碍就是任务

假设这个圈有100米,当这个跑者从七点出发在跑完一圈的过程中,可能出现的问题以及他们出现的位置是这样的:

  • 20米: 从任务队列(Task Queue)中取出最早的一个任务(如:JavaScript事件回调、setTimeout或setInterval的回调函数等)。

  • 40米: 执行该任务。任务可能包括以下动作:

    a. 对DOM进行操作(添加、删除、修改元素等)。

    b. 发送HTTP请求、处理响应。

    c. 更新UI,如绘制动画。

    d. 执行其他JavaScript代码。

  • 60米: 一旦任务完成,处理微任务队列(Microtask Queue)。此队列包括诸如 Promise.then 回调、MutationObserver 回调等微任务。浏览器会继续执行微任务队列中的任务,直到该队列为空。

  • 80米: 进行浏览器渲染更新:对于涉及DOM修改或者样式变化的操作,浏览器会进行重新布局(重排)和重绘。由于重排和重绘涉及性能开销,浏览器会在适当的时机将这些操作进行合并,以优化性能。

  • 100米: 当这些步骤完成后,事件循环返回到第一步,继续检查任务队列以处理下一个任务。

上述这些障碍就是大多数情况下通常情况下一次事件循环中会出现的任务,当然这些任务不是一定会出现,但是出现了就一定要执行完了才能继续

如果理解了这个例子,我们带入事件循环时,就可以在心中假象一个一直不停绕圈的执行器,这个执行器永远不会停止绕圈,直到关闭当前标签页

1.4事件循环的多久循环一次

答案很简单,不确定,是不是很奇怪的答案,但是我们能从上面的跑步的例子中理解这个答案,假如这个跑者的体力恒定,速度恒定,那么当他跑一圈的速度就恒定,这样我们就知道了跑一圈要多久了对吧,然而事实却是跑一圈的过程中会出现很多障碍,而且障碍的难易程度还不确定,这样就导致了我们不知道跑者跑一圈要多久

带入到事件循环中,我们并不知道在一次循环中会出现哪些任务需要执行器执行,这些任务的执行时间也不知道是多少,例如这次的任务只是执行一个这个👇,可能执行时间不到一毫秒

console.log(123)

而下次的任务是执行这个👇

for(let i = 1; i < 30; i++){ 
  console.log(fib(i))
}
function fib(n) {
if(n < 3) return 1
  return fib(n - 1) + fib(n - 2)
}

可能要执行好几秒,这就导致了事件循环执行完一圈的时间不固定的原因,我们可以认为执行一圈的时间与任务的难易程度和电脑的性能有直接关系

1.5事件循环的开始和结束

事件循环在标签页打开的一瞬间就已经开始,直到关闭当前标签页才会停止,中间没有任何原因会暂停

1.6事件循环的任务执行逻辑

这里我们主要分析js的执行任务

我们可以简单的将事件循环中的js任务分为同步任务和异步任务,而异步任务又分为宏任务和微任务 他们的执行逻辑大多数情况下遵循以下逻辑:

  • 在同时存在同步任务和异步任务时,优先执行同步任务
  • 在仅有异步任务时,如果同时有需要执行的宏任务和微任务,优先执行微任务

然后,我们需要给自己灌输一个观点,在执行过程中,有以下几个模块:

  • 任务执行池: 里面是一条条待执行的js任务,按照任务进入的顺序先进入的先执行,类似队列的先进先出
  • 异步任务池:里面存放的是已经过期的异步任务,当当前任务执行池中的任务执行完毕时,就会去异步任务池中拿取任务到任务执行池中,注意,当一个微任务和一个宏任务的过期时间相同时,优先执行微任务
  • webAPI: 这里是浏览器的一些能力,负责对微任务和宏任务的过期时间进行倒计时,抵达过期时间时将对应任务放到异步任务池中

这里有一个网站大家可以在这个网站里直观的感受一下事件循环的工作流程,关于上面提到的宏任务和微任务展开来讲有很多可以说的,这里先挖个坑~

接下来我们开看一段代码,试着分析一下当button按钮被点击时 控制台的输出是什么:

const el = document.getElementById("btn")
el.addEventListener("click", () => {
  Promise.resolve().then(() => console.log("microtask 1"));
  console.log("1");
});
el.addEventListener("click", () => {
  Promise.resolve().then(() => console.log("microtask 2"));
  console.log("2");
});

大家可以带着上面网站中的demo,思考一下这段代码控制台会输出什么,一段分割线防止偷看答案~

。。。。。。。。。。。

。。。。。。。。。。。

。。。。。。。。。。。

。。。。。。。。。。。

。。。。。。。。。。。

。。。。。。。。。。。

。。。。。。。。。。。

。。。。。。。。。。。

。。。。。。。。。。。

。。。。。。。。。。。

。。。。。。。。。。。

。。。。。。。。。。。

。。。。。。。。。。。

。。。。。。。。。。。

。。。。。。。。。。。

最终答案是1→reslove1→2→resolve2

不知道有没有人答对呢,又有多少人的答案是1→reslove1→2→resolve2呢 这里可以试着用下列步骤理解js的执行步骤:

  1. 当js主线程执行任务的时候,先时发现了两个同步的事件监听绑定任务,这是一次对dom进行事件监听绑定绑定完成后,js任务栈为空,此时按钮已经被绑定了两个事件回调
  2. 按钮被点击时,两个鼠标点击事件回调函数被被放入异步任务中的宏任务等待执行队列中,并且把第一个入队的按钮回调函数放入js调用栈中执行,并打印了1
  3. 当回调函数执行完后,js栈为空了,这时该去异步任务内拿任务了,这里关键来了,此时会去拿Promise的微任务!,这里注意我们之前说的,当同时有宏任务和微任务等待执行时,优先执行微任务!所以 接下来打印的就是reslove1
  4. 然后重复上面步骤,打印2→resolve2

这里有一段可以运行的代码沙盒,大家可以点击下面沙河右上角的码上掘金调试代码查看控制台输出

通过以上描述,我们就再次强化了一个观念,在事件循环中,每次只会执行一个任务或者一行代码,且当前任务执行完毕后,才会执行下一个任务

大家可以消化一下上述例子,接下来,我们再来看看这个!

const el = document.getElementById("btn")
el.addEventListener("click", () => {
  Promise.resolve().then(() => console.log("microtask 1"));
  console.log("1");
});
el.addEventListener("click", () => {
  Promise.resolve().then(() => console.log("microtask 2"));
  console.log("2");
});
el.click()

看到这段代码和之前那段代码的不同了吗,之前是通过用户鼠标点击按钮触发事件回调函数执行,而这段代码是通过js触发dom的clik事件,大家再来试试这段代码会输出什么呢,这里先卖个关子,大家心中有答案的可以在评论区一起交流哦~

提示一下:当js调用栈内在执行document.getElementById("btn").click()时,按照我们上文的理解,js调用栈内的任务在执行过程中,必须要把当前这条任务执行完毕后,才会执行下一条一人,那么按照这个理解,是不是应该把click的所有事件处理函数执行完毕后,才能将click任务弹栈呢,带着这个理解去思考上面的控制台输出,可能会有不一样的结果哦

2事件循环与dom的关系

在事件循环中,与dom相关的任务有很多,例如:

  • JavaScript执行时操作DOM
  • 异步任务执行时操作DOM
  • 在检测到DOM更新后,针对新的style树、html树进行重绘和重排
  • 根据新的style树、html树渲染ui视图

2.1多久渲染一次dom

通常情况下,我们认为浏览器会每16.6ms~16.7ms渲染一次,这个数字是这样得来的:1000ms(一秒) / 60(普通屏幕一秒的刷新率),当然在这只是一个大概的数字,为了方便理解,后面我们统一用16ms来表示每一帧的渲染间隔时长

2.2不确定的渲染时间

但是还是有例外,例如在某一帧的渲染过程中,js执行了一个计算量超级大的任务,导致js执行过程中消耗了20ms才执行完,这就导致了本来该当前帧执行的任务,由于js占用了过多的时间,导致当前帧的渲染被滞后了,就像这样:

image.png 本来每一帧之间的渲染间是16ms,但是在上面这个例子中,由于第三帧渲染之间js执行了一个很耗时的任务,直接导致了第三帧的渲染没有在16ms之内出现,导致了第三帧的渲染滞后了

而由于第三帧的渲染滞后了4ms,导致第三帧与第四帧之间的渲染间隔时间由正常的16ms变为了12ms

这就导致对于肉眼的直观感受就是:在第二帧渲染完成过后,出现了一点卡顿才渲染出第三帧,而且第三帧和第四帧之间的渲染间隔明显变短

这也就是有一些老的js动画库由于历史原因实在没有办法,只能通过setTimeout(cb, 16.6)这样的形式来渲染动画,而由于每一帧的渲染时间不确定性导致,本来应该是一个线性匀速的渲染,变成了一个时快时慢的非匀速渲染,就像在这样:

2fl2z-otfcz.gif

2.3一次dom渲染都干了些什么

image.png 上图中每一项任务都有可能消耗很长的时间,这也是导致每一帧dom渲染之间的时间间隔不一致的原因之一

2.4一次dom渲染等于几次事件循环

如果我们还记得1.2中的内容,我们就会发现,一个完整的、充实的一次事件循环里所做的事情大概就是一次dom渲染做的事情

这样就很容易让我们产生一种误解:一次dom渲染就是一次事件循环

然而真实情况是,一次dom渲染中,准确来说,是两次dom渲染之间,事件循环的次数是不确定的,不能被事件循环中存在dom渲染这个任务而被其误导