JavaScript的事件队列(Event Queue)

7,183 阅读6分钟

前言

在写代码的时候经常思考一个问题,到底是那个函数先执行,本身JavaScript是一门单线程的语言,意思就是按照顺序执行。但是加入一些setTimeout和promise的函数来又实现了异步操作,常常我会写一个setTimeout(fn,0),他会立即执行吗?

宏任务和微任务

首先我们先来看一段代码:

<script>
console.log("Start");

setTimeout(function(){
console.log("SetTimeout");
},0);

new Promise(function(resolve,reject){
console.log("Promise");
resolve();
}).then(function(){
console.log("Then");
});

console.log("End");
<script>

这些日志的打印顺序是:

Start
Promise
End
Then
SetTimeout

这是为什么

首先,我们知道JavaScript的一大特点就是单线程,而这个线程中拥有唯一的一个事件循环。

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

任务队列又分为macro-task(宏任务)与micro-task(微任务),在最新标准中,它们被分别称为task与jobs。

宏任务

  • setTimeout
  • setInterval
  • I/O
  • script代码块

微任务

  • nextTick
  • callback
  • Promise
  • process.nextTick
  • Object.observe
  • MutationObserver

事件循环的顺序,决定js代码的执行顺序。一段代码块就是一个宏任务。进入整体代码(宏任务)后,开始第一次循环。接着执行所有的微任务。然后再次从宏任务开始,找到其中一个任务队列执行完毕,再执行所有的微任务。

主线程(宏任务) => 微任务 => 宏任务 => 主线程

下图是简易版的事件循环:

所以在上面的代码中宏任务有script代码块,setTimeout,微任务有Promise

事件循环流程分析如下

  • 整体script 作为第一个宏任务进入主线程,遇到console.log,输出Start
  • 遇到setTimeout,其回调函数被分发到宏任务Event Queue中。
  • 遇到Promise,new Promise直接执行,输出Promise。then被分发到微任务Event Queue中。
  • 遇到console.log,立即执行,输出End
  • 整体代码script作为第一个宏任务执行结束,看看有哪些微任务?我们发现了then在微任务Event Queue里面,执行
  • ok,第一轮事件循环结束了,我们开始第二轮循环,当然要从宏任务Event Queue开始。我们发现了宏任务Event Queue中setTimeout对应的回调函数,立即执行。
  • 所以代码结束。

提高下难度在来一段较为复杂的代码来检验是否已经基本了解了事件循环的机制

async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}
async function async2() {
  console.log('async2');
}
console.log('script start');
setTimeout(function() {
    console.log('setTimeout1');
}, 200);
setTimeout(function() {
    console.log('setTimeout2');
    new Promise(function(resolve) {
        resolve();
    }).then(function() {
        console.log('then1')
    })
    new Promise(function(resolve) {
        console.log('Promise1');
        resolve();
    }).then(function() {
        console.log('then2')
    })
},0)
async1();
new Promise(function(resolve) {
    console.log('promise2');
    resolve();
  }).then(function() {
    console.log('then3');
  });
console.log('script end');

第一轮事件循环流程分析如下:

  • 整体script作为第一个宏任务进入主线程,async1(),和async12()函数申明,但并没有执行,遇到console.log输出script start

  • 继续向下执行,遇到setTimeout,把它的回调函数放入宏任务Event Queue。(ps:暂且叫他setTimeout1)

    宏任务 微任务
    setTimeout1 1
  • 继续向下执行,又遇到一个setTimeout,继续将他放入宏任务Event Queue。(ps:暂且叫他setTimeout2)

    宏任务 微任务
    setTimeout1
    setTimeout2
  • 遇到执行async1(), 进入async的执行上下文之后,遇到console.log输出 async1 start

  • 然后遇到await async2(),由于()的优先级高,所有立即执行async2(),进入async2()的执行上下文。

  • 看到console.log输出async2,之后没有返回值,结束函数,返回undefined,返回async1的执行上下文的await undefined,由于async函数使用await后得语句会被放入一个回调函数中,所以把下面的放入微任务Event Queue中。

    宏任务 微任务
    setTimeout1 async1 => awati 后面的语句
    setTimeout2
  • 结束async1() 遇到Promise,new Promise直接执行,输出Promise2then后面的函数被分发到微任务Event Queue中

    宏任务 微任务
    setTimeout1 async1 => awati 后面的语句
    setTimeout2 new Promise() => 后的then
  • 执行完Promise(),遇到console.log,输出script end,这里一个宏任务代码块执行完毕。

  • 在主线程执行的过程中,事件触发线程一直在监听着异步事件, 当主线程空闲下来后,若微任务队列中有任务未执行,执行的事件队列(Event Queue)中有微任务,遇到new Promise()后面的回调函数,执行代码,输出then3

  • 看到 async1await后面的回调函数,执行代码,输出async1 end(注意:如果俩个微任务的优先级相同那么任务队列自上而下执行,但是promise的优先级高于async,所以先执行promise后面的回调函数)

  • 自此,第一轮事件循环正式结束,这一轮的结果是输出:script start => async1 start => async2 => promise2 => script end => then3 => async1 end

    宏任务 微任务
    setTimeout1
    setTimeout2
  • 那么第二轮时间循环从setTimeout宏任务开始:

  • setTimeout和setInterval的运行机制是,将指定的代码移出本次执行,等到下一轮Event Loop时,再检查是否到了指定时间。如果到了,就执行对应的代码;如果不到,就等到再下一轮Event Loop时重新判断。因为setTimeout1有200ms的延时,并没到达指定时间,所以先执行setTimeout2这个宏任务

  • 进入到setTimeout2,遇到console.log首先输出setTimeout2;

  • 遇到Promise,new Promise直接执行。then后面的函数被分发到微任务Event Queue中

    宏任务 微任务
    setTimeout1 new Promise() => 后的then1
  • 再次遇到Promise,new Promise直接执行输出promise1then后面的函数被分发到微任务Event Queue中

    宏任务 微任务
    setTimeout1 new Promise() => 后的then1
    new Promise() => 后的then2
  • 主线程执行执行空闲,开始执行微任务队列中依次输出then1then2

  • 第二轮事件循环正式结束。第二轮依次输出promise1 => then1 => then2

  • 现在任务队列中只有个延时200ms的setTimeout1,在到达200ms后执行setTimeout的回调函数输出setTimeout1

  • 时间循环结束

  • 整段代码,完整的输出为script start => async1 start => async2 => promise2 => script end => then3 => async1 end => promise1 => then1 => then2 => setTimeout1

总结

  1. 在执行栈中执行一个宏任务。
  2. 在执行过程中遇到微任务和宏任务,分别添加到微任务队列和宏任务队列中去。
  3. 当前宏任务执行完毕,立即执行微任务队列中的任务(微任务存在优先级,优先级高的先执行)。
  4. 当前微任务队列中的任务执行完毕,检查渲染,GUI线程接管渲染。
  5. 继续执行下一个宏任务从事件队列中取。

所以在我们写下setTimeout(fn,0)的时候他并不是在当时立即执行,是从下一个Event loop开始执行,即是等当前所有脚本执行完再运行,就是"尽可能早"。