重温 JavaScript 之 Event Loop

Posted 葱须道长

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了重温 JavaScript 之 Event Loop相关的知识,希望对你有一定的参考价值。

最近有朋友来信说在面试的时候遇到与事件循环(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 规范中对任务的定义是拥有下列内容的一个结构。

重温 JavaScript 之 Event Loop
task

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

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

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

重温 JavaScript 之 Event Loop
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

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

重温 JavaScript 之 Event Loop
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 2018

  2. What the heck is the event loop anyway? | Philip Roberts | JSConf EU

  3. Tasks, microtasks, queues and schedules

  4. SPEC

首图由Free-Photos在Pixabay上发布

以上是关于重温 JavaScript 之 Event Loop的主要内容,如果未能解决你的问题,请参考以下文章

重温委托(delegate)和事件(event)

重温前端基础之-js排序算法

JavaScript之DOM-8 Event对象(事件概述事件处理event对象)

JavaScript四大家族之event家族

重温JavaScript(lesson6):函数

JavaScript之彻底学会Event Loop