面试宝典-js事件循环机制及应用探讨
前端面试中,经常会被问到的知识点之一,就是js的事件循环机制,什么是事件循环?
什么是事件循环?
说事件循环之前要先说说单线程多线程,做服务端的朋友就会知道,如果是多线程编程,要考虑一些同步的问题,比如线程之间的通信, 线程之间的数据共享等等,而js是单线程的,这就意味着js中的代码是按照顺序执行的,不会有线程之间的同步问题, 但是js中也有异步操作,比如定时器、事件监听、ajax请求等等,这个时候怎么知道现在该做什么呢?
js执行会在同步代码执行完之后立马检查异步队列任务,而异步队列任务又分为 宏任务队列
和 微任务队列
。
这张图就可以表示js的事件循环机制,简单来说就是:
- 同步代码执行时,如果遇到异步代码(分为
宏任务
、宏任务
)会把他们放入各自的队列中 - 当同步代码执行完成,则检查当前
宏任务
队列,将其全部执行完成。 宏任务
队列执行完成后,检查宏任务
队列,执行队首的任务后。- 执行完一个
宏任务
后,再次检查宏任务
队列,如果宏任务
队列有任务则又执行完所有的宏任务
,然后再取一个宏任务
来执行。
队列和栈都是一种常见且前端需要了解的数据结构。
队列,它是一种先进先出(FIFO)的数据结构,即最先进入队列的元素最先被取出。
栈,它是一种后进先出(LIFO)的数据结构,即最后进入栈的元素最先被取出。
什么是宏任务和微任务?
上面提到的 宏任务
和 微任务
,是js中异步任务的两种分类,可以理解为是两种优先级不一样的异步任务,从上面的图可以看出来,
每次线程得空的时候都会把所有的 微任务
捞出来执行,然后再执行一个 宏任务
,然后又执行所有的 微任务
(如果有),因此我们可以看到两种任务的显著区别是:
微任务
,优先级高,线程得空将全部执行。宏任务
,优先级低于微任务
,执行一个宏任务
后就会先去执行所有的微任务
,然后再回来继续执行宏任务
。
这是 宏任务
和 微任务
的特性,后面我会介绍一些如何迎合这些特性的一些合理和高性能的编码场景。
宏任务(Macro Tasks)
常见的宏任务有:
- setTimeout
- setInterval
- setImmediate(nodejs)
- I/O 操作
- UI 渲染
- requestAnimationFrame
- 事件
可以看出来宏任务一般都是一些比较耗时,且完成时间不确定的操作
微任务(Micro Tasks)
常见的微任务有:
- Promise(async/await)
- process.nextTick(nodejs)
- MutationObserver
- queueMicrotask
微任务都是一些需要立即执行的任务,比如像MutationObserver需要及时的触发通知到处理函数。
示例代码:
console.log("Start");
setTimeout(() => {
console.log("Timeout 1");
}, 0);
Promise.resolve().then(() => {
console.log("Promise 1");
});
setTimeout(() => {
console.log("Timeout 2");
}, 0);
Promise.resolve().then(() => {
console.log("Promise 2");
});
console.log("End");
输出结果
Start
End
Promise 1
Promise 2
Timeout 1
Timeout 2
首先,console.log(“Start”) 和 console.log(“End”) 被立即执行,输出 “Start” 和 “End”。 setTimeout 的回调被放入任务队列,而 Promise 的回调被放入微任务队列。 在执行栈清空后,事件循环首先执行微任务队列中的回调,输出 “Promise 1” 和 “Promise 2”。 最后,事件循环执行任务队列中的回调,输出 “Timeout 1” 和 “Timeout 2”。
一些特别的场景
给timer设置时间0
setTimeout 和 setInterval 的延迟:即使你设置了延迟为 0,它仍然是一个宏任务
requestAnimationFrame的特殊性
requestAnimationFrame是比较特殊的一个宏任务,它会在浏览器下一次重绘之前执行,它在不同的显示器刷新率下,执行频率会不一样,比如60Hz每秒执行60次,120Hz下每秒执行120次, 而且它和setTimeout最大的不同是,如果微任务执行时间过长,它就不会执行,所以并不一定每秒执行了60次。比如如下代码,requestAnimationFrame正常执行1秒, 然后被微任务阻断,微任务中又添加新的微任务,一直到3秒之后才不添加新的微任务,此时requestAnimationFrame才会继续执行。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>requestAnimationFrame 被微任务阻断示例</title>
</head>
<body>
<p id="counter">计数器: 0</p>
<script>
// 获取用于显示计数器的元素
const counterElement = document.getElementById('counter');
let counter = 0;
// 模拟一个耗时 3 秒的微任务
function simulateLongRunningMicrotask() {
return new Promise((resolve) => {
const startTime = performance.now();
function checkTime() {
const currentTime = performance.now();
if (currentTime - startTime >= 3000) {
resolve();
} else {
queueMicrotask(checkTime);
}
}
queueMicrotask(checkTime);
});
}
// requestAnimationFrame 回调函数
function animate() {
counter++;
counterElement.textContent = `计数器: ${counter}`;
requestAnimationFrame(animate);
}
// 启动 requestAnimationFrame
requestAnimationFrame(animate);
// 执行耗时微任务
setTimeout(()=>{
simulateLongRunningMicrotask().then(() => {
console.log('耗时微任务执行完毕');
});
},1000)
</script>
</body>
</html>
如果浏览器正在执行一个耗时较长的宏任务,就可能会打乱 requestAnimationFrame 的正常执行节奏:
阻塞重绘时机:当浏览器忙于执行某个宏任务(如大量的计算、复杂的 DOM 操作等),且该宏任务的执行时间超过了一次屏幕刷新的间隔(约 16.7 毫秒),那么这次屏幕刷新就会被错过。由于 requestAnimationFrame 的回调是在重绘之前执行,所以对应的这次回调也会被跳过。
这个api推荐用于动画,而不是用于一些计算逻辑,因为它的执行频率是不稳定的。
queueMicrotask
推荐在一些需要在当前同步代码执行完之后立马就要执行的时候使用queueMicrotask,但是切记不要在微任务里面写入耗时的逻辑,否则可能阻断线程导致页面卡顿