深入解析JS事件循环机制 (Event Loop)
JavaScript是一门单线程语言,这意味着它在任意时刻只能执行一个任务。然而,在浏览器环境中,我们经常需要处理耗时的操作,如网络请求、定时器和用户交互,而不能因此阻塞主线程,影响用户体验。这就要归功于JavaScript的并发模型核心——事件循环(Event Loop)。
什么是事件循环?
事件循环是JavaScript运行时环境中一个持续不断的过程,它负责执行代码、收集和处理事件以及执行队列中的子任务。简单来说,事件循环就像一个永不疲倦的调度员,时刻监控着任务队列,并按照特定顺序将任务推入执行栈中执行。这种机制使得JavaScript能够在单线程的限制下实现非阻塞的异步操作。
事件循环的核心组成部分
要理解事件循环,首先需要了解其几个核心概念:
- 调用栈(Call Stack): 一个后进先出(LIFO)的数据结构,用于追踪函数的调用。当一个函数被调用时,它会被推入栈中;当函数执行完毕返回时,它会从栈中被弹出。
- Web APIs(或Node.js APIs): 由浏览器或Node.js环境提供,用于处理异步操作,例如
setTimeout
、setInterval
、DOM事件、fetch
请求等。这些API在后台处理耗时任务,而不会阻塞主线程。 - 任务队列(Task Queue / Macrotask Queue): 一个先进先出(FIFO)的队列,用于存放待处理的宏任务(Macrotask)。当一个异步操作(如
setTimeout
或用户点击)完成后,其回调函数会被放入任务队列中等待执行。 - 微任务队列(Microtask Queue): 另一个先进先出(FIFO)的队列,用于存放待处理的微任务(Microtask)。微任务的优先级高于宏任务。常见的微任务包括
Promise.then()
、Promise.catch()
、Promise.finally()
的回调和async/await
中的代码。
事件循环的运作流程
事件循环的整个过程可以简化为以下几个步骤,并周而复始地进行:
- 执行同步代码: JavaScript引擎会首先执行调用栈中的所有同步代码。
- 检查微任务队列: 当调用栈为空时(即所有同步代码执行完毕),事件循环会立即检查微任务队列。
- 清空微任务队列: 如果微任务队列不为空,事件循环会按顺序执行队列中所有的微任务,直到微任务队列被清空。如果在执行微任务的过程中又产生了新的微任务,这些新的微任务也会被添加到队列的末尾,并在当前轮次中一并执行。
- 执行一个宏任务: 当微任务队列为空后,事件循环会从任务队列(宏任务队列)中取出一个宏任务,并将其回调函数推入调用栈中执行。
- 重复循环: 一个宏任务执行完毕后,调用栈再次变空。事件循环会再次回到第二步,检查微任务队列,如此循环往复。
关键点: 每一轮事件循环中,只会执行一个宏任务,但会清空整个微任务队列。这就是为什么微任务总是在下一个宏任务之前执行的原因。
10道事件循环面试题
以下问题旨在检验您对事件循环机制的深入理解和在复杂场景下的应用能力。
题目一:基础执行顺序
问题: 预测下面代码的输出顺序,并解释为什么。
console.log('script start');setTimeout(function() {console.log('setTimeout');
}, 0);Promise.resolve().then(function() {console.log('promise1');
}).then(function() {console.log('promise2');
});console.log('script end');
输出顺序:
script start
script end
promise1
promise2
setTimeout
解析:
- 首先执行同步代码,打印
script start
和script end
。 setTimeout
的回调函数是一个宏任务,被放入宏任务队列。Promise.resolve().then()
的回调是微任务,被放入微任务队列。第一个.then()
返回的仍然是Promise,因此第二个.then()
也被视为微任务,并紧接着放入微任务队列。- 同步代码执行完毕,调用栈为空。事件循环检查微任务队列,发现里面有
promise1
和promise2
的回调。 - 依次执行微任务,打印
promise1
和promise2
。 - 微任务队列清空后,事件循环从宏任务队列中取出一个任务执行,即
setTimeout
的回调,打印setTimeout
。
题目二:async/await
与Promise
的结合
问题: 预测下面 async
函数相关代码的输出顺序。
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
解析:
- 同步代码执行,打印
script start
。 setTimeout
回调被放入宏任务队列。- 执行
async1()
。打印async1 start
。 - 执行
await async2()
。这会立即执行async2
函数,打印async2
。await
右侧的表达式执行完后,会把await
下方的代码(console.log('async1 end')
)作为微任务放入队列。然后跳出async1
函数。 - 继续执行同步代码,
new Promise
中的代码是同步的,打印promise1
。.then()
的回调被放入微任务队列。 - 继续执行同步代码,打印
script end
。 - 同步代码执行完毕,清空微任务队列。
await
的后续代码(async1 end
)比promise2
先入队。所以先打印async1 end
,再打印promise2
。 - 微任务队列清空后,执行宏任务,打印
setTimeout
。
题目三:宏任务与微任务的嵌套
问题: 预测以下复杂嵌套代码的输出顺序。
setTimeout(_ => console.log(4));new Promise(resolve => {resolve();console.log(1);
}).then(_ => {console.log(3);Promise.resolve().then(_ => {console.log('before timeout');}).then(_ => {Promise.resolve().then(_ => {console.log('also before timeout');})});
});console.log(2);
输出顺序:
1
2
3
before timeout
also before timeout
4
解析:
setTimeout
是宏任务,放入宏任务队列。new Promise
中的代码是同步的,打印1
。第一个.then
的回调是微任务,放入微任务队列。- 执行同步代码,打印
2
。 - 同步代码结束,清空微任务队列。执行第一个
.then
的回调,打印3
。 - 在执行这个微任务的过程中,又注册了新的微任务(
'before timeout'
)。根据规则,新产生的微任务会在当前轮次的微任务队列末尾执行。 - 因此,打印
'before timeout'
。这个微任务又注册了新的微任务 ('also before timeout'
),它同样在本轮被执行。 - 打印
'also before timeout'
。 - 所有微任务都执行完毕,开始下一轮事件循环,执行宏任务,打印
4
。
题目四:async/await
的错误处理
问题: 在下面的代码中,try...catch
能捕获到 promise
中的错误吗?如果能,输出是什么?如果不能,应该如何修改才能捕获到?
async function main() {try {console.log('try block start');new Promise((resolve, reject) => {reject('promise error');});console.log('try block end');} catch (e) {console.log('Caught error:', e);}
}main();
回答:
不能捕获到错误。try...catch
只能捕获其所在上下文中的同步代码错误或 await
的异步代码错误。
输出:
try block start
try block end
// Uncaught (in promise) promise error
解析:
new Promise
的执行器函数是同步执行的,但 reject
是异步的。当 reject
被调用时,try...catch
块已经执行完毕了。这个未被处理的 Promise rejection
会在全局作用域中抛出错误。
修改方案:
为了捕获错误,你必须 await
这个 Promise
:
async function main() {try {console.log('try block start');await new Promise((resolve, reject) => { // 加上 awaitreject('promise error');});console.log('try block end'); // 这行不会执行} catch (e) {console.log('Caught error:', e);}
}main();
// 输出:
// try block start
// Caught error: promise error
题目五:DOM 事件作为宏任务
问题: 用户点击一个按钮会立即触发 console.log
吗?假设用户在 “script end” 打印后、第一个 setTimeout
执行前立即点击了按钮,请解释输出顺序。
<button id="myBtn">Click me</button>
<script>const btn = document.getElementById('myBtn');btn.addEventListener('click', function() {console.log('Button clicked');Promise.resolve().then(() => console.log('Promise in click'));});Promise.resolve().then(() => console.log('Initial Promise'));setTimeout(() => console.log('Initial setTimeout'), 0);console.log('script end');
</script>
输出顺序:
script end
Initial Promise
Initial setTimeout
Button clicked
Promise in click
解析:
- 执行同步代码,为按钮添加事件监听器,打印
script end
。 Initial Promise
的回调是微任务,放入微任务队列。Initial setTimeout
的回调是宏任务,放入宏任务队列。- 同步代码执行完毕,清空微任务队列,打印
Initial Promise
。 - 微任务队列清空,执行第一个宏任务,打印
Initial setTimeout
。 - 此时,第一轮事件循环结束。假设用户在此时点击了按钮。点击事件的回调是一个新的宏任务,被放入宏任务队列。
- 事件循环开始新的一轮,从宏任务队列中取出点击事件的回调并执行。打印
Button clicked
。 - 在执行点击回调(宏任务)的过程中,产生了一个新的微任务(
Promise in click
)。 - 点击回调(宏任务)执行完毕后,调用栈为空。事件循环立即检查微任务队列,发现
Promise in click
的回调。 - 执行微任务,打印
Promise in click
。
题目六:渲染时机 (requestAnimationFrame
)
问题: requestAnimationFrame
(rAF) 的回调与 setTimeout
或 Promise.then
的回调,在执行时机上有什么本质区别?在下面的代码中,rAF
和 setTimeout
哪个会先执行?
setTimeout(() => console.log('Timeout'), 0);
requestAnimationFrame(() => console.log('rAF'));
Promise.resolve().then(() => console.log('Promise'));
回答:
-
本质区别:
Promise.then
是微任务,在当前同步代码执行完后立刻执行。setTimeout
是宏任务,在微任务队列清空后,并且达到指定延迟时间后执行。requestAnimationFrame
(rAF) 的执行时机则与它们都不同,它不是标准的宏任务,而是由浏览器在下一次重绘(Repaint)之前调用的一个特殊任务。其执行时机紧跟在所有微任务之后,但在UI渲染之前。 -
执行顺序:
Promise
->rAF
->Timeout
是最常见的顺序。
解析:
Promise
作为微任务,最先被执行。setTimeout
作为宏任务,被放入宏任务队列。rAF
的回调被放入一个专门的动画帧请求队列。- 同步代码执行完,清空微任务队列(打印
Promise
)。 - 接下来,在一轮事件循环中,浏览器会决定是否需要渲染。如果需要,它会在执行下一个宏任务(
setTimeout
)之前,去处理渲染相关的任务,其中就包括执行rAF
的回调。因此rAF
通常会在setTimeout(fn, 0)
之前执行。 - 最后,当渲染工作结束后(或者浏览器决定本轮不渲染),才从宏任务队列中取出任务执行,打印
Timeout
。
(注意:rAF
和 setTimeout
的精确顺序有时会因浏览器负载和刷新率而有细微差异,但原理是不变的:rAF
紧随渲染,而setTimeout
是标准宏任务。)
题目七:setTimeout(fn, 0)
的真正含义
问题: 为什么 setTimeout(fn, 0)
并不会让回调函数立即执行?它在事件循环中扮演什么角色?
回答:
setTimeout(fn, 0)
并不会让回调立即执行,而是尽快执行。它做的事情是:将回调函数 fn
作为一个新的宏任务,添加到宏任务队列的末尾。
角色和原因:
- 最小延迟:尽管设置了0毫秒延迟,但浏览器或Node.js环境通常有一个最小的延迟时间(例如在浏览器中通常是4ms左右)。所以它不会真的在0ms后执行。
- 事件循环机制:根据事件循环的规则,只有在当前执行栈为空(所有同步代码执行完毕)并且微任务队列也为空时,才会从宏任务队列中取出一个任务来执行。
- 应用场景:它的主要作用是改变代码的执行顺序,让我们能将一个任务推迟到下一个事件循环周期中执行。这在需要等待DOM更新完成,或者需要将一个高计算量的任务分片以避免阻塞主线程时非常有用。它提供了一种“让出主线程”的能力。
题目八:Node.js环境下的特殊性 (process.nextTick
)
问题: (Node.js 环境)process.nextTick
和 Promise.then
的执行优先级有何不同?预测以下代码在 Node.js 中的输出。
// Node.js Environment
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));console.log('sync');
输出顺序:
sync
nextTick
promise
timeout
immediate
(注意:timeout
和 immediate
的顺序在某些情况下可能交换,这取决于Node.js准备timer阶段的时机,但它们一定在 nextTick
和 promise
之后)
解析:
在Node.js的事件循环中,任务队列的优先级比浏览器更复杂:
process.nextTick
队列:这是一个特殊的队列,它的优先级最高。在当前同步代码执行完毕后,事件循环会立即清空nextTick
队列,其优先级高于微任务队列。- 微任务队列 (Microtask Queue):存放
Promise.then
等微任务。在nextTick
队列清空后,接着清空微任务队列。 - 宏任务队列 (Macrotask Queue):
setTimeout
,setImmediate
, I/O 等宏任务。
执行流程:
- 执行同步代码,打印
sync
。 process.nextTick
的回调放入nextTick
队列。Promise.then
的回调放入微任务队列。setTimeout
和setImmediate
的回调放入各自的宏任务队列。- 同步代码结束。首先检查
nextTick
队列,执行并打印nextTick
。 - 然后检查微任务队列,执行并打印
promise
。 - 最后进入宏任务阶段。通常会先进入
timers
阶段,执行setTimeout
的回调,打印timeout
。然后进入check
阶段,执行setImmediate
的回调,打印immediate
。
题目九:微任务队列“插队”
问题: 解释为什么在执行微任务的过程中产生的新微任务,会被添加到当前微任务队列的末尾并立即执行,而不是等待下一轮事件循环。请用下面的例子说明。
Promise.resolve().then(() => {console.log('promise1');Promise.resolve().then(() => {console.log('promise2');});
});setTimeout(() => console.log('timeout'), 0);
输出顺序:
promise1
promise2
timeout
解析:
事件循环的一个核心规则是:在进入下一个宏任务之前,必须将微任务队列完全清空。
setTimeout
回调进入宏任务队列。promise1
的回调进入微任务队列。- 同步代码执行完毕,事件循环开始处理微任务。它从队列中取出
promise1
的回调并执行,打印promise1
。 - 在执行
promise1
回调的过程中,promise2
的回调作为一个新的微任务被创建并添加到微任务队列的末尾。 - 此时,事件循环并不会结束微任务阶段,因为它会检查到微任务队列还不为空。它会继续从队列中取出下一个任务,也就是
promise2
的回调,并执行它,打印promise2
。 - 这个过程会一直持续,直到微任务队列被彻底清空。
- 微任务队列确认清空后,事件循环才会去宏任务队列中取出
timeout
的回调并执行。
这个机制确保了微任务的响应是即时的,并且总是在下一个宏任务(如UI渲染或setTimeout
)之前完成。
题目十:微任务队列“饿死”宏任务队列
问题: 请编写一个代码片段,演示微任务队列如何持续添加任务,从而导致宏任务队列(例如 setTimeout
的回调)永远得不到执行的机会。这在实际开发中会造成什么问题?
代码片段:
setTimeout(() => {// 这个宏任务可能永远不会被执行console.log('Timeout callback executed!');
}, 0);function infiniteMicrotasks() {Promise.resolve().then(() => {// 持续不断地将自己再次添加到微任务队列console.log('Running a microtask...');infiniteMicrotasks();});
}infiniteMicrotasks();console.log('Script finished.');
解析:
setTimeout
的回调被放入宏任务队列。infiniteMicrotasks()
被调用,它创建了一个Promise
,其.then()
回调被放入微任务队列。- 同步代码结束。事件循环开始清空微任务队列。
- 它执行第一个微任务,打印
Running a microtask...
。然而,在这个微任务的内部,又调用了infiniteMicrotasks()
,这会再次将一个新的微任务添加到微任务队列的末尾。 - 事件循环检查微任务队列,发现它仍然不为空,于是继续执行下一个微任务。这个过程无限循环。
- 由于微任务队列永远无法被清空,事件循环将永远无法进入下一个阶段去处理宏任务队列。因此,
setTimeout
的回调被“饿死”(starved),永远没有机会执行。
实际问题:
这种情况在实际开发中是灾难性的。它会导致:
- 页面完全冻结:浏览器UI渲染是一个宏任务。如果微任务无限循环,浏览器将永远没有机会重新渲染页面,导致页面卡死,对用户输入(点击、滚动)无响应。
- 异步操作无法完成:所有其他的宏任务,如
setTimeout
,setInterval
, I/O 操作的回调,都将无法执行。