浏览器中的事件循环机制

简介

javascript的一大特点就是单线程,这个线程中拥有唯一的一个事件循环。事件循环机制从整体上告诉了我们javascript代码的执行顺序。javascript代码的执行过程中,除了一开函数调用栈来搞定函数的执行顺序外,还依靠任务队列(task queue)来搞定另外一些代码的执行。

一个线程中,事件循环是唯一的,但是任务队列可以拥有多个。

任务队列

宏任务(macro-task)

script(整体代码),setTimeOut,setInterval,setImmediate,I/O,UI rendering

微任务(micro-task)

process.nextTick,promise,MutationObserver

任务源

setTimeOut/Promise称为任务源,而进入队列的是他们制定的执行任务。

规则

  1. 来自不同任务源的 任务会进入到不同的任务队列,其中setTimeout与setInterval是同源的。
  2. 事件循环的顺序,决定了javascript代码的执行顺序。它从script(整体代码)开始第一次循环。全局上下文进入函数调用栈,直到调用栈清空(只剩下全局),然后执行所有micro-task;当所有micro-task执行完毕之后,循环再次从macro-task开始,找到其中一个macro任务队列进行执行,执行完毕,再执行所有micro-task;这样一直循环下去。
  3. 其中每一个任务的执行,无论是macro-task还是micro-task,都是借用函数调用栈来完成的。

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
setTimeout(function() {
console.log('timeout1');
})
new Promise(function(resolve) {
console.log('promise1');
for(var i = 0; i < 1000; i++) {
i == 99 && resolve();
}
console.log('promise2');
}).then(function() {
console.log('then1');
})
console.log('global1');

首先,事件循环从宏任务队列开始,这时候,宏任务队列中,只有一个script(整体代码)任务。每一个任务的执行顺序,都依赖函数调用栈来搞定,而当遇到任务源时,则会先分发任务到对应的队列中,所以上面的例子的第一步执行如下所示,script任务开始执行,全局上下文入栈:

image.png

第二步:script任务执行时首先遇到了setTimeout,setTimeout为一个宏任务源,那么他的作用就是将任务分到它对应的队列中。

1
2
3
setTimeout(function() {
console.log('timeout1');
})

宏任务timeout1进入setTimeout队列。

image.png

第三步,script执行时,遇到Promise实例,Promise构造函数中的第一个参数,是在new的时候执行,因此不会进入到任何其他队列,而是直接在当前任务直接执行了,而后续的then则会分配到微任务的Promise队列中。

因此,构造函数执行时,里面的参数进入函数调用栈,for循环不会进入任何队列,因此代码会依次执行,所以这里的promise1和promise2会依次输出。

promise1入栈执行,这时promise1被最先输出。

image.png

resolve在for循环中入栈执行。

image.png

构造函数执行完毕的过程中,resolve执行完毕出栈,promise2输出,promise也出栈,then执行时,Promise任务then1进入对应的微任务队列。

image.png

script任务继续往下执行,最后只输出了一句global1,然后全局任务就执行完毕了。

第四步:第一个宏任务script执行完毕后,就开始执行所有的可执行的任务。这个时候,微任务队列中,只有promise队列中的一个任务then1,因此直接执行,结果输出then1。当然,也是进入函数调用栈中执行的。

image.png

第五步:当所有的微任务执行完毕之后,表示第一轮循环结束了。这个时候进入第二轮循环,第二轮循环仍从宏任务macro-task开始。

这个时候,发现宏任务中,只有setTimeout队列中还有一个timeou1的任务等待执行,因此直接执行即可。

这时宏任务与微任务队列中都没有任务了,因此不会再输出其他东西了。

Reference

前端基础进阶(十二):深入核心,详解事件循环机制