事件循环(Event Loop)机制是全面了解JavaScript代码执行顺序绕不开的一个重要知识点。虽然许多人知道这个知识点非常重要,它对我们加深JavaScript的认知非常重要,但是其实很少有人能够真正的理解它。特别是在ES6中正式支持Promise之后,对于新标准中事件循环的理解就变得更加重要。

在学习事件循环机制之前,我默认大家已经搞懂了以下概念。都有在书中前面的章节中详细解读过,如果还没有搞懂,可以回过头去重新学习。

  • 执行上下文(Execution context)
  • 函数调用栈(call stack)
  • 队列数据结构(queue)
  • Promise

我们先来看两个简单的例子,大家可以先看看自己能够能够得出正确的执行结果。

// demo01
setTimeout(function() {
    console.log(1);
}, 0);
console.log(2);

for(var i = 0; i < 5; i++) {
    console.log(3)
}

console.log(4);
// demo02
console.log(1);

for(var i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log('2-' + i);
    }, 0);
}

console.log(3);

我想很多读者在运行了之后会感到困惑,为什么即使设置了setTimeout的延迟时间为0,它里面的代码仍然是最后执行?

之前我们已经知道,在通常情况下,决定代码执行顺序的是函数调用栈。很显然这里的setTimeout中的执行顺序已经不是能够用函数调用栈能够解释清楚的了,那么是什么呢?

答案是队列。

我们知道JavaScript的一个特点就是单线程。但是很多时候我们仍然需要在不同的时间去执行不同的任务,例如给元素添加点击事件,设置一个定时器,或者发起ajax请求。因此需要一个异步机制来达到这样的目的。事件循环机制也因此而来。

每一个JavaScript程序拥有唯一的事件循环。大多数代码的执行顺序都可以根据函数调用栈的规则执行。而setTImeout/setInterval或者不同的事件绑定(click, mousedown等)中的代码,则通过队列来执行。

我们可以称setTimeout为任务源,或者任务分发器。由他们将不同的任务分发到不同的任务队列中去。每一个任务源都有对应的任务队列。

任务队列又分为宏任务(macro-task)与微任务(micro-task)。在浏览器中,大概包括:

  • macro-task: script(整体代码), setTimeout/setInterval, I/O,UI rendering等
  • micro-task: Promise

在nodejs中还包括更多的任务队列,此处我们不做讨论。
来自不同任务源的任务会进入到不同的任务队列中,其中setTimeout与setInterval是同源的。

事件循环的顺序,决定了JavaScript代码的执行顺序。

它从macro-task中的script开始第一次循环。此时全局上下文进入函数调用栈,直到调用栈清空(只剩下全局上下文),在这个过程中,遇到任务分发器,就会将任务放入对应队列中去。

第一次循环时,macro-task其实仅仅只有script,因此函数调用栈清空之后,会直接执行所有的micro-task。当所有可执行的micro-task执行完毕之后,表示第一次事件循环已经结束。

第二次循环会再次从macro-task开始执行。此时macro-task中的script队列中已经没有任务了,但是可能会有其他的队列任务,而micro-task中暂时还没有任务。此时会先选择其中一个宏任务队列,例如为setTimeout,会将该队列中的所有任务全部执行完毕,然后再执行此过程中可能产生的微任务。微任务执行完毕之后,再回过头来执行其他宏任务队列中的任务。依次类推,直到所有的宏任务队列中任务都被执行过一遍,并且清空了微任务,那么第二次循环就会结束。

如果再第二次循环过程中,产生了新的宏任务队列,或者之前宏任务队列中的的任务暂时没有满足执行条件,例如延迟时间不够或者事件没有触发,那么将会继续以同样的顺序重复循环。

...

接下来我们结合例子来一步一步理解这个复杂的规则。

setTimeout(function() {
    console.log('setTimeout')
}, 0);
console.log('global');

首先,macro-task script任务队列最先执行。执行过程中遇到setTimeout,setTimeout的执行会将它的任务分发到setTimeout任务队列中去,因此此时里面的代码是不执行的。代码会接着往下执行,因此这里会首先输出global

整个过程没有产生micro-task,因此第一轮循环结束。第二轮循环开始,发现setTimeout任务队列中会存在一个任务,所以执行它,这个时候会输出'setTimeout'

因此这个例子的最终输出结果为:

global
setTimeout

再来看一个稍微复杂一点的例子。

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任务执行时首先遇到了setTimeout,setTimeout为一个宏任务源,那么他的作用就是将任务分发到它对应的队列中。

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

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

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

resolve在for循环中入栈执行

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

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

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

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

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

这个时候宏任务队列与微任务队列中都没有任务了,所以代码就不会再输出其他东西了。

那么上面这个例子的输出结果就显而易见。

promise1
promise2
global1
then1
timeout1

results matching ""

    No results matching ""