面试题:事件循环(Event Loop)

在日常工作中,你有没有碰到过这种疑惑:

  • 疑惑一:为什么这份代码它不按照我的意思走?为啥不是输出 1 2 3?
for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i); // 3 3 3
  }, 1000);
}
  • 疑惑二:为什么这份代码它也不按照我的意思走?为啥不是输出 jsliang?
let name;

setTimeout(() => {
  name = '梁峻荣';
  console.log(name); // 梁峻荣
}, 1000);

if (name) {
  name = 'jsliang';
  console.log(name);
}
1. Event Loop

首先,我们需要知道的是:JavaScript 是单线程的。
单线程意味着,所有任务都需要排队,前一个任务结束,才会执行后一个任务。
假设 jsliang 和 JavaScript 一样一次只能做一件事,那么大概就是如下图所示。
在这里插入图片描述
而这种 主线程从 “任务队列” 中读取执行事件,不断循环重复的过程,就被称为 事件循环(Event Loop)。
然后,如果前一个任务耗时很长,后一个任务就不得不一直等着,那么我们肯定要对这种情况做一些特殊处理,毕竟很多时候我们并不是完全希望它如此执行。

所以为了协调事件(event),用户交互(user interaction),脚本(script),渲染(rendering),网络(networking)等,用户代理(user agent)必须使用事件循环(event loops)。

这样,在了解浏览器 Event Loop 和 Node.js Event Loop 的情况下,我们就可以了解它的执行过程。
通过自身的了解,来处理一些较为棘手的问题。

为了加深小伙伴们的印象,可以看下图:
在这里插入图片描述
jsliang 日常中,强制被加上了 “被豆豆妈打”。
当然,这个被打的顺序也不一定是在后面,可能打多两次后,“睡觉” 完之后就是 “被豆豆妈打” 了。
通过这个解释,小伙伴们应该知道为啥有 浏览器 Event Loop 和 Node.js Event Loop 了。
等等,你刚才说到了 浏览器 Event Loop 和 Node.js Event Loop,为什么都是关于 JavaScript 的,在这两部分都不一样呢?

简单来说:你的页面放到了浏览器去展示,你的数据放到了后台处理(将 Node.js 看成 PHP、Java 等后端语言),这两者能没有区别么?

  • Node.js:Node.js 的 Event Loop 是基于 libuv,libuv 已经对 Event Loop 作出了实现;
  • 浏览器:浏览器的 Event Loop 是基于 HTML5 规范的。而 HTML5 规范中只是定义了浏览器中的 Event Loop 的模型,具体实现留给了浏览器厂商。

最后,咱们解疑开头的两个问题,为什么会这样子,有没办法解决?

  • 疑惑一:为什么这份代码它不按照我的意思走?为啥不是输出 1 2 3?
 for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i); // 3 3 3
  }, 1000);
}

这道题是面试常备题,它是个很有意思的问题,不仅可以让面试官跟你闲聊到 Event Loop,也可以闲聊下 var let const。
在这里插入图片描述
请仔细观看 GIF 图:

  • 在执行 for 遍历的时候,它先执行了和 setTimeout 同级的 console,然后往下执行,到 setTimeout 的时候,跳过了(放到某个位置)setTimeout,依次打印了 0, 1, 2。
  • 步骤 1 跳过的三次 setTimeout 开始执行,但是这时候的 i 的值,经过前面的 i++ 后,变成了 3(for 中止循环后,i 已经是 3 了)。所以,再依次打印了 3 3 3。

就是说,先走了正常的 for,然后碰到 setTimeout 时,将 setTimeout 依次放到了异次元,最后走完 for 后,再将异次元中的的 setTimeout 放出,依次将数字给输出了。

解决这个问题之前,不妨思考下下面的输出:

for (var i = 0; i < 3; i++) {

}
for (let j = 0; j < 3; j++) {

}
console.log(i); // 3
console.log(j); // ReferenceError: j is not defined

是不是有些想法,那么咱们再看下下面的解决方法,再进行总结:

for (let i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i); // 0 1 2
  }, 1000);
}

是的,将 var i 改成了 let i 后,输出的结果依次是 0 1 2 了。

为什么呢?简单回复就是:
let 在 for 中形成了独特的作用域块,当前的 i 只在本轮循环中有效,然后 setTimeout 会找到本轮最接近的 i,从而作出了正确的输出。
而我们通过 var 进行的定义,它会污染全局变量,所以在 for 外层,还可以看到 i 的值。

当然,讲到这里,你可能还是不太清楚更细节的区分,亦或者面试官进一步问你 var let const 的区分了,你要怎么更好回答?
JavaScript中 var、let、const 特性及区别详解

  • 疑惑二:为什么这份代码它也不按照我的意思走?为啥不是输出 梁峻荣?
let name;

setTimeout(() => {
  name = '梁峻荣';
  console.log(name); // 梁峻荣
}, 1000);

if (name) {
  name = 'jsliang';
  console.log(name);
}

当你了解产生疑惑一的原因后,疑惑二也就不破而解了。
我们希望的是 JavaScript 按照我们需要的顺序写,结果它并没有,就是因为受到了 Event Loop 的影响。

JavaScript 在碰到 setTimeout 的时候,会将它封印进异次元,只有等所有正常的语句(if、for……)执行完毕后,才会将它从异次元解封,输出最终结果。

2. 浏览器 Event Loop

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

  • 所有同步任务都在主线程上执行,形成一个 “执行栈”(execution context stack);
  • 主线程之外,存在一个 “任务队列”(task queue),在走主流程的时候,如果碰到异步任务,那么就在 “任务队列” 中放置这个异步任务;
  • 一旦 “执行栈” 中所有同步任务执行完毕,系统就会读取 “任务队列”,看看里面存在哪些事件。那些对应的异步任务,结束等待状态,进入执行栈,开始执行;
  • 主线程不断重复上面三个步骤;

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

宏任务(Macrotask):script(整体代码)、setTimeout、setInterval、XMLHttpRequest.prototype.onload、I/OUI 渲染
微任务(Microtask):Promise、MutationObserver

在这里插入图片描述
示例 1

// 位置 1
setTimeout(function () {
  console.log('timeout1');
}, 1000);

// 位置 2
console.log('start');

// 位置 3
Promise.resolve().then(function () {
  // 位置 5
  console.log('promise1');
  // 位置 6
  Promise.resolve().then(function () {
    console.log('promise2');
  });
  // 位置 7
  setTimeout(function () {
    // 位置 8
    Promise.resolve().then(function () {
      console.log('promise3');
    });
    // 位置 9
    console.log('timeout2')
  }, 0);
});

// 位置 4
console.log('done');

这是经典的面试题型,所以咱们看到不用慌,先拿我们上面的点,区分下分宏任务和微任务:

  • 宏任务(Macrotask):script(整体代码)、setTimeout、setInterval、XMLHttpRequest.prototype.onload、I/O、UI 渲染;
  • 微任务(Microtask):Promise、MutationObserver;
1.首先碰到的是 script(整体代码),先看【位置 1】,属于宏任务 setTimeout 下的,所以做个标记,待会回来执行。
2.接着碰到【位置 2】,这是 script(整体代码)下的无阻碍代码,直接执行即可。
3.再来碰到【位置 3】,它现在是 script(整体代码)下的微任务,所以做个标记,走完文件所有代码后,优先执行微任务,再执行宏任务。
4.最后碰到【位置 4】,它是 script(整体代码)下的无阻碍代码,直接执行即可。

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

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

到这一步,我们就走完了 script(整体代码)及之下的所有微任务了。

这时候,我们会说,【位置 1】和【位置 7】都被丢到任务队列了,是不是【位置 1】先走呢?
答案为:不是的。

同样的 setTimeout,jsliang 在测试的时候,就发现它们的输出结果在各个环境都有自己的流程,有时候先走【位置 7】,再走【位置 1】;而有时候先走【位置 1】,再走【位置 7】。
当然,如果你指定是在 Chrome 的控制台输出一下上面的代码,那就是先【位置 7】,再【位置 1】。

不要主观臆断某个代码会怎么走,最好还是直接实况运行走一波!

1.先走【位置 7】。碰到【位置 8】,将其添加到【位置 7】的微任务中,等【位置 7】所有代码执行完毕回来优先走微任务;
  碰到【位置 9】,这是无阻碍代码,直接输出即可。
2.执行【位置 7】的微任务【位置 8】,输出对应文本。
3.最后走【位置 1】,输出对应文本。

在这里插入图片描述

所以答案是:

start
done
promise1
promise2
timeout2
promise3
timeout1

示例 2

console.log("script start");

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

setTimeout(function() {
  console.log("setTimeout---200");
  setTimeout(function() {
    console.log("inner-setTimeout---0");
  });
  Promise.resolve().then(function() {
    console.log("promise5");
  });
}, 200);

Promise.resolve()
  .then(function() {
    console.log("promise1");
  })
  .then(function() {
    console.log("promise2");
  });
Promise.resolve().then(function() {
  console.log("promise3");
});
console.log("script end");
script start
script end
promise1
promise3
promise2
setTimeout---0
setTimeout---200
promise5
inner-setTimeout---0

在这里插入图片描述
示例 3

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

const promise = new Promise(function executor(resolve) {
  console.log(1);
  for (var i = 0; i < 10000; i++) {
    i == 9999 && resolve();
  }
  console.log(2);
}).then(function() {
  console.log(5);
});

console.log(3);
1
2
3
5
4

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

所以,这里咱们废话两点:

  • 了解宏任务和微任务的大体执行,例如 先走 if…else…,再走 Promise……但是,详细到每个 point 都记下来,这里不推荐;
  • 浏览器的 Event Loop 和 Node.js 的 Event Loop 不同,万一哪天 XX 小程序搞另类,有自己的 Event Loop,你要一一记住吗?
3. Node.js Event Loop

上面我们讲解过: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 官方讲解 Event Loop》

Node.js 在不停的探索中,也会有所更新,所以正应了 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 & setImmediate 和 process.nextTice()。

3.1 setTimeout & setImmediate

  • setTimeout:众所周知,这是一个定时器,指定 n 毫秒后执行定时器里面的内容;
  • setImmediate:Node.js 发现使用 setTimeout 和 setInterval 有些小弊端,所以设计了个 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 周期内被调度的,那么它将会在任何的定时器之前执行,跟这里存在多少个定时器无关。

3.2 process.nextTick()

nextTick 比较特殊,它存有自己的队列。它独立于 Event Loop,无论 Event Loop 处于何种阶段,都会在阶段结束的时候清空 nextTick 队列。还有需要注意的是:process.nextTick() 优先于其他的微任务(microtask)执行。

当然,如果你对此有所兴趣,你可以进一步探索源码,或者观察大佬们探索源码:

不要混淆nodejs和浏览器中的event loop
浏览器与Node的事件循环(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

示例 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');
2
4
end
3
5
1
6

小结
Node 端事件循环中的异步队列也是这两种:Macrotask(宏任务)队列和 Microtask(微任务)队列。

  • 常见的 Macrotask:setTimeout、setInterval、setImmediate、script(整体代码)、 I/O 操作等;
  • 常见的 Microtask:process.nextTick、new Promise().then(回调) 等;
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值