【前端面试题】理清JavaScript事件循环(Event Loop)

为什么 JavaScript 是单线程?

作为浏览器端脚本语言,JS 主要用途是与用户交互,操作 DOM,发送网络请求等。这用途决定了单线程更合适,否则会带来很复杂的同步问题。因此为了避免复杂性,JavaScript 就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
如何解决并发问题呢?

  • JavaScript 使用异步来解决 I/O 的并发场景。
  • Runtime 通过 Web Worker、Child process(NodeJS) 等方式可以创建多线程(进程)来充分利用多核 CPU;
  • Event Loop 是实现异步 I/O 的一种方案(不唯一)。

拓展小知识:
并发(Concurrent),在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行。
并行(Parallel),当系统有一个以上 CPU 时,当一个 CPU 执行一个进程时,另一个 CPU 可以执行另一个进程,两个进程互不抢占 CPU 资源,可以同时进行,这种方式我们称之为并行(Parallel)。
它们区别有哪些呢?
并发,指的是多个事情,在同一时间段内同时发生了。并发的多个任务之间是互相抢占资源的。
并行,指的是多个事情,在同一时间点上同时发生了。并行的多个任务之间是不互相抢占资源的。
只有在多 CPU 的情况中,才会发生并行。否则,看似同时发生的事情,其实都是并发执行的。

事件循环

EventLoop

  • JavaScript 虽然是单线程,Engine 维护了一个栈用于执行进栈的任务;执行任务的过程可能会调用一些 Runtime 提供的异步接口。

  • Runtime 等待异步任务(如定时器、Promise、文件 I/O、网络请求等)完成后会把 callback 扔到 Task Queue(如定时器)或 Microtask Queue(如 Promise);

  • JavaScript 主线程栈空了后 Microtask Queue 的任务会依次扔到栈里执行,直到清空,之后会取出一个 Task Queue 里可以执行的任务扔到栈里执行;

  • 周而复始。

事件循环是通过 「任务队列」 的机制来进行协调的。一个 Event Loop 中,可以有一个或者多个任务队列(task queue),一个任务队列便是一系列有序任务(task)的集合;每个任务都有一个任务源(task source),「源自同一个任务源的 task 必须放到同一个任务队列,从不同源来的则被添加到不同队列」。 setTimeout/Promise 等 API 便是任务源,而进入任务队列的是他们指定的具体执行任务。

setTimeout/Promise 等 API 便是任务源,而进入任务队列的是他们指定的具体执行任务。

JS 中任务分为两类:
宏任务:是每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)。
浏览器为了能够使得 JS 内部(macrotask)与 DOM 任务能够有序的执行,会在一个(macrotask)执行结束后,在下一个(macrotask) 执行开始前,对页面进行重新渲染。

微任务:可以理解是在当前 task 执行结束后立即执行的任务。也就是说,在当前 task 任务后,下一个 task 之前,在渲染之前。
所以它的响应速度相比 setTimeout(setTimeout 是 task)会更快,因为无需等渲染。也就是说,在某一个 macrotask 执行完后,就会将在它执行期间产生的所有 microtask 都执行完毕(在渲染前)。
在挂起任务时,JS 引擎会将所有任务按照类别分到这两个队列中,首先在 macrotask 的队列(这个队列也被叫做 task queue)中取出第一个任务,执行完毕后取出 microtask 队列中的所有任务顺序执行;

宏任务和微任务执行流程:

  • 整段脚本 script 作为宏任务开始执行
  • 遇到微任务将其推入微任务队列,宏任务推入宏任务队列
  • 一个宏任务执行完毕,检查有没有可执行的微任务
  • 发现有可执行的微任务,将所有微任务执行完毕
  • 当前宏任务执行完毕,开始检查渲染,然后 GUI 线程接管渲染
  • 渲染完毕后,JS 线程继续接管,开始新的宏任务,反复如此直到所有任务执行完毕

总结起来的执行的循环顺序如下:
宏任务(一个) --> 微任务(所有) --> Dom 渲染 --> 宏任务

任务类型任务源
宏任务(macrotask )整段脚本script, setTimeout, setInterval,I/O, UI交互事件, postMessage, MessageChannel, setImmediate(Node.js 环境)
微任务(microtask )promise.then catch finally, process.nextTick(Node 使用) MutationObserver(浏览器使用)

注意点

  1. Promise 和 async 中的立即执行
    我们知道 Promise 中的异步体现在 then 和 catch 中,所以写在 Promise 中的代码是被当做同步任务立即执行的。而在 async/await 中,在出现 await 出现之前,其中的代码也是立即执行的。那么出现了 await 时候发生了什么呢?

  2. await 做了什么?
    从字面意思上看 await 就是等待,await 等待的是一个表达式,这个表达式的返回值可以是一个 promise 对象也可以是其他值。
    很多人以为 await 会一直等待之后的表达式执行完之后才会继续执行后面的代码,「实际上 await 是一个让出线程的标志」。
    await 紧跟后面的表达式会先执行一遍,将 await 后面的代码加入到 microtask 中,然后就会跳出整个 async 函数来执行后面的代码。
    由于因为 async await 本身就是 promise+generator 的语法糖。所以 await 后面的代码是 microtask。

面试解题小技巧

  • 脑中总是想着一个当前执行栈,当前脚本作为开始的宏任务,然后将遇到关键词(见上面总结)分配到不同的任务队列中。 执行栈完成后,取出来全部微队列(在微队列放入栈中执行时,可能会产出新的微队列,依然要取),微队列彻底为空时才去执行宏队列。
  • 有些面试比较坑,setTimeout后边有有个时间。会影响到输出顺序。
  • 见过比较好的,答题同学,是将当前执行队列在代码后边标注一下,方便记录执行到哪里了。 这种方法非常棒。

来看一些例子

  1. 热身题目, 宏队列和微队列的交替执行过程
setTimeout(() => {
  console.log('setTimeout')
}, 0)

let promise2 = new Promise((resolve) => {
  resolve('promise2.then')
  console.log('promise2')
})

promise2.then((res) => {
  console.log(res)
  Promise.resolve().then(() => {
    console.log('promise3')
    Promise.resolve().then(() => {
      console.log('promise4')
    })
  })
})
console.log('script end')

结果为:promise2, script end, promise2.then, promise3, promise4, setTimeout

  1. 题目 1
console.log('a')
setTimeout(function(){
 console.log('b')
}, 200)
setTimeout(function(){
 console.log('c')
}, 0)
console.log('d')

数据结果为: a d b c

  1. 题目 2

这道题其实是很经典的,包括如何把下面的输出给改对,经常被问到。

for(var i = 0; i < 10; i++) {
    setTimeout(function() {
        console.log(i)
    }, 1000)
}

结果:十个 10 每次 for 循环遇到 setTimeout 都将其放入事件队列中等待执行,直到全部循环结束,i 作为全局变量当循环结束后 i = 10 ,再来执行 setTimeout 时 i 的值已经为 10 , 结果为十个 10。

  1. 题目 3
const p = new Promise(resolve => {
 console.log('a')
 resolve()
 console.log('b')
})

p.then(() => {
 console.log('c')
})

console.log('d')

结果是 a b d c。 Promise 中的函数相当于在当前队列中因此首先会被执行所以 a b 会被输出,紧接着当前会将then 放在微队列中等待执行,但是当前队列中还有d被输出。

  1. 题目 4
setTimeout(function(){
 console.log('setTimeout')
}, 0)

const p = new Promise(resolve => {
 console.log('a')
 resolve()
 console.log('b')
})

p.then(() => {
 console.log('c')
 setTimeout(function(){
  console.log('then中的setTimeout')
 }, 0)
})

console.log('d')

结果为 a b d c setTimeout then中的setTimeout

  1. 题目 5
console.log('a');

new Promise(resolve => {
  console.log('b')
  resolve()
}).then(() => {
  console.log('c')
  setTimeout(() => { //macro----2
    console.log('d')
  }, 0)
})

setTimeout(() => { //macro ----3
  console.log('e')
  new Promise(resolve => {
    console.log('f')
    resolve()
  }).then(() => {
    console.log('g')
  })
}, 100)

setTimeout(() => {
  console.log('h')
  new Promise(resolve => {
    resolve()
  }).then(() => {
    console.log('i')
  })
  console.log('j')
}, 0)

结果为:a, b, c, h, j ,i, d, e, f, g。 注意中间 setTimeout 的时间间隔

  1. 题目 6
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('setTimeout')
}, 0)

async1()

new Promise(function (resolve) {
  console.log('promise1')
  resolve()
}).then(function () {
  console.log('promise2')
})
console.log('script end')

结果为:script start, async1 start, async2, promise1, script end, async1 end, promise2, setTimeout

变形题:

async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}
async function async2() {
  //async2做出如下更改:
  new Promise(function (resolve) {
    console.log('promise1');
    resolve();
  }).then(function () {
    console.log('promise2');
  });
}
console.log('script start');

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

new Promise(function (resolve) {
  console.log('promise3');
  resolve();
}).then(function () { //micro ----3
  console.log('promise4');
});

console.log('script end');

结果为: script start, async1 start, promise1,promise3, script end, promise2 , async1 end, promise4, setTimeout

变形题:

async function async1() {
  console.log('async1 start')
  await async2()
  // 更改如下:
  setTimeout(function () { // macro ----- 3
    console.log('setTimeout1')
  }, 0)
}

async function async2() { // macro ----- 2
  // 更改如下:
  setTimeout(function () {
    console.log('setTimeout2')
  }, 0)
}

console.log('script start')

setTimeout(function () { // macro ---- 1
  console.log('setTimeout3')
}, 0)

async1()

new Promise(function (resolve) {
  console.log('promise1')
  resolve()
}).then(function () {
  console.log('promise2')
})
console.log('script end')



结果为: script start, async1 start, promise1, script end, promise2, setTimeout3, setTimeout2, setTimeout1

async function a1() {
  console.log('a1 start')
  await a2()
  console.log('a1 end')
}

async function a2() {
  console.log('a2')
}

console.log('script start')

setTimeout(() => { // macro ---1
  console.log('setTimeout')
}, 0)

Promise.resolve().then(() => {
  console.log('promise1')
})

a1()

let promise2 = new Promise((resolve) => {
  resolve('promise2.then')
  console.log('promise2')
})

promise2.then((res) => {
  console.log(res)
  Promise.resolve().then(() => { // micro --- 1
    console.log('promise3')
  })
})
console.log('script end')

结果为:script start, a1 start, a2, promise2, script end, promise1, a1 end, promise2.then, promise3, setTimeout

关于我
loading