前端学习之路--事件循环(Event Loop)

195 阅读11分钟

2019/8/12

事件循环(Event Loop)

  在学习前端的路上,有很多零碎的知识点,在此开始做一个记录,以备之后回顾学习,在学习的过程中会不断更新,当然,作为初期,里面或许漏洞百出,在不断成长的过程中,我会进行更正与补充。若有读者给予指正,觉剑感激不尽。

  事件循环,是一种JavaScript的一种运行机制,描述了在浏览器中JavaScript事件的执行顺序。

  大家都知道,JavaScript是一种单线程语言,代码顺序执行,不能并发执行。这样就容易造成代码阻塞,而事件循环就是浏览器用来解决JavaScript代码单线程运行时防止阻塞的机制。

  在了解Event Loop之前先要了解两个名词:宏任务(macrotask)和微任务(microtask)。

宏任务和微任务

  JavaScript的事件分为两大类,一类就是宏任务(macrotask),也叫tasks,一类就是微任务(microtask),也叫jobs。宏任务的异步任务回调会依次进入宏任务队列(macro task queue),等待后续调用;微任务的异步任务回调会依次进入微任务队列(micro task queue),等待后续调用。任务队列(task queue)顾名思义是队列结构,是一种先进先出的数据结构,任务依次进入队列中,最早进入的最早被调用。

  宏任务:包含script的全部代码,setTimeOut,setTimeInterval,requestAnimationFrame,I/O等

  微任务:浏览器中主要是Promise,Node中还有Process.nextTick

事件循环

  事件循环(Event Loop)就是按某种顺序流程执行任务的过程,其整体执行过程如下:

  1. 首先执行全局Script代码,这里面就是主线程代码,全部在调用栈(Stack)中,执行过程中,各种异步回调任务按照宏任务微任务规则进行分配进入各自任务队列;
  2. 全局Sctipt代码执行完毕后,调用栈(Stack)清空;
  3. 从微任务队列中取出位于队首的回调任务,也就是最早进入微任务队列的任务,放入Stack中执行,执行完毕后微任务队列长度减一;
  4. 重复3中的步骤(如果在执行微任务的过程中又产生新的微任务,将新产生的微任务加入微任务队列末尾,并在此周期内调用执行),直到该周期内微任务队列中所有微任务全部执行完毕,执行完毕后微任务队列长度为0,Stack为空栈;
  5. 一个微任务执行周期结束后,从宏任务队列中取出位于队首的回调任务(仅取出一个任务),也就是最早进入宏任务队列的任务,放入Stack中执行,执行完毕后宏任务队列长度减一,Stack为空栈;
  6. 重复第3、4、5步骤;

  这从1-6的过程就是一个完整的事件循环过程。可以发现,事件循环其实就是一个入栈出栈的不断循环,优先执行JavaScript执行栈中的同步代码任务(主执行线程任务),然后取出微任务队列任务直到当前所有微任务执行一空,之后取出最早的宏任务执行(只执行一个宏任务),接着继续执行微任务队列(因宏任务执行而产生的新的微任务)直到清空,然后再拿出当前宏任务队列队首的任务...依次循环执行。

举个例子:

谷歌大神的一篇文章《Tasks, microtasks, queues and schedules》中有这样一段代码:

console.log('script start');

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

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');

  首先我们要知道的是,这段代码在不同浏览器中的结果不一定相同,具体原因在《Tasks, microtasks, queues and schedules》一文中有说明。但是这段代码总是有一个唯一的正确执行顺序的,正确打印顺序是

script start, script end, promise1, promise2, setTimeout

  我们按照上面讲的事件循环顺序来一步一步分析为什么是这样的执行顺序。

第一步

  优先执行主线程任务,全部script中的同步代码,JavaScript的代码也是从上往下顺序执行的,因此,第一步执行console.log('script start');

  此时的调用栈、宏任务队列与微任务队列中的任务如下:

Stack: [console.log('script start')]

Macro task queue:[ ]

Micro task queue:[ ]

打印结果:script start

第二步

  接下来主线程任务还没执行完,第二个主线程任务是setTimeOut,这是一个异步任务,但是setTimeOut这个任务是在主线程任务中的,其内部回调任务放在宏任务队列中。

  此时的调用栈、宏任务队列与微任务队列中的任务如下:

Stack: [setTimeOut]

Macro task queue:[console.log('setTimeout')]

Micro task queue:[ ]

打印结果:script start

第三步

  接下来第三个主线程任务是Promise,这也是一个异步任务,同样Promise这个任务是算在主线程任务中的,其内部回调任务放在微任务队列中。

  此时的调用栈、宏任务队列与微任务队列中的任务如下:

Stack: [Promise]

Macro task queue:[console.log('setTimeout')]

Micro task queue:[console.log('promise1'),console.log('promise2') ]

打印结果:script start

第四步

  最后一个主线程任务又是console,当这个任务执行完毕后,Stack第一次清空。

  此时的调用栈、宏任务队列与微任务队列中的任务如下:

Stack: [console.log('script end')]

Macro task queue:[console.log('setTimeout')]

Micro task queue:[console.log('promise1'),console.log('promise2') ]

打印结果:script start, script end

第五步

  到这一步开始调用微任务队列,把微任务队列中第一个任务取出。

  此时的调用栈、宏任务队列与微任务队列中的任务如下:

Stack: [console.log('promise1')]

Macro task queue:[console.log('setTimeout')]

Micro task queue:[console.log('promise2') ]

打印结果:script start, script end, promise1

第六步

  继续取出微任务队列中的微任务,直到全部执行完毕。

  此时的调用栈、宏任务队列与微任务队列中的任务如下:

Stack: [console.log('promise2') ]

Macro task queue:[console.log('setTimeout')]

Micro task queue:[ ]

打印结果:script start, script end, promise1, promise2

第七步

  微任务队列全部执行完毕后,取出宏任务队列队首任务执行。

  此时的调用栈、宏任务队列与微任务队列中的任务如下:

Stack: [console.log('setTimeout')]

Macro task queue:[ ]

Micro task queue:[ ]

打印结果:script start, script end, promise1, promise2, setTimeout

至此事件循环结束,所有任务执行完毕,打印结果为:script start, script end, promise1, promise2, setTimeout


我们再来看另一个复杂的例子

这个例子来自于segmentfault的liuxuan大神的文章,

console.log(1);

setTimeout(() => {
  console.log(2);
  Promise.resolve().then(() => {
    console.log(3)
  });
});

new Promise((resolve, reject) => {
  console.log(4)
  resolve(5)
}).then((data) => {
  console.log(data);
  
  Promise.resolve().then(() => {
    console.log(6)
  }).then(() => {
    console.log(7)
    
    setTimeout(() => {
      console.log(8)
    }, 0);
  });
})

setTimeout(() => {
  console.log(9);
})

console.log(10);

我们来逐步分析,最后再放打印结果:

第一步

  优先执行主线程任务,第一步执行console.log(1);

  此时的调用栈、宏任务队列与微任务队列中的任务如下:

Stack: [console.log(1)]

Macro task queue:[ ]

Micro task queue:[ ]

打印结果:1

第二步

  继续执行主线程任务,第二步执行setTimeOut,并将其回调函数放到宏任务队列中;

//在这里,我们将整个setTimeOut回调函数写成callback1来代表这个宏任务
console.log(2);
  Promise.resolve().then(() => {
    console.log(3)
  });

  此时的调用栈、宏任务队列与微任务队列中的任务如下:

Stack: [setTimeOut]

Macro task queue:[callback1]

Micro task queue:[ ]

打印结果:1

第三步

  主线程任务执行到了promise,将其回调函数放到微任务队列中;

new Promise((resolve, reject) => {
  //注意这里是同步执行的,并没有异步,因此这里是在主线程执行
  console.log(4)
  resolve(5)
}).then((data) => {
  //在这里,我们将整个promise回调函数写成callback2来代表这个微任务
  console.log(data);
  //这里依旧算是callback2内
  Promise.resolve().then(() => {
    console.log(6)
  }).then(() => {
    console.log(7)
    
    setTimeout(() => {
      console.log(8)
    }, 0);
  });
})

  此时的调用栈、宏任务队列与微任务队列中的任务如下:

Stack: [promise]

Macro task queue:[callback1]

Micro task queue:[callback2]

打印结果:1,4

第四步

  继续执行主线程任务,第四步又是一个setTimeOut;

  此时的调用栈、宏任务队列与微任务队列中的任务如下:

Stack: [setTimeOut]

Macro task queue:[callback1,console.log(9)]

Micro task queue:[callback2]

打印结果:1,4

第五步

  最后一个主线程任务是一个console,执行结束后至此Stack第一次清空;

  此时的调用栈、宏任务队列与微任务队列中的任务如下:

Stack: [console.log(10)]

Macro task queue:[callback1,console.log(9)]

Micro task queue:[callback2]

打印结果:1,4,10

第六步

  开始执行微任务队列,将微任务队列中的callback2取出来执行;

//callback2的执行也是有顺序的,会重新渲染任务队列
//这里data是5,这一步先执行
 console.log(data);
  //接下来又遇到一个promise,其里面的回调会被加入到微任务队列的末尾
  Promise.resolve().then(() => {
    console.log(6)
  }).then(() => {
    //这里用callback3代表
    console.log(7)
    //这里也是callback3里面
    setTimeout(() => {
      console.log(8)
    }, 0);
  });

  此时的调用栈、宏任务队列与微任务队列中的任务如下:

Stack: [console.log(5)]

Macro task queue:[callback1,console.log(9)]

Micro task queue:[console.log(6),callback3]

打印结果:1,4,10,5

第七步

  微任务队列有增加,继续执行微任务队列,将微任务队列中的微任务依次执行;

  此时的调用栈、宏任务队列与微任务队列中的任务如下:

Stack: [console.log(6)]

Macro task queue:[callback1,console.log(9)]

Micro task queue:[callback3]

打印结果:1,4,10,5,6

第八步

  继续执行微任务队列,这里执行完毕后微任务队列第一次清空;

//这里是callback3,先执行console
 console.log(7)
    //执行中遇到了一个setTimeOut,将其回调添加到宏任务队列中等待执行
    setTimeout(() => {
      console.log(8)
    }, 0);

  此时的调用栈、宏任务队列与微任务队列中的任务如下:

Stack: [console.log(7)]

Macro task queue:[callback1,console.log(9),console.log(8)]

Micro task queue:[ ]

打印结果:1,4,10,5,6,7

第九步

  微任务队列第一次执行完毕,从宏任务队列中提取队首的任务开始执行;

//这里是callback1,先执行console
 console.log(2);
   //遇到一个promise,将回调任务添加到微任务队列等待执行
   Promise.resolve().then(() => {
     console.log(3)
   });

  此时的调用栈、宏任务队列与微任务队列中的任务如下:

Stack: [console.log(2)]

Macro task queue:[console.log(9),console.log(8)]

Micro task queue:[console.log(3)]

打印结果:1,4,10,5,6,7,2

第十步

  宏任务队首任务执行完毕,宏任务的执行中止,微任务队列有增加,从微任务队列中提取队首的任务开始执行,执行完毕后微任务队列第二次清空;

  此时的调用栈、宏任务队列与微任务队列中的任务如下:

Stack: [console.log(3)]

Macro task queue:[console.log(9),console.log(8)]

Micro task queue:[ ]

打印结果:1,4,10,5,6,7,2,3

第十一步

  微任务队列第二次清空,继续从宏任务队列中提取队首的任务开始执行;

  此时的调用栈、宏任务队列与微任务队列中的任务如下:

Stack: [console.log(9)]

Macro task queue:[console.log(8)]

Micro task queue:[ ]

打印结果:1,4,10,5,6,7,2,3,9

第十二步

  宏任务队首任务执行完毕,宏任务的执行中止,开始执行微任务队列,但是此时微任务队列为空,所以继续从宏任务队列中提取队首的任务开始执行;

  此时的调用栈、宏任务队列与微任务队列中的任务如下:

Stack: [console.log(8)]

Macro task queue:[ ]

Micro task queue:[ ]

打印结果:1,4,10,5,6,7,2,3,9,8

至此事件循环结束,所有任务执行完毕,打印结果为1,4,10,5,6,7,2,3,9,8

总结

  浏览器中的事件循环其实就是一个入栈出栈的不断循环,优先执行JavaScript执行栈中的同步代码任务(主执行线程任务),然后取出微任务队列任务直到当前所有微任务执行一空,之后取出最早的宏任务执行(只执行一个宏任务),接着继续执行微任务队列(因宏任务执行而产生的新的微任务)直到清空,然后再拿出当前宏任务队列队首的任务...依次循环执行。

后续

  之后Node部分的事件循环等学习到那一步之后进行补充。

参考文献

感谢各位大神的讲解

带你彻底弄懂Event Loop

44.理解事件循环一(浅析)

一次弄懂Event Loop(彻底解决此类面试问题)

译文:JS事件循环机制(event loop)之宏任务、微任务

JS浏览器事件循环机制