最近有朋友来信说在面试的时候遇到与事件循环(Event Loop)有关的问题,主要就是 PromisesetTimeout 等一起出现的时候,它们的执行顺序是怎样的。

有朋友遇到的示例代码比较复杂、嵌套较深,当被问到原理时没能正确回答。

我看了一些网上的文章,似乎能解释示例代码的执行过程,但总觉得有些地方不太对劲,于是花了三天的时间看了一些文章和视频、重点阅读了 SPEC 规范中的 Event Loops 章节,希望能有一个更合理的结论,这篇文章是我根据这些资料所做的总结,本人水平有限,因此不会深入细节,总结也不一定正确。

想要深入了解的朋友,可以研究文末的参考资料。

认知错误

在正式进入事件循环前,我们需要先纠正以往认知中的一些错误。

宏任务与微任务

很多文章将 JavaScript 中的任务分为宏任务和微任务,但是在 SPEC 规范中并没有宏任务的概念,规范中提到的任务类型是 taskmicrotask,即任务微任务,而且微任务也只是相对于任务的一种口语化称谓,是指通过微任务排队算法创建的任务。

这是第一个认知错误。

宏任务队列

前文已经说过 SPEC 规范中并没有宏任务的概念,因此也就没有宏任务队列的说法,规范中的说法是 task queuemicrotask queue,即任务队列微任务队列

任务队列的数据结构并不是真的队列(Queues),而是有序集合(Sets),微任务队列才是真的队列

很多文章将 setTimeout 列为宏任务,它会将其回调函数在宏任务队列中进行入队操作,进而导致代码的延迟执行。其实这个说法我们简单验证一下就知道有问题,例如:

console.log(`I will execute immediately!`);

setTimeout(function () {
  console.log(`I will execute after 6 seconds!`)
}, 6000);

setTimeout(function () {
  console.log(`I will execute after 4 seconds!`)
}, 4000);

setTimeout(function () {
  console.log(`I will execute after console.log!`)
}, 0)

得到的结果如下:

I will execute immediately!
I will execute after console.log!
I will execute after 4 seconds!
I will execute after 6 seconds!

我们知道,对于队列来说,正常的执行顺序应当是先进先出,而从打印结果得到的是回调函数按序入队,却没有按序出队。

因此,要记住任务队列不是队列,是有序集合,这是第二个认知错误。

规范

SPEC 规范的内容非常多,本文只简要的讲解一些重点内容。

SPEC 规范中将事件循环分为三类,分别是 window event loopworker event loopworklet event loop,其中 worker event loop 是与 web worker 相关的,而 worklet event loop 我暂时也不知道是什么,感兴趣的朋友可以研究一下,本文主要讲解 window event loop

每个事件循环都有一个或多个任务队列,一个任务队列是一些任务的集合;每个事件循环都只有一个微任务队列

要理解事件循环,首先需要知道什么是任务,什么是任务队列,什么是微任务,以及什么是微任务队列。

任务

SPEC 规范中对任务的定义是拥有下列内容的一个结构

task

其中的 source 很重要,它标明了某个任务的来源,即任务源 task source,用户代理(user agent)用它来区分不同类型的任务,进而选择将其加入哪个任务队列中,稍后将详细讲解。

steps 指明了该任务中的每一步执行什么。

任务封装了负责以下工作的算法:

task_algorithms

可以看到,回调函数、异步获取资源等操作如何处理是任务算法已经预设好了的,并不是由“出入队”决定的。

任务源

SPEC 规范中的任务源有六种(目前我只发现了六种),如下表所示:

任务源描述
timer task source与定时器相关的任务,如 setTimeout()
DOM manipulation task source与 DOM 操作相关的任务,如以非阻塞方式将元素插入到文档中
user interaction task source与用户交互相关的任务,如 onclick()
networking task source与网络活动相关的任务,如 new XMLHttpRequest()
history traversal task source与浏览器历史相关的任务,如 history.back()
microtask task source微任务,如 Promise.then()

任务源是任务排队的依据。

任务队列

事件循环会根据任务源将不同类型的任务加入(append)到对应的任务队列中,之后从这些任务队列中选取任务进行处理。

既然规范中说的是每个事件循环中有一个或多个任务队列,说明各种任务队列一开始并不一定全部存在,应该是在需要时创建。

微任务与微任务队列

前文已经讲过微任务与微任务队列的概念,那么什么样的任务是微任务呢?根据 SPEC 规范与 ECMA 规范综合得出,MutationObserver()Promise.then()Promise.catch() 是微任务,它们将被加入(enqueue)微任务队列,在处理后从微任务队列出队(dequeue)。

微任务是有可能被移动到常规任务队列的,详情可以查阅规范。

小结

SPEC 规范中在 queue tasks 部分还提到了一个 element task,即元素任务,这类任务都是 DOM 元素上的,它与任务略有不同。

比如在 textarea 元素中选择文本时任务源是 user interaction task source;如果 iframe 元素没有指定 src 属性,而用户代理恰好是首次处理 iframe 元素的属性时,任务源是 DOM manipulation task source

至此,我们可以知道,一个事件循环中的队列“结构”可能如下图所示:

queues

再次强调,任务队列不是队列,是有序集合,一定要牢记。

如果想了解这些任务具体是怎样进行排队的,可以查阅规范,本文将不展开。

接下来我们讲解事件循环的处理模型(processing model),也就是处理任务的流程。

处理模型

SPEC 规范中对处理流程讲述得非常详细,我们这里将其简化一下,如图所示:

processing_model

需要注意的是,规范中对于以什么顺序来选择任务队列并没有明确规定,而是以实现定义(implementation-defined)的方式进行的,即由用户代理自行决定实现细节,这是产生浏览器差异的原因之一。

事件循环中每运行一个任务,就会将其从对应的任务队列中移除(remove),每运行一个微任务,就会将其从微任务队列中出队(dequeue)。

worker event loop 的处理流程会略有不同,详情可以查阅规范。

其中还有一个细节,那就是 IndexedDB 的事务是在微任务队列的最后清理的。

旋转事件循环

规范中还有一个重要的东西,叫 spin the event loop ,即旋转事件循环,原文是这样写的:

spin_the_event_loop

好吧,我并不是很明白到底在讲什么,只是感觉可能和算法是如何处理 setTimeout 等的回调函数的细节有关。

示例

理论讲得再多,还是觉得空洞,接下来我们结合示例来分析。

示例一

在文章开头的例子上稍微修改一下,特别留意 script 标签:

<script>
  console.log(`I will execute immediately!`);

  setTimeout(function () {
    console.log(`I will execute after 6 seconds!`)
  }, 6000);

  setTimeout(function () {
    console.log(`I will execute after 4 seconds!`)
  }, 4000);

  setTimeout(function () {
    console.log(`I will execute after console.log!`)
  }, 0)
</script>

前文说过事件循环是以选取任务队列开始的,而任务队列也是创建的,那么什么时候创建的任务队列呢?是在第一次运行 JavaScript 代码的时候。

规范中对于 script 有很大篇幅的讲解,这里简述一下流程,即:

  1. 获取 scripts
  2. 创建 scripts
  3. 运行 scripts

因此,此例中第一个 console.log 是在运行 scripts 时输出的,之后的 setTimeout 全部按序进入了 timer task queue(应该是在遇到第一个 setTimeout 时创建的任务队列),然后进行事件循环,依次输出。

输出结果如下:

I will execute immediately!
I will execute after console.log!
I will execute after 4 seconds!
I will execute after 6 seconds!

这个示例主要是为了让大家能够明白事件循环的开始时机。

示例二

让我们再对上面的例子做一些修改,加入微任务:

<script>
  console.log(`I will execute immediately!`);

  setTimeout(function () {
    console.log(`I will execute after 6 seconds!`)
  }, 6000);

  setTimeout(function () {
    console.log(`I will execute after 4 seconds!`)
  }, 4000);

  setTimeout(function () {
    console.log(`I will execute after console.log!`)
  }, 0);

  const promise = new Promise((resolve, reject) => {
    console.log(1);
    resolve('success');
    console.log(2);
  });

  promise.then(() => {
    console.log(3);
  });
</script>

根据以往经验,相信大家都能说出答案,如下:

I will execute immediately!
1
2
3
I will execute after console.log!
I will execute after 4 seconds!
I will execute after 6 seconds!

但这并不是此例要表达的重点。

如果按照前文所说的事件循环流程:

  1. 运行 console.log()
  2. setTimeout 依次进入 timer task queue
  3. 运行 new Promise() 中的 console.log()
  4. promise.then() 进入微任务队列
  5. 运行 timer task queue 中的第一个任务,打印 I will execute after console.log!
  6. 运行微任务队列,打印 3
  7. 循环

3 应该在 I will execute after console.log! 之后输出,因为它是微任务,应当在第一个任务运行后运行,而它却出现在了前面。

这是因为在运行 scripts 后会进行清理(clean up after running script),清理过程中的重要一步就是运行一次微任务队列,然后才进行事件循环,所以 3 会在 I will execute after console.log! 前输出。

示例三

让我们再对上面的例子做一些修改:

<script>
  console.log(`I will execute immediately!`);

  setTimeout(function () {
    console.log(`I will execute after 6 seconds!`)
  }, 6000);

  setTimeout(function () {
    console.log(`I will execute after 4 seconds!`)
  }, 4000);

  setTimeout(function () {
    console.log(`I will execute after console.log!`)
  }, 0);

  const promise = new Promise((resolve, reject) => {
    console.log(1);
    resolve('success');
    console.log(2);
  });

  promise.then(() => {
    console.log(3);
  });
</script>
<script>
  console.log(`I will execute immediately!`);

  setTimeout(function () {
    console.log(`I will execute after 6 seconds!`)
  }, 6000);

  setTimeout(function () {
    console.log(`I will execute after 4 seconds!`)
  }, 4000);

  setTimeout(function () {
    console.log(`I will execute after console.log!`)
  }, 0);

  const promise2 = new Promise((resolve, reject) => {
    console.log(1);
    resolve('success');
    console.log(2);
  });

  promise2.then(() => {
    console.log(3);
  });
</script>

有多个 script 标签的情况,会怎样执行呢?结果如下:

I will execute immediately!
1
2
3
I will execute immediately!
1
2
3
I will execute after console.log!
I will execute after console.log!
I will execute after 4 seconds!
I will execute after 4 seconds!
I will execute after 6 seconds!
I will execute after 6 seconds!

可以看到,JavaScript 会将所有 script 标签全部运行后才进行事件循环。

结语

事件循环是一个非常复杂的东西,本文的内容也只是粗浅地带大家过了一下,如果真想吃透事件循环,至少要完整阅读 SPEC 规范,因为里面的内容是前后关联的。

另外还有一个重要的点,规范也是在不断变化的。

欢迎感兴趣的朋友对文中内容进行探讨、勘误,毕竟很多细节我也还是没有完全搞清楚。

最后,要感谢参考资料的作者们,不然,单纯地阅读规范我可能会精神崩溃 😂。

参考资料:

  1. Further Adventures of the Event Loop - Erin Zimmer - JSConf EU 2018open in new window

  2. What the heck is the event loop anyway? | Philip Roberts | JSConf EUopen in new window

  3. Tasks, microtasks, queues and schedulesopen in new window

  4. SPECopen in new window

最近更新:
作者: MeFelixWang