JavaScript中的事件循环是什么?
事件循环对许多开发人员来说是一个令人困惑的问题,但它是JavaScript引擎的一个基本组成部分。它使JavaScript能够以非阻塞的方式在单线程中执行。要理解事件循环,我们首先需要解释一些关于JavaScript引擎的内容,例如调用栈、任务、微任务及其各自的队列。让我们逐个解释它们。
调用栈
调用栈是一种数据结构,用于跟踪JavaScript代码的执行。顾名思义,它是一个栈,因此是一种后进先出(LIFO)的内存数据结构。每个执行的函数在调用栈中表示为一个帧,并放置在前一个函数的顶部。
让我们逐步看一个简单的例子:
function foo() {
console.log('foo');
bar();
}
function bar() {
console.log('bar');
}
- 调用栈最初为空。
- 函数
foo()
被推入调用栈。 - 函数
foo()
被执行并从调用栈中弹出。 - 函数
console.log('foo')
被推入调用栈。 - 函数
console.log('foo')
被执行并从调用栈中弹出。 - 函数
bar()
被推入调用栈。 - 函数
bar()
被执行并从调用栈中弹出。 - 函数
console.log('bar')
被推入调用栈。 - 函数
console.log('bar')
被执行并从调用栈中弹出。 - 调用栈现在为空。
任务和任务队列
任务是计划的同步代码块。在执行过程中,它们独占调用栈,并且还可以将其他任务入队。在任务之间,浏览器可以执行渲染更新。任务存储在任务队列中,等待由其关联的函数执行。任务队列本身是一种先进先出(FIFO)的数据结构。任务的示例包括与事件相关联的事件侦听器的回调函数和setTimeout()
的回调函数。
微任务和微任务队列
微任务与任务类似,它们是被调度的同步代码块,在执行时独占访问调用栈。此外,它们存储在自己的FIFO(先进先出)数据结构中,即微任务队列。然而,微任务与任务的不同之处在于,在重新渲染之前,必须清空微任务队列。微任务的例子包括Promise
回调和MutationObserver
回调。
微任务和微任务队列也被称为作业和作业队列。
事件循环
最后,事件循环是一个持续运行的循环,检查调用栈是否为空。它通过将任务和微任务逐个放入调用栈中来处理它们,并控制渲染过程。它由四个关键步骤组成:
- 脚本评估:同步执行脚本,直到调用栈为空。
- 任务处理:选择任务队列中的第一个任务,并运行它,直到调用栈为空。
- 微任务处理:选择微任务队列中的第一个微任务,并运行它,直到调用栈为空,重复此过程直到微任务队列为空。
- 渲染:重新渲染UI,并返回到步骤2。
一个实际的例子
为了更好地理解事件循环,让我们看一个实际的例子,结合上述所有概念:
console.log('脚本开始');
setTimeout(() => console.log('setTimeout()'), 0);
Promise.resolve()
.then(() => console.log('Promise.then() #1'))
.then(() => console.log('Promise.then() #2'));
console.log('脚本结束');
// 输出:
// 脚本开始
// 脚本结束
// Promise.then() #1
// Promise.then() #2
// setTimeout()
输出结果是否符合您的预期?让我们逐步分解一下发生的情况:
- 调用栈最初为空。事件循环开始评估脚本。
console.log()
被推入调用栈并执行,打印'脚本开始'
。setTimeout()
被推入调用栈并执行。这在任务队列中创建了一个新的任务,该任务的回调函数在任务队列中排队。Promise.prototype.resolve()
被推入调用栈并执行,依次调用Promise.prototype.then()
。Promise.prototype.then()
被推入调用栈并执行。这在微任务队列中为其回调函数创建了一个新的微任务。console.log()
被推入调用栈并执行,打印'脚本结束'
。- 事件循环完成了当前任务的评估。然后开始运行微任务队列中的第一个微任务,即在步骤5中排队的
Promise.prototype.then()
的回调函数。 console.log()
被推入调用栈并执行,打印'Promise.then() #1'
。Promise.prototype.then()
被推入调用栈并执行。这在微任务队列中为其回调函数创建了一个新的条目。- 事件循环检查微任务队列。由于不为空,它执行第一个微任务,即在步骤9中排队的
Promise.prototype.then()
的回调函数。 console.log()
被推入调用栈并执行,打印'Promise.then() #2'
。- 如果有的话,此处会进行重新渲染。
- 微任务队列为空,所以事件循环转到任务队列并执行第一个任务,即在步骤3中排队的
setTimeout()
的回调函数。 console.log()
被推入调用栈并执行,打印'setTimeout()'
。- 如果有的话,此处会进行重新渲染。
- 调用栈现在为空。
总结
- 事件循环 负责执行 JavaScript 代码。它首先评估和执行脚本,然后处理 任务 和 微任务。
- 任务 和 微任务 是被调度的同步代码块。它们逐个执行,并分别放置在 任务队列 和 微任务队列 中。
- 对于所有这些,调用栈 用于跟踪函数调用。
- 每当执行 微任务 时,必须先清空 微任务队列,然后才能执行下一个 任务。
- 渲染 发生在 任务 之间,但不会发生在 微任务 之间。
注意事项
- 事件循环的脚本评估步骤本身类似于一个任务。
setTimeout()
的第二个参数表示执行的最小时间,而不是保证的时间。这是因为任务按顺序执行,并且微任务可能在其中执行。- Node.js 中事件循环的行为类似,但有一些差异。最显著的是,没有渲染步骤。
- 较旧的浏览器版本不完全遵守操作顺序,因此任务和微任务可能以不同的顺序执行。