声明:此文章摘取借鉴自一个知识点汇总网站:https://www.yuque.com/baiyueguang-rfnbu/tr4d0i/dm2emf
前端面试经常出现事件循环的问题,可能出现的问题主要有下面几个:
- 事件循环的概念和原理。
-
- 什么是事件循环
- 事件循环解决了什么问题
- 事件循环的完整过程
- 宏任务和微任务的概念。
-
- 什么是宏任务和微任务
- 为什么要区分宏任务和微任务
- 说出代码片段执行结果。
-
- 异步代码执行顺序
知识点
什么是事件循环?
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引擎从宏任务队列中取宏任务时,会按照优先级选择宏任务队列,若高优先级的宏任务队列中没有任务时,才会到低级的宏任务队列中去取任务。