浏览器 Event Loop

在讲解浏览器的 Event Loop 前,我们需要先了解一下 JavaScript 的运行机制:

  1. 所有的任务分为同步任务和异步任务
  2. 所有同步任务都在主线程上执行,形成一个 “执行栈”(execution context stack)。
  3. 主线程之外,存在一个 “任务队列”(task queue),在走主流程的时候,如果碰到异步任务,那么就在 “任务队列” 中放置这个异步任务,如果异步任务有了运行结果,就在任务队列中注册事件。
  4. 一旦 “执行栈” 中所有同步任务执行完毕,系统就会读取 “任务队列”,看看里面存在哪些事件。那些对应的异步任务,结束等待状态,进入执行栈,开始执行。
  5. 主线程不断重复上面三个步骤。
  6. 一般情况下,宏任务先于微任务执行。

而 JavaScript 的异步任务,还细分两种任务:

  • 宏任务(Macrotask)script(整体代码)、setTimeoutsetIntervalXMLHttpRequest.prototype.onloadI/O、UI 渲染
  • 微任务(Microtask)PromiseMutationObserver

这么讲是不太容易理解的,咱们上图:

JS  Event Loop事件循环 - 图2示例 1

  1. // 位置 1
  2. setTimeout(function () {
  3. console.log('timeout1');
  4. }, 1000);
  5. // 位置 2
  6. console.log('start');
  7. // 位置 3
  8. Promise.resolve().then(function () {
  9. // 位置 5
  10. console.log('promise1');
  11. // 位置 6
  12. Promise.resolve().then(function () {
  13. console.log('promise2');
  14. });
  15. // 位置 7
  16. setTimeout(function () {
  17. // 位置 8
  18. Promise.resolve().then(function () {
  19. console.log('promise3');
  20. });
  21. // 位置 9
  22. console.log('timeout2')
  23. }, 0);
  24. });
  25. // 位置 4
  26. console.log('done');

提问:请指出上面代码的输出结果?
回答
这是经典的面试题型,所以咱们看到不用慌,先拿我们上面的点,区分下分宏任务和微任务:

  • 宏任务(Macrotask)script(整体代码)、setTimeoutsetIntervalXMLHttpRequest.prototype.onloadI/O、UI 渲染
  • 微任务(Microtask)PromiseMutationObserver

OK,开始走流程:

  1. 首先碰到的是 script(整体代码),先看【位置 1】,属于宏任务 setTimeout 下的,所以做个标记,待会回来执行。
  2. 接着碰到【位置 2】,这是 script(整体代码)下的无阻碍代码,直接执行即可。
  3. 再来碰到【位置 3】,它现在是 script(整体代码)下的微任务,所以咱们做个标记,走完文件所有代码后,优先执行微任务,再执行宏任务。
  4. 最后碰到【位置 4】,它是 script(整体代码)下的无阻碍代码,直接执行即可。

这样,第一波步骤,我们输出的是【位置 2】的 start 和【位置 4】的 done
我们接着走:

  1. 上面我们走完了第一遍代码,然后现在这一步先走 script(整体代码)下的微任务,即【位置 3】
    1. 先碰到【位置 5】,这是无阻碍代码,直接执行。
    2. 再碰到【位置 6】,这是微任务,标记一下,等下执行完【位置 3】内所有代码后,优先执行它。
    3. 最后碰到【位置 7】,这是宏任务,丢入任务队列,看它和【位置 1】谁先走了。
  2. 走完一遍【位置 3】后,发现还有微任务【位置 6】,所以执行【位置 6】,进行打印输出。

到这一步,我们就走完了 script(整体代码)及之下的所有微任务了。
这时候,我们会说,【位置 1】和【位置 7】都被丢到任务队列了,是不是【位置 1】先走呢?
答案为:不是的。
同样的 setTimeout,我在测试的时候,就发现它们的输出结果在各个环境都有自己的流程,有时候先走【位置 7】,再走【位置 1】;而有时候先走【位置 1】,再走【位置 7】。
当然,如果你指定是在 Chrome 的控制台输出一下上面的代码,那就是先【位置 7】,再【位置 1】~

  • point:不要主观臆断某个代码会怎么走,最好还是直接实况运行走一波!
  1. 先走【位置 7】。碰到【位置 8】,将其添加到【位置 7】的微任务中,等【位置 7】所有代码执行完毕回来优先走微任务;碰到【位置 9】,这是无阻碍代码,直接输出即可。
  2. 执行【位置 7】的微任务【位置 8】,输出对应文本。
  3. 最后走【位置 1】,输出对应文本。

所以答案是:

  1. start
  2. done
  3. promise1
  4. promise2
  5. timeout2
  6. promise3
  7. timeout1

示例 2

在上面,jsliang 花费了许多口水,讲了一些繁杂冗余的步骤,所以下面这个示例,请小伙伴们先自行猜设,得出结论后再翻看答案和调试 GIF~

示例 2

  1. console.log("script start");
  2. setTimeout(function() {
  3. console.log("setTimeout---0");
  4. }, 0);
  5. setTimeout(function() {
  6. console.log("setTimeout---200");
  7. setTimeout(function() {
  8. console.log("inner-setTimeout---0");
  9. });
  10. Promise.resolve().then(function() {
  11. console.log("promise5");
  12. });
  13. }, 200);
  14. Promise.resolve()
  15. .then(function() {
  16. console.log("promise1");
  17. })
  18. .then(function() {
  19. console.log("promise2");
  20. });
  21. Promise.resolve().then(function() {
  22. console.log("promise3");
  23. });
  24. console.log("script end");
  • 输出结果
    1. script start
    2. script end
    3. promise1
    4. promise3
    5. promise2
    6. setTimeout---0
    7. setTimeout---200
    8. promise5
    9. inner-setTimeout---0

示例 3

示例 3

  1. setTimeout(function() {
  2. console.log(4);
  3. }, 0);
  4. const promise = new Promise(function executor(resolve) {
  5. console.log(1);
  6. for (var i = 0; i < 10000; i++) {
  7. i == 9999 && resolve();
  8. }
  9. console.log(2);
  10. }).then(function() {
  11. console.log(5);
  12. });
  13. console.log(3);
  • 输出结果

    1
    2
    3
    5
    4
    

    如果不常用 Promise 的小伙伴,可能对此感到疑惑,为啥不是:3 1 2 5 4
    手动滑稽,别问,问就是进一步探索 Promise

  • 《jsliang 的 Promise 探索》:github.com/LiangJunron…

当然,还没将所有探索结果更新,如果有小伙伴催更会加快速度,欢迎留言或者私聊催更,哈哈~

小结

这样,我们就通过 3 个示例,大致了解了浏览器的 Event Loop。
当然,实际应用中的代码,何止这么简单,甚至有时候,面试官给你的面试题,也会让你瞠目结舌。
所以,这里咱们废话两点:

  1. 你可以了解宏任务和微任务的大体执行,例如先走 if...else...,再走 Promise……但是,详细到每个 point 都记下来,这里不推荐。大人,时代在进步,记住死的不如多在业务实践中尝试,取最新的知识。
  2. 浏览器的 Event Loop 和 Node.js 的 Event Loop 不同,万一哪天 XX 小程序搞另类,有自己的 Event Loop,你要一一记住吗?

碰到问题不要慌,程序员,折腾就对了~

Node.js Event Loop

返回目录

那么,下面咱们吐槽下 Node.js 的 Event Loop。

说实话,看完 Node 官网和大佬们关于 Node.js 的 Event Loop 讲解,让我想起了 Vue、React、微信小程序 的【生命周期】,再联想到我们的人生仿佛就像被写死的程序一样周期性、事件性运行,非常可恶,哈哈~

上面我们讲解过:Node.js 的 Event Loop 是基于 libuv。libuv 已经对 Event Loop 作出了实现。
那么其机制是怎样子的呢?看图:

┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

关于这 6 个阶段,官网描述为:

  • 定时器(timers):本阶段执行已经被 setTimeout()setInterval() 的调度回调函数。
  • 待定回调(pending callbacks):执行延迟到下一个循环迭代的 I/O 回调。
  • idle, prepare:仅系统内部使用。
  • 轮询(poll):检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有情况下,除了关闭的回调函数,那些由计时器和 setImmediate() 调度的之外),其余情况 Node 将在适当的时候在此阻塞。
  • 检测(check)setImmediate() 回调函数在这里执行。
  • 关闭的回调函数(close callbacks):一些关闭的回调函数,如:socket.on('close', ...)

当然,这里 jsliang 并不想画蛇添足,将官网或者其他大佬的文章照搬过来说是自己的,推荐小伙伴们阅读官网关于 Event Loop 的各个阶段的描述,以期在工作中有所使用

Node.js v9.5.0 Event Loop

┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘

但是,迫于生活所需,有些时候,前端面试官还是会跟你扯 setTimeout & setImmediateprocess.nextTice()

1 setTimeout & setImmediate

返回目录

  • setTimeout:众所周知,这是一个定时器,指定 n 毫秒后执行定时器里面的内容。
  • setImmediate:Node.js 发现使用 setTimeoutsetInterval 有些小弊端,所以设计了个 setImmediate,该方法被设计为一旦在当前轮询阶段完成,就执行这个脚本。

当然,光说无益,看代码:

index.js

setTimeout(() => {
  console.log('timeout');
}, 0);
setImmediate(() => {
  console.log('immediate');
});

猜测下在 VS Code 中执行 node index.js 命令会发生什么?

结局 1

immediate
timeout

结局 2

timeout
immediate

事实上这两个结局都是会存在的,看似 happy ending,但是有的小伙伴可能心里闹翻天。
按照官网的解释:

  • 执行计时器的顺序将根据调用它们的上下文而异。
  • 如果两则都从主模块内调用,则计时器将受到进程性能的约束(这可能会受到计算机上其他正在运行应用程序的影响)。
  • 如果你将这两个函数放入一个 I/O 循环内调用,setImmediate 总是被有限调用。

    const fs = require('fs');
    fs.readFile(__filename, () => {
    setTimeout(() => {
      console.log('timeout');
    }, 0);
    setImmediate(() => {
      console.log('immediate');
    });
    });
    复制代码
    

    虽然官方解释的很 巧妙,但是不管你懂不懂,反正我觉得有点扯淡。
    最后再来句官方总结:

  • 使用 setImmediate() 相对于 setTimeout 的主要优势是:如果 setImmediate() 是在 I/O 周期内被调度的,那么它将会在任何的定时器之前执行,跟这里存在多少个定时器无关。

enm…后面如果我具体使用 Node.js 的时候,我再进一步观察吧,至于现在,我还是先了解下即可。

2 process.nextTick()

返回目录

nextTick 比较特殊,它存有自己的队列。
并且,它独立于 Event Loop,无论 Event Loop 处于何种阶段,都会在阶段结束的时候清空 nextTick 队列。
还有需要注意的是:process.nextTick() 优先于其他的微任务(microtask)执行。
当然,如果你对此有所兴趣,你可以进一步探索源码,或者观察大佬们探索源码:

没有使用就没有发言权,作为一个 Node.js 菜鸡,这里就不妄加评论分析了。

3 示例 1

返回目录

下面开始示例,我们看下 Node.js 的 Event Loop 有何差异:

示例 1

setTimeout(() => {
  console.log("timer1");
  Promise.resolve().then(function() {
    console.log("promise1");
  });
});
setTimeout(() => {
  console.log("timer2");
  Promise.resolve().then(function() {
    console.log("promise2");
  });
});

如果你还记得上面讲解的浏览器的 Event Loop,你可能会将答案直接写成:

浏览器 Event Loop 输出:

timer1
promise1
timer2
promise2

是的你是对的,那就是浏览器的 Event Loop,到了 Node.js 这块,就有不同变化了:

Node.js Event Loop 输出:

timer1
timer2
promise1
promise2

尝试接受它!
然后大声默念:根据具体环境进行对应观察和得出结论

4 示例 2

返回目录

下面咱们再看一个示例:

示例 2

setTimeout(function () {
   console.log(1);
});
console.log(2);
process.nextTick(() => {
   console.log(3);
});
new Promise(function (resolve, rejected) {
   console.log(4);
   resolve()
}).then(res=>{
   console.log(5);
})
setImmediate(function () {
   console.log(6)
})
console.log('end');

node index.js

2
4
end
3
5
1
6

这里不打算解析,因为我怕初识 Event Loop 的小伙伴看完解释后懵逼,然后搞混淆了。

实话:我也不敢解析,因为我就是 Node.js 菜鸡

5 小结

返回目录

终上所述,我们进行小结:
Node 端事件循环中的异步队列也是这两种:Macrotask(宏任务)队列和 Microtask(微任务)队列。

  • 常见的 Macrotask:setTimeoutsetIntervalsetImmediatescript(整体代码)、 I/O 操作等。
  • 常见的 Microtask:process.nextTicknew Promise().then(回调) 等。

参考文献

  1. 《Tasks, microtasks, queues and schedules》 - Jake
  2. 《彻底搞懂浏览器 Event-loop》 - 刘小夕
  3. 《彻底理解 JS Event Loop(浏览器环境)》 - 93
  4. 《彻底弄懂浏览器端的 Event-Loop》 - 长可
  5. 《什么是浏览器的事件循环(Event Loop)?》 - 鱼子酱
  6. 《理解event loop(浏览器环境与nodejs环境)》 - sugerpocket
  7. 《从 event loop 规范探究 JavaScript 异步及浏览器更新渲染时机》 - 杨敬卓
  8. 《跟着 Event loop 规范理解浏览器中的异步机制》 - fi3ework
  9. 《不要混淆 nodejs 和浏览器中的 event loop》 - youth7
  10. 《浏览器的 event loop 和 node 的 event loop》 - 金大光
  11. 《浏览器与 Node 的事件循环(Event Loop)有何区别?》 - 浪里行舟
  12. 《浏览器和 Node 不同的事件循环(Event Loop)》 - toBeTheLight
  13. 《let 和 const 命令》 - 阮一峰
  14. 《Node.js Event Loop》 - Node.js 官网