拜托被问到事件循环别再瞎说了

172 阅读6分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第5天,点击查看活动详情 >>

JavaScript 中的事件循环是什么?

事件循环是许多开发人员困惑的根源,但它是 JavaScript 引擎的基础部分。

它允许 JavaScript 是单线程的,但能够以非阻塞方式执行。

要理解事件循环,我们首先需要解释一些关于 JavaScript 引擎的东西,例如调用堆栈、任务、微任务及其各自的队列。

让我们一一分解。

调用堆栈

调用栈是一种跟踪 JavaScript 代码执行的数据结构。顾名思义,它是一个堆栈,因此是内存中的 LIFO(后进先出)数据结构。

执行的每个函数都表示为调用堆栈中的一个框架,并放置在前一个函数的顶部。

举个例子:

function foo() {
  console.log('foo');
  bar();
}

function bar() {
  console.log('bar');
}
  1. 调用堆栈最初是空的。
  2. 该函数foo()被压入调用堆栈。
  3. 该函数foo()被执行并从调用堆栈中弹出。
  4. 该函数console.log('foo')被压入调用堆栈。
  5. 该函数console.log('foo')被执行并从调用堆栈中弹出。
  6. 该函数bar()被压入调用堆栈。
  7. 该函数bar()被执行并从调用堆栈中弹出。
  8. 该函数console.log('bar')被压入调用堆栈。
  9. 该函数console.log('bar')被执行并从调用堆栈中弹出。
  10. 调用堆栈现在是空的。

宏任务和宏任务队列

宏任务是计划好的的代码块。在执行时,它们对调用堆栈具有独占访问权,并且还可以将其他任务排入队列。

在 Tasks 之间,浏览器可以执行渲染更新。

宏任务存储在宏任务队列中,等待相关函数执行。

反过来,宏任务队列是一个 FIFO(先进先出)数据结构。

宏任务的示例包括与事件关联的事件侦听器的回调函数和setTimeout(), script标签, I/O接口

微任务和微任务队列

微任务与宏任务类似,是调度的的代码块,在执行时具有对调用堆栈的独占访问权限。

此外,它们存储在自己的 FIFO(先进先出)数据结构Microtask Queue中。

然而,微任务与宏任务不同,微任务队列必须在宏任务完成后和重新渲染之前清空。

微任务的示例包括 Promise 回调和 MutationObserver 回调。

事件循环

事件循环 的概念非常简单。它是一个在 JavaScript 引擎等待任务,执行任务和进入休眠状态等待更多任务这几个状态之间转换的无限循环。

引擎的一般算法:

  1. 当有任务时:
    • 从最先进入的任务开始执行。
  2. 休眠直到出现任务,然后转到第 1 步。

当我们浏览一个网页时就是上述这种形式。JavaScript 引擎大多数时候不执行任何操作,它仅在脚本/处理程序/事件激活时执行。

任务示例:

  • 当外部脚本
  • 当用户移动鼠标时,任务就是派生出 mousemove 事件和执行处理程序。
  • 当安排的(scheduled)setTimeout 时间到达时,任务就是执行其回调。
  • ……诸如此类。

设置任务 —— 引擎处理它们 —— 然后等待更多任务(即休眠,几乎不消耗 CPU 资源)。

一个任务到来时,引擎可能正处于繁忙状态,那么这个任务就会被排入队列。

多个任务组成了一个队列,即所谓的“宏任务队列”(v8 术语):

image.png

例如,当引擎正在忙于执行一段 script 时,用户可能会移动鼠标而产生 mousemove 事件,setTimeout 或许也刚好到期,以及其他任务,这些任务组成了一个队列,如上图所示。

队列中的任务基于“先进先出”的原则执行。当浏览器引擎执行完 script 后,它会处理 mousemove 事件,然后处理 setTimeout 处理程序,依此类推。

到目前为止,很简单,对吧?

两个细节:

  1. 引擎执行任务时永远不会进行渲染(render)。如果任务执行需要很长一段时间也没关系。仅在任务完成后才会绘制对 DOM 的更改。
  2. 如果一项任务执行花费的时间过长,浏览器将无法执行其他任务,例如处理用户事件。因此,在一定时间后,浏览器会抛出一个如“页面未响应”之类的警报,建议你终止这个任务。这种情况常发生在有大量复杂的计算或导致死循环的程序错误时。

宏任务和微任务

微任务仅来自于我们的代码。它们通常是由 promise 创建的:对 .then/catch/finally 处理程序的执行会成为微任务。微任务也被用于 await 的“幕后”,因为它是 promise 处理的另一种形式。

还有一个特殊的函数 queueMicrotask(func),它对 func 进行排队,以在微任务队列中执行。

每个宏任务之后,引擎会立即执行微任务队列中的所有任务,然后再执行其他的宏任务,或渲染,或进行其他任何操作。

例如:

setTimeout(() => alert("timeout"));

Promise.resolve()
  .then(() => alert("promise"));

alert("code");

这里的执行顺序是怎样的?

  1. code 首先显示,因为它是常规的同步调用。
  2. promise 第二个出现,因为 then 会通过微任务队列,并在当前代码之后执行。
  3. timeout 最后显示,因为它是一个宏任务。

更详细的事件循环图示如下(顺序是从上到下,即:首先是脚本,然后是微任务,渲染等):

image.png

微任务会在执行任何其他事件处理,或渲染,或执行任何其他宏任务之前完成。

这很重要,因为它确保了微任务之间的应用程序环境基本相同(没有鼠标坐标更改,没有新的网络数据等)。

如果我们想要异步执行(在当前代码之后)一个函数,但是要在更改被渲染或新事件被处理之前执行,那么我们可以使用 queueMicrotask 来对其进行安排(schedule)。

总结

更详细的事件循环算法(尽管与 规范 相比仍然是简化过的):

从 宏任务 队列(例如 “script”)中出队(dequeue)并执行最早的任务。 执行所有 微任务: 当微任务队列非空时: 出队(dequeue)并执行最早的微任务。 如果有变更,则将变更渲染出来。 如果宏任务队列为空,则休眠直到出现宏任务。 转到步骤 1。

安排(schedule)一个新的 宏任务:

  • 使用零延迟的 setTimeout(f) (其实并不是真正的0延迟)。

它可被用于将繁重的计算任务拆分成多个部分,以使浏览器能够对用户事件作出反应,并在任务的各部分之间显示任务进度。

此外,也被用于在事件处理程序中,将一个行为(action)安排(schedule)在事件被完全处理(冒泡完成)后。

安排一个新的 微任务:

  • 使用 queueMicrotask(f)
  • promise 处理程序也会通过微任务队列。

在微任务之间没有 UI 或网络事件的处理:它们一个立即接一个地执行。

所以,我们可以使用 queueMicrotask 来在保持环境状态一致的情况下,异步地执行一个函数。