我们都知道,Event Loop即事件循环,是指浏览器或Node的一种解决JavaScript单线程运行时不会阻塞的一种机制,即我们经常使用异步的原理。
而浏览器中的事件循环与Node.js事件循环机制各不相同。

1. 浏览器端Event Loop

1.1 我们为什么需要Event Loop

在介绍Event Loop原理前,我们必须得要搞清楚为什么需要Event Loop?
JavaScript是一门单线程语言,当一个函数执行时,它不会被抢占,只有在它运行完毕后才会去运行任何其他的代码,才能去修改这个函数操作的数据。然而当一个消息需要太长时间才能处理完毕时,Web应用程序就无法处理与用户的交互,例如点击或滚动;又或者是浏览新闻时图片加载过慢,网页不可能一直卡着直到图片完全显示出来。为了解决这些问题,我们将任务分为了两类:同步任务与异步任务。
在异步任务中,同一时间按照代码顺序等待执行。通常我们在遇到异步代码时将其挂起并略过,等待同步代码执行完毕后按照特定顺序执行异步代码。接下来我们深入了解一下。

1.2 JavaScript的运行模型

Event Loop - 图1
如图所示,当遇到同步代码时会立即执行;而遇到异步代码时将其加入到工作线程中,等异步代码所需时间到达后将其加入到任务队列当中。当执行栈为空时,被处理的消息被移除队列,并作为输入参数来调用与之关联的函数。函数的处理会一直进行到执行栈再次为空为止;然后事件循环将会处理队列中的下一个消息。
说了这么多,还不如来一个实例更容易理解:

  1. const s = new Date().getSeconds();
  2. setTimeout(function() {
  3. // 输出 "2",表示回调函数并没有在 500 毫秒之后立即执行
  4. console.log("Ran after " + (new Date().getSeconds() - s) + " seconds");
  5. }, 500);
  6. while(true) {
  7. if(new Date().getSeconds() - s >= 2) {
  8. console.log("looped for 2 seconds");
  9. break;
  10. }
  11. }

输出结果如下: :::info looped for 2 seconds
Ran after 2 seconds ::: 上述的代码执行流程如下:

  • 先将异步代码挂起,放在工作线程中;
  • 然后在运行同步代码while语句,等待间隔2秒;
  • 在500ms时,将异步任务加入任务队列中;到2秒时执行console.log("looped for 2 seconds")
  • 此时执行栈为空,任务队列推送任务setTimeout给执行栈,开始执行。

相信通过上述的代码,大家对Event Loop有了基本的了解。同时我们从代码中也可以发现,函数**setTimeout**中的时间值参数它代表的是消息被实际加入队列的最小延迟时间,而不是确切的等待时间。如果任务队列中没有其他消息且栈为空,在该延迟时间过后消息会被立马处理;但如果有其他消息,setTimeout消息必须等待其他消息处理完。

1.3 宏任务与微任务

在ECMA标准升级后,将异步任务分为了微任务和宏任务。
宏任务:是JS中原始的异步任务,包括setTimeoutsetIntervalAJAX等,在代码执行环境中按照同步代码的顺序,逐个进入工作线程挂起,再按照异步任务到达的时间节点,逐个进入异步任务队列,最终按照队列中的顺序进入函数执行栈执行。
微任务:每一个宏任务执行前,程序先检测是否有当次事件循环未执行的微任务,优先清空本次的微任务后,在执行下一个宏任务。每个宏任务内部可注册当次任务的微任务队列,在下一个宏任务执行前运行,微任务也是按照进入队列的顺序执行。包括PromiseMutationObserve等。
让我们先来看看两者的执行顺序:
Event Loop - 图2
执行栈的执行完同步任务后,判断执行栈是否为空。若执行栈为空,则去检查微任务队列是否为空,如果为空则去执行宏任务,否则一次性执行完所有的微任务。
宏任务执行完毕后,检查是否存在微任务队列是否为空。如果为空则继续执行下一个宏任务;否则去执行完所有的微任务,然后再去执行宏任务,如此循环。
举个例子:

  1. console.log('script start')
  2. async function async1() {
  3. await async2()
  4. console.log('async1 end')
  5. }
  6. async function async2() {
  7. console.log('async2 end')
  8. }
  9. async1()
  10. setTimeout(function() {
  11. console.log('setTimeout')
  12. }, 0)
  13. new Promise(resolve => {
  14. console.log('Promise')
  15. resolve()
  16. })
  17. .then(function() {
  18. console.log('promise1')
  19. })
  20. .then(function() {
  21. console.log('promise2')
  22. })
  23. console.log('script end')

输出结果如下: :::info script start
async2 end
Promise
script end
async1 end
promise1
promise2
setTimeout ::: 在讲解之前,我们必须得要理解async/await在底层是转换成promisethen的回调函数。当我们使用await,解释器创建一个promise对象,然后把剩下的async函数操作放到then回调函数中。我们需要知道,在同一个上下文当中,总的执行顺序为同步代码 ——> 微任务 ——> 宏任务。
我相信,根据上面的讲述大家应该都能正确理解,这里就不过多讲解了。

2. Node.js环境Event Loop

2.1 执行流程

Node.jsEvent Loop分为六个阶段,它们按照顺序反复执行,在每个阶段后面都会运行微任务队列

  1. - `timers`:执行`setTimeout()` `setInterval()`中到期的callback
  2. - `I/O callbacks`:上一轮循环中有少数的`I/Ocallback`会被延迟到这一轮的这一阶段执行
  3. - `idle, prepare`:队列的移动,仅内部使用
  4. - `poll`:最为重要的阶段,执行`I/O callback`,在适当的条件下会阻塞在这个阶段
  5. - `check`:执行`setImmediate`callback
  6. - `close callbacks`:执行close事件的callback,例如s`ocket.on("close",func)`

Event Loop - 图3

2.2 setTimeout/setImmediate

在运行过程中,如果timers阶段执行时创建了setImmediate,则会在此轮循环的check阶段中执行;如果timers阶段创建了setTimeout,此时由于timers已取出完毕,则会进入到下一轮循环。check阶段创建timers任务同理。
来个代码演示一下:

  1. const fs = require('fs');
  2. // 此时处在 I/O 周期
  3. fs.readFile(__filename, () => {
  4. setTimeout(() => {
  5. console.log('timeout');
  6. }, 0);
  7. setImmediate(() => {
  8. console.log('immediate');
  9. });
  10. });
  1. 运行结果一定为`setImmediate > setTimeout`, 因为在`I/O`阶段读取文件后进行到了`poll`阶段,然后到`check`阶段,此时会立刻执行`setImmediate`,等到进入`timers`阶段采取执行`setTimeout`

2.3 Process.nextTick()

process.nextTick()callback添加到next tick队列。

  1. setTimeout(() => console.log(1));
  2. setImmediate(() => console.log(2));
  3. Promise.resolve().then(() => console.log(3));
  4. process.nextTick(() => console.log(4));
  5. // 输出结果:4 3 1 2或者4 3 2 1

从输出结果可以看到,微任务比宏任务先运行,而在Nodeprocess.nextTickPromise更为优先,所以输出结果4 —> 3;但在Node中没有绝对意义上的0ms,所以setTimeoutsetImmediate顺序不固定。

2.4 浏览器与Node执行顺序比较

  1. setTimeout(()=>{
  2. console.log('timeout1')
  3. Promise.resolve().then(function() {
  4. console.log('promise1')
  5. })
  6. }, 0)
  7. setTimeout(()=>{
  8. console.log('timeout2')
  9. Promise.resolve().then(function() {
  10. console.log('promise2')
  11. })
  12. }, 0)

输出结果如下: :::info 在浏览器中:timeout1 —> promise1 —> timeout2 —> promise2
在Node环境中: timeout1 —> timeout2 —> promise1 —> promise2 ::: 下面来解释一下Node环境中的逻辑:
开始时进入timers阶段,执行timeout1的回调,打印出timeout1后将promise。then()放入微任务队列中,相同步骤执行 timeout2。在timers阶段结束后进入下一个阶段前,执行微任务队列中的所有任务,依次打印出promise1promise2.
浏览器的执行顺序就不过多讲述了。

3、总结

总体而言,浏览器端与Node.js的执行顺序大有不同。最大的差异在于浏览器端按照同步代码 —>微任务 —>宏任务的顺序执行;而Node.js环境中按照六个阶段顺序执行,且在每个阶段结束后都会执行微任务队列里的所有任务。

本文就写到这里,如有错误,敬请指正!