面试题

前端面试经常出现事件循环的问题,可能出现的问题主要有下面几个:

  1. 事件循环的概念和原理。
    • 什么是事件循环
    • 事件循环解决了什么问题
    • 事件循环的完整过程
  2. 宏任务和微任务的概念。
    • 什么是宏任务和微任务
    • 为什么要区分宏任务和微任务
  3. 说出代码片段执行结果。
    • 异步代码执行顺序

知识点

什么是事件循环?

JavaScript是单线程的,为避免单线程中代码执行阻塞,JavaScript通过事件循环机制支持异步操作。

简言之,事件循环是JavaScript引擎的一种任务执行机制,用来支持异步操作。

同步任务和异步任务

为什么需要支持异步操作呢?

写的代码语句对于JavaScript引擎来说就是一个一个的任务。我们有时候希望代码一条语句接着一条语句执行(同步执行),有时候某些代码执行需要等待一段时间,我们希望先执行后续代码,等异步代码到达时机再执行(异步执行)。

举个例子

  1. console.log(1)
  2. setTimeout(() => {
  3. console.log(2)
  4. }, 1000)
  5. console.log(3)

上述代码我们希望先打印1,然后延时1s打印2语句不会阻塞后语句执行,先打印3,等1s之后再打印2

对于上面代码,打印13是同步任务,一条语句接着一条语句执行。而打印1的是异步任务,它不阻塞后续代码,而是先执行后续代码,等异步任务达到时机(定时器到达时间)再执行。

那么浏览器是如何通过事件循环机制支持异步任务的呢?

事件循环原理

事件循环通过任务队列实现对异步任务的支持。JavaScript代码执行时候遇到异步任务,先交给其他线程(如定时器线程、网络请求线程),其他线程处理完,再把回调加入到JavaScript线程的任务队列中。

JavaScript线程会不断地轮询任务队列,发现有任务就取出来执行,这个循环的过程就称为事件循环。

JavaScript事件循环 - 图2

  1. console.log(1)
  2. // 交给定时器线程计时,并继续执行后续代码
  3. // 计时器线程结束后会把回调(打印2的函数)加入到任务队列中,JavaScript循环检测时候发现该任务则会执行
  4. setTimeout(() => {
  5. console.log(2)
  6. }, 1000)
  7. console.log(3)

异步任务有2种,一种是有一些任务需要其他模块支持,并且需要在其他模块处理完再把回调交给JavaScript线程执行。比如定时器、网络请求。

还有一种是JavaScript本身支持的,在当前代码执行过程中,希望创建一个任务,这个任务不影响后续代码执行,而是等当前代码执行完再执行。比如Promise、MutationObserver。

  1. console.log(1)
  2. Promise.resolve()
  3. // 打印2的函数将被加入到任务队列中,然后继续执行后续代码。任务队列中的人物在后续轮询中被执行
  4. .then(() => {
  5. console.log(2)
  6. })
  7. console.log(3)

这两种异步任务前者被称为宏任务,后者被称为微任务。

为什么会有这两种类型的异步任务,在下面的章节中会详细说明。

总之,事件循环机制通过任务队列来支持异步任务,遇到异步任务交给其他线程执行或者加入到任务队列,然后继续执行后续代码,然后当前代码执行完后再从任务队列中取任务执行,这样就保证了异步任务不会阻塞后续代码的执行了。

事件循环的完整过程

事件循环的过程为:当执行栈空的时候,就会从任务队列中,取任务来执行。共分3步:

  1. 取一个宏任务来执行。执行完毕后,下一步。
  2. 取一个微任务来执行,执行完毕后,再取一个微任务来执行。直到微任务队列为空,执行下一步。
  3. 更新UI渲染。

每个循环称为一个tick。

可以简单描述为:一个宏任务 + 所有微任务 ->一个宏任务 + 所有微任务……,循环往复。

(其中,UI渲染会根据浏览器的逻辑,决定要不要马上执行更新,不一定在本次循环中立即执行。可以看到,事件循环中包含UI渲染,这就是为什么我们说JavaScript的执行会阻塞UI渲染。)

我们看一个例子

  1. console.log(1);
  2. new Promise(resolve => {
  3. resolve();
  4. console.log(2);
  5. }).then(() => {
  6. console.log(3);
  7. })
  8. setTimeout(() => {
  9. console.log(4);
  10. }, 0);
  11. console.log(5);

我们用上面讲的事件循环过程分析一下代码执行过程

0. 初始状态

代码都在调用栈中

JavaScript事件循环 - 图3

1. 第一步

执行当前调用栈,先打印1,然后执行new Promise,打印2,然后将.then回调放到微任务队列,将setTimeout回调放到宏任务队列,然后打印5,调用栈为空

打印1,2,5

JavaScript事件循环 - 图4

2. 第二步

查看微任务队列,取出promise.then的回调放入调用栈中执行,执行完后调用栈为空。

打印3

JavaScript事件循环 - 图5

3. 第三步

微任务队列为空,所以查找宏任务队列中的setTimeout回调,放入调用栈中,执行完后为空。

打印4

JavaScript事件循环 - 图6

4. 结束

调用栈为空,执行结束

JavaScript事件循环 - 图7

宏任务和微任务

为什么区分宏任务和微任务?

为了区分优先级。

我们已经知道,异步任务分为依赖外部线程的异步任务和JavaScript本身创建的异步任务。这两种异步任务的优先级是不同的。

JavaScript自己创建的异步任务应该优先执行,而不应该放到宏任务队列底部等待当前宏任务都执行完再执行。优先执行微任务,这样**微任务中所做的状态修改在下一轮事件循环中也能得到同步。**

我们通过Promise和MutationObserver这两个例子来看下。

  1. const promise = Promise.resolve()
  2. promise
  3. .then(res => { console.log(1) })
  4. .then(res => { console.log(2) })
  5. console.log(3)

上面这段代码中,promise注册了两个回调,而这时候promise状态已经变为fullfilled了,所以回调应该在下个tick之前就执行,而不应该等待其他宏任务执行完再执行。另外我们看到promise通过then注册了两个回调。如果不区分优先级,可能这两个then回调中间会被插入其他宏任务,这显然是没必要的。

再比如MutationObeserver:

  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <title>demo</title>
  5. </head>
  6. <body>
  7. <div id="root">
  8. </div>
  9. <script>
  10. const callback = (mutataionList, observer) => {
  11. console.log(mutataionList[0].attributeName + ' was modified')
  12. }
  13. const observer = new MutationObserver(callback)
  14. observer.observe(document.querySelector('#root'), {
  15. attributes: true, childList: true, subtree: true
  16. })
  17. document.querySelector('#root').setAttribute('style', 'background-color: red')
  18. console.log('after modified')
  19. </script>
  20. </body>
  21. </html>

上面代码为DOM修改注册了一个回调,然后修改了DOM。

修改完DOM之后,在下个tick之前就应该执行回调,而不应该等后面宏任务都执行完再回调,因为每个tick都可能重新渲染界面,每个tick也可能修改DOM,所以当前修改DOM之后回调应该尽快执行。

总结一下,宏任务指依赖外部线程的异步任务,微任务指JavaScript本身创建的异步任务。区分宏任务和微任务是为了让JavaScript创建的任务更及时执行。

真题详解

1. 什么是事件循环?

事件循环是JavaScript引擎的一种任务执行机制,用来支持异步操作。避免阻塞后续代码执行。

2. 宏任务和微任务的区别?

  1. 宏任务队列依赖外部线程,是由外部线程加入到任务队列的;微任务不依赖外部线程,是JavaScript线程创建并加入到队列的。
  2. 执行优先级不同,每次tick取一个宏任务执行,然后再把微任务队列中所有的微任务都执行完。
  3. 宏任务队列可以有多个,微任务队列只有一个。
  4. 宏任务有 script(全局任务), setTimeout, setInterval, setImmediate, I/O, UI rendering。微任务有 process.nextTick, Promise, Object.observer, MutationObserver。
  5. 宏任务队列有优先级之分。每次js引擎从宏任务队列中取宏任务时,会按照优先级选择宏任务队列,若高优先级的宏任务队列中没有任务时,才会到低级的宏任务队列中去取任务。

3. 说出代码执行结果。

解题思路

根据上面事件循环执行过程的规则,我们可以总结解题的思路是

  1. 先列出调用栈、宏任务队列、微任务队列及初始状态:调用栈是当前的代码,最开始宏任务队列和微任务队列为空。
  2. 遇到new Promise,直接执行Promise的函数参数。
  3. 遇到resolve/reject,改变状态。
  4. 遇到promise.then/promise.catch,放入微任务队列、遇到setTimeout放入宏任务队列。
  5. 调用栈执行完后,从微任务队列依次取任务放到调用栈中执行,直到微任务队列中任务执行完为止。
  6. 微任务队列执行完后,从宏任务队列取一个放到调用栈执行,然后执行上一步,直到宏任务队列为空。

真题

真题1

题目:说出代码执行结果

  1. const promise = new Promise((resolve,reject)=>{
  2. console.log(1);
  3. resolve();
  4. console.log(2);
  5. reject()
  6. })
  7. setTimeout(()=>{console.log(5)},0)
  8. promise.then(()=>{console.log(3)})
  9. .then(()=>{console.log(6)})
  10. .catch(()=>{console.log(7)})
  11. console.log(4)

解析

答案是1,2,4,3,6,5

首先new Promise时候打印1和2,因为new Promise时候会立即执行传入的方法

然后后面代码都是异步代码,先将setTimeout的回调加入宏任务队列,再把promise.then放入到微任务队列,然后直接执行最后一句,打印4

这样宏任务代码执行完了,接下来开始执行微任务队列中的任务,由于promise resolve,因为promise resolve之后状态不会再改变,因此不会执行到reject的对调,所以打印3和6

微任务队列为空,再到宏任务队列中查找任务,找到setTimeout回调执行,打印5

调用栈、宏任务队列、微任务队列都为空,代码执行结束。

真题2

题目:说出代码执行结果

  1. const first = () => (new Promise((resolve, reject) => {
  2. console.log(3);
  3. let p = new Promise((resolve, reject) => {
  4. console.log(7);
  5. setTimeout(() => {
  6. console.log(5);
  7. resolve();
  8. }, 0);
  9. resolve(1);
  10. });
  11. resolve(2);
  12. p.then((arg) => {
  13. console.log(arg);
  14. });
  15. }));
  16. first().then((arg) => {
  17. console.log(arg);
  18. });
  19. console.log(4);

解析

3, 7, 4, 1, 2, 5

首先定义first

然后执行first,然后执行new Promise传入的方法,先打印3

又new Promise,执行其中传入的方法,打印7

执行setTimeout,将回调放入宏任务队列

执行resolve(1),将内部promise状态置为fullfilled,值为1

执行resolve(2),将外部promise状态置为fullfilled,值为2

执行内部promise.then方法,将回调加入微任务队列

执行first().then,即外部的promise,将回调加入到微任务队列

调用栈为空,开始从微任务队列拿取任务,首先拿到内部promise的回调,打印其值1

然后从微任务队列中拿取外部的promise的回调,打印其值2

此时微任务队列为空,开始从宏任务队列中拿取任务,即setTimeout回调,打印5。

调用栈,宏任务队列和微任务队列都为空,执行结束。