JavaScript 运行时中的事件循环是什么?
call stack 和 task queue 之间有什么区别?TL;DR
事件循环是 JavaScript 运行时环境内关于异步操作如何在 JavaScript 引擎中执行的概念。它的工作原理如下:
- JavaScript 引擎开始执行脚本,将同步操作放在调用堆栈上。
- 当遇到异步操作(例如,
setTimeout()
、HTTP 请求)时,它会被卸载到相应的 Web API 或 Node.js API,以在后台处理该操作。 - 一旦异步操作完成,其回调函数将被放置在相应的队列中——任务队列(也称为宏任务队列/回调队列)或微任务队列。从这里开始,我们将“任务队列”称为“宏任务队列”,以便更好地与微任务队列区分开来。
- 事件循环持续监视调用堆栈并执行调用堆栈上的项目。如果/当调用堆栈为空时:
- 处理微任务队列。微任务包括 promise 回调(
then
、catch
、finally
)、MutationObserver
回调和对queueMicrotask()
的调用。事件循环从微任务队列中获取第一个回调并将其推送到调用堆栈以供执行。重复此操作,直到微任务队列为空。 - 处理宏任务队列。宏任务包括 Web API,如
setTimeout()
、HTTP 请求、用户界面事件处理程序,如点击、滚动等。事件循环从宏任务队列中出列第一个回调并将其推送到调用堆栈以供执行。但是,在处理完宏任务队列回调后,事件循环不会继续处理下一个宏任务!事件循环首先检查微任务队列。检查微任务队列是必要的,因为微任务的优先级高于宏任务队列回调。刚刚执行的宏任务队列回调可能添加了更多微任务!- 如果微任务队列非空,则按上一步处理它们。
- 如果微任务队列为空,则处理下一个宏任务队列回调。重复此操作,直到宏任务队列为空。
- 处理微任务队列。微任务包括 promise 回调(
- 此过程无限期地持续进行,允许 JavaScript 引擎高效地处理同步和异步操作,而不会阻塞调用堆栈。
不幸的是,仅使用文本很难很好地解释事件循环。我们建议查看以下优秀的视频之一,解释事件循环:
- JavaScript Visualized - Event Loop, Web APIs, (Micro)task Queue (2024): Lydia Hallie 是 JavaScript 方面的热门教育家,这是解释事件循环的最新最佳视频。对于那些喜欢详细的基于文本的解释的人,还有一个配套博客文章。
- In the Loop (2018): 来自 Chrome 团队的 Jake Archibald 在 JSConf 2018 期间提供了事件循环的视觉演示,解释了不同类型的任务。
- What the heck is the event loop anyway? (2014): Philip Robert 在 JSConf 2014 上发表了这次史诗般的演讲,这是 YouTube 上观看次数最多的 JavaScript 视频之一。
我们建议观看 Lydia 的视频,因为它是最现代和简洁的解释,时长仅为 13 分钟,而其他视频至少需要 30 分钟。她的视频足以满足面试的目的。
JavaScript 中的事件循环
事件循环是 JavaScript 异步操作的核心。它是一种处理代码执行的机制,允许异步操作并确保 JavaScript 引擎的单线程性质不会阻塞程序的执行。
事件循环的组成部分
为了更好地理解它,我们需要了解系统的所有组成部分。这些组件是事件循环的一部分:
调用堆栈
调用堆栈跟踪程序中正在执行的函数。当调用一个函数时,它被添加到调用堆栈的顶部。当函数完成时,它将从调用堆栈中删除。这允许程序跟踪它在函数执行中的位置,并在函数完成时返回到正确的位置。顾名思义,它是一个遵循后进先出的堆栈数据结构。
Web API/Node.js API
异步操作,如 setTimeout()
、HTTP 请求、文件 I/O 等,由 Web API(在浏览器中)或 C++ API(在 Node.js 中)处理。这些 API 不是 JavaScript 引擎的一部分,并且在单独的线程上运行,允许它们并发执行而不会阻塞调用堆栈。
任务队列 / 宏任务队列 / 回调队列
任务队列,也称为宏任务队列/回调队列/事件队列,是一个保存需要执行的任务的队列。这些任务通常是异步操作,例如传递给 Web API 的回调(setTimeout()
、setInterval()
、HTTP 请求等)以及用户界面事件处理程序,如点击、滚动等。
微任务队列
Microtasks 是比 macrotasks 具有更高优先级的任务,它们在当前执行的脚本完成之后、下一个 macrotask 执行之前立即执行。Microtasks 通常用于更即时、轻量级的操作,这些操作应在当前操作完成后尽快执行。有一个专门用于 microtasks 的 microtask 队列。Microtasks 包括 promise 回调(then()
、catch()
和 finally()
)、await
语句、queueMicrotask()
和 MutationObserver
回调。
事件循环顺序
- JavaScript 引擎开始执行脚本,将同步操作放在调用堆栈上。
- 当遇到异步操作(例如,
setTimeout()
、HTTP 请求)时,它会被卸载到相应的 Web API 或 Node.js API,以在后台处理该操作。 - 一旦异步操作完成,其回调函数将被放置在相应的队列中——任务队列(也称为 macrotask 队列/回调队列)或 microtask 队列。从这里开始,我们将“任务队列”称为“macrotask 队列”,以便更好地与 microtask 队列区分开来。
- 事件循环持续监视调用堆栈并执行调用堆栈上的项目。如果/当调用堆栈为空时:
- microtask 队列被处理。事件循环从 microtask 队列中获取第一个回调,并将其推送到调用堆栈以供执行。这将重复进行,直到 microtask 队列为空。
- macrotask 队列被处理。事件循环从 macrotask 队列中取出第一个回调,并将其推送到调用堆栈上以供执行。但是,在处理完 macrotask 队列回调之后,事件循环不会继续处理下一个 macrotask!事件循环首先检查 microtask 队列。检查 microtask 队列是必要的,因为 microtasks 的优先级高于 macrotask 队列回调。刚刚执行的 macrotask 队列回调可能添加了更多 microtasks!
- 如果 microtask 队列非空,则按上一步处理它们。
- 如果 microtask 队列为空,则处理下一个 macrotask 队列回调。这将重复进行,直到 macrotask 队列为空。
- 此过程无限期地持续进行,允许 JavaScript 引擎高效地处理同步和异步操作,而不会阻塞调用堆栈。
示例
以下代码使用正常执行、macrotasks 和 microtasks 的组合来记录一些语句。
console.log('Start');setTimeout(() => {console.log('Timeout 1');}, 0);Promise.resolve().then(() => {console.log('Promise 1');});setTimeout(() => {console.log('Timeout 2');}, 0);console.log('End');// Console output:// Start// End// Promise 1// Timeout 1// Timeout 2
输出说明:
Start
和End
首先被记录,因为它们是初始脚本的一部分。Promise 1
接下来被记录,因为 promises 是 microtasks,并且 microtasks 在调用堆栈上的项目之后立即执行。Timeout 1
和Timeout 2
最后被记录,因为它们是 macrotasks,并且在 microtasks 之后被处理。