面试题
前端面试经常出现事件循环的问题,可能出现的问题主要有下面几个:
- 事件循环的概念和原理。
- 什么是事件循环
- 事件循环解决了什么问题
- 事件循环的完整过程
- 宏任务和微任务的概念。
- 什么是宏任务和微任务
- 为什么要区分宏任务和微任务
- 说出代码片段执行结果。
- 异步代码执行顺序
知识点
什么是事件循环?
JavaScript是单线程的,为避免单线程中代码执行阻塞,JavaScript通过事件循环机制支持异步操作。
简言之,事件循环是JavaScript引擎的一种任务执行机制,用来支持异步操作。
同步任务和异步任务
为什么需要支持异步操作呢?
写的代码语句对于JavaScript引擎来说就是一个一个的任务。我们有时候希望代码一条语句接着一条语句执行(同步执行),有时候某些代码执行需要等待一段时间,我们希望先执行后续代码,等异步代码到达时机再执行(异步执行)。
举个例子
console.log(1)
setTimeout(() => {
console.log(2)
}, 1000)
console.log(3)
上述代码我们希望先打印1
,然后延时1s打印2
语句不会阻塞后语句执行,先打印3
,等1s之后再打印2
。
对于上面代码,打印1
、3
是同步任务,一条语句接着一条语句执行。而打印1
的是异步任务,它不阻塞后续代码,而是先执行后续代码,等异步任务达到时机(定时器到达时间)再执行。
那么浏览器是如何通过事件循环机制支持异步任务的呢?
事件循环原理
事件循环通过任务队列实现对异步任务的支持。JavaScript代码执行时候遇到异步任务,先交给其他线程(如定时器线程、网络请求线程),其他线程处理完,再把回调加入到JavaScript线程的任务队列中。
JavaScript线程会不断地轮询任务队列,发现有任务就取出来执行,这个循环的过程就称为事件循环。
console.log(1)
// 交给定时器线程计时,并继续执行后续代码
// 计时器线程结束后会把回调(打印2的函数)加入到任务队列中,JavaScript循环检测时候发现该任务则会执行
setTimeout(() => {
console.log(2)
}, 1000)
console.log(3)
异步任务有2种,一种是有一些任务需要其他模块支持,并且需要在其他模块处理完再把回调交给JavaScript线程执行。比如定时器、网络请求。
还有一种是JavaScript本身支持的,在当前代码执行过程中,希望创建一个任务,这个任务不影响后续代码执行,而是等当前代码执行完再执行。比如Promise、MutationObserver。
console.log(1)
Promise.resolve()
// 打印2的函数将被加入到任务队列中,然后继续执行后续代码。任务队列中的人物在后续轮询中被执行
.then(() => {
console.log(2)
})
console.log(3)
这两种异步任务前者被称为宏任务,后者被称为微任务。
为什么会有这两种类型的异步任务,在下面的章节中会详细说明。
总之,事件循环机制通过任务队列来支持异步任务,遇到异步任务交给其他线程执行或者加入到任务队列,然后继续执行后续代码,然后当前代码执行完后再从任务队列中取任务执行,这样就保证了异步任务不会阻塞后续代码的执行了。
事件循环的完整过程
事件循环的过程为:当执行栈空的时候,就会从任务队列中,取任务来执行。共分3步:
- 取一个宏任务来执行。执行完毕后,下一步。
- 取一个微任务来执行,执行完毕后,再取一个微任务来执行。直到微任务队列为空,执行下一步。
- 更新UI渲染。
每个循环称为一个tick。
可以简单描述为:一个宏任务 + 所有微任务 ->一个宏任务 + 所有微任务……,循环往复。
(其中,UI渲染会根据浏览器的逻辑,决定要不要马上执行更新,不一定在本次循环中立即执行。可以看到,事件循环中包含UI渲染,这就是为什么我们说JavaScript的执行会阻塞UI渲染。)
我们看一个例子
console.log(1);
new Promise(resolve => {
resolve();
console.log(2);
}).then(() => {
console.log(3);
})
setTimeout(() => {
console.log(4);
}, 0);
console.log(5);
我们用上面讲的事件循环过程分析一下代码执行过程
0. 初始状态
代码都在调用栈中
1. 第一步
执行当前调用栈,先打印1,然后执行new Promise,打印2,然后将.then回调放到微任务队列,将setTimeout回调放到宏任务队列,然后打印5,调用栈为空
打印1,2,5
2. 第二步
查看微任务队列,取出promise.then的回调放入调用栈中执行,执行完后调用栈为空。
打印3
3. 第三步
微任务队列为空,所以查找宏任务队列中的setTimeout回调,放入调用栈中,执行完后为空。
打印4
4. 结束
调用栈为空,执行结束
宏任务和微任务
为什么区分宏任务和微任务?
为了区分优先级。
我们已经知道,异步任务分为依赖外部线程的异步任务和JavaScript本身创建的异步任务。这两种异步任务的优先级是不同的。
JavaScript自己创建的异步任务应该优先执行,而不应该放到宏任务队列底部等待当前宏任务都执行完再执行。优先执行微任务,这样**微任务中所做的状态修改在下一轮事件循环中也能得到同步。**
我们通过Promise和MutationObserver这两个例子来看下。
const promise = Promise.resolve()
promise
.then(res => { console.log(1) })
.then(res => { console.log(2) })
console.log(3)
上面这段代码中,promise注册了两个回调,而这时候promise状态已经变为fullfilled
了,所以回调应该在下个tick之前就执行,而不应该等待其他宏任务执行完再执行。另外我们看到promise通过then注册了两个回调。如果不区分优先级,可能这两个then回调中间会被插入其他宏任务,这显然是没必要的。
再比如MutationObeserver:
<!DOCTYPE html>
<html>
<head>
<title>demo</title>
</head>
<body>
<div id="root">
</div>
<script>
const callback = (mutataionList, observer) => {
console.log(mutataionList[0].attributeName + ' was modified')
}
const observer = new MutationObserver(callback)
observer.observe(document.querySelector('#root'), {
attributes: true, childList: true, subtree: true
})
document.querySelector('#root').setAttribute('style', 'background-color: red')
console.log('after modified')
</script>
</body>
</html>
上面代码为DOM修改注册了一个回调,然后修改了DOM。
修改完DOM之后,在下个tick之前就应该执行回调,而不应该等后面宏任务都执行完再回调,因为每个tick都可能重新渲染界面,每个tick也可能修改DOM,所以当前修改DOM之后回调应该尽快执行。
总结一下,宏任务指依赖外部线程的异步任务,微任务指JavaScript本身创建的异步任务。区分宏任务和微任务是为了让JavaScript创建的任务更及时执行。
真题详解
1. 什么是事件循环?
事件循环是JavaScript引擎的一种任务执行机制,用来支持异步操作。避免阻塞后续代码执行。
2. 宏任务和微任务的区别?
- 宏任务队列依赖外部线程,是由外部线程加入到任务队列的;微任务不依赖外部线程,是JavaScript线程创建并加入到队列的。
- 执行优先级不同,每次tick取一个宏任务执行,然后再把微任务队列中所有的微任务都执行完。
- 宏任务队列可以有多个,微任务队列只有一个。
- 宏任务有 script(全局任务), setTimeout, setInterval, setImmediate, I/O, UI rendering。微任务有 process.nextTick, Promise, Object.observer, MutationObserver。
- 宏任务队列有优先级之分。每次js引擎从宏任务队列中取宏任务时,会按照优先级选择宏任务队列,若高优先级的宏任务队列中没有任务时,才会到低级的宏任务队列中去取任务。
3. 说出代码执行结果。
解题思路
根据上面事件循环执行过程的规则,我们可以总结解题的思路是
- 先列出调用栈、宏任务队列、微任务队列及初始状态:调用栈是当前的代码,最开始宏任务队列和微任务队列为空。
- 遇到new Promise,直接执行Promise的函数参数。
- 遇到resolve/reject,改变状态。
- 遇到promise.then/promise.catch,放入微任务队列、遇到setTimeout放入宏任务队列。
- 调用栈执行完后,从微任务队列依次取任务放到调用栈中执行,直到微任务队列中任务执行完为止。
- 微任务队列执行完后,从宏任务队列取一个放到调用栈执行,然后执行上一步,直到宏任务队列为空。
真题
真题1
题目:说出代码执行结果
const promise = new Promise((resolve,reject)=>{
console.log(1);
resolve();
console.log(2);
reject()
})
setTimeout(()=>{console.log(5)},0)
promise.then(()=>{console.log(3)})
.then(()=>{console.log(6)})
.catch(()=>{console.log(7)})
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
题目:说出代码执行结果
const first = () => (new Promise((resolve, reject) => {
console.log(3);
let p = new Promise((resolve, reject) => {
console.log(7);
setTimeout(() => {
console.log(5);
resolve();
}, 0);
resolve(1);
});
resolve(2);
p.then((arg) => {
console.log(arg);
});
}));
first().then((arg) => {
console.log(arg);
});
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。
调用栈,宏任务队列和微任务队列都为空,执行结束。