重温 JavaScript 之 Event Loop
Posted 葱须道长
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了重温 JavaScript 之 Event Loop相关的知识,希望对你有一定的参考价值。
最近有朋友来信说在面试的时候遇到与事件循环(Event Loop)有关的问题,主要就是 Promise
和 setTimeout
等一起出现的时候,它们的执行顺序是怎样的。
有朋友遇到的示例代码比较复杂、嵌套较深,当被问到原理时没能正确回答。
我看了一些网上的文章,似乎能解释示例代码的执行过程,但总觉得有些地方不太对劲,于是花了三天的时间看了一些文章和视频、重点阅读了 SPEC 规范中的 Event Loops 章节,希望能有一个更合理的结论,这篇文章是我根据这些资料所做的总结,本人水平有限,因此不会深入细节,总结也不一定正确。
想要深入了解的朋友,可以研究文末的参考资料。
认知错误
在正式进入事件循环前,我们需要先纠正以往认知中的一些错误。
宏任务与微任务
很多文章将 javascript 中的任务分为宏任务和微任务,但是在 SPEC 规范中并没有宏任务的概念,规范中提到的任务类型是 task
和 microtask
,即任务与微任务,而且微任务也只是相对于任务的一种口语化称谓,是指通过微任务排队算法创建的任务。
这是第一个认知错误。
宏任务队列
前文已经说过 SPEC 规范中并没有宏任务的概念,因此也就没有宏任务队列的说法,规范中的说法是 task queue
和 microtask 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 loop
、worker event loop
和 worklet event loop
,其中 worker event loop
是与 web worker
相关的,而 worklet event loop
我暂时也不知道是什么,感兴趣的朋友可以研究一下,本文主要讲解 window event loop
。
每个事件循环都有一个或多个任务队列,一个任务队列是一些任务的集合;每个事件循环都只有一个微任务队列。
要理解事件循环,首先需要知道什么是任务,什么是任务队列,什么是微任务,以及什么是微任务队列。
任务
SPEC 规范中对任务的定义是拥有下列内容的一个结构。
其中的 source
很重要,它标明了某个任务的来源,即任务源 task source
,用户代理(user agent)用它来区分不同类型的任务,进而选择将其加入哪个任务队列中,稍后将详细讲解。
steps
指明了该任务中的每一步执行什么。
任务封装了负责以下工作的算法:
可以看到,回调函数、异步获取资源等操作如何处理是任务算法已经预设好了的,并不是由“出入队”决定的。
任务源
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
。
至此,我们可以知道,一个事件循环中的队列“结构”可能如下图所示:
再次强调,任务队列不是队列,是有序集合,一定要牢记。
如果想了解这些任务具体是怎样进行排队的,可以查阅规范,本文将不展开。
接下来我们讲解事件循环的处理模型(processing model),也就是处理任务的流程。
处理模型
SPEC 规范中对处理流程讲述得非常详细,我们这里将其简化一下,如图所示:
需要注意的是,规范中对于以什么顺序来选择任务队列并没有明确规定,而是以实现定义(implementation-defined)的方式进行的,即由用户代理自行决定实现细节,这是产生浏览器差异的原因之一。
事件循环中每运行一个任务,就会将其从对应的任务队列中移除(remove
),每运行一个微任务,就会将其从微任务队列中出队(dequeue
)。
worker event loop
的处理流程会略有不同,详情可以查阅规范。
其中还有一个细节,那就是 IndexedDB 的事务是在微任务队列的最后清理的。
旋转事件循环
规范中还有一个重要的东西,叫 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
有很大篇幅的讲解,这里简述一下流程,即:
-
获取 scripts
-
创建 scripts
-
运行 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!
但这并不是此例要表达的重点。
如果按照前文所说的事件循环流程:
-
运行 console.log()
-
setTimeout
依次进入timer task queue
-
运行 new Promise()
中的console.log()
-
promise.then()
进入微任务队列 -
运行 timer task queue
中的第一个任务,打印I will execute after console.log!
-
运行微任务队列,打印 3
-
循环
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 规范,因为里面的内容是前后关联的。
另外还有一个重要的点,规范也是在不断变化的。
欢迎感兴趣的朋友对文中内容进行探讨、勘误,毕竟很多细节我也还是没有完全搞清楚。
最后,要感谢参考资料的作者们,不然,单纯地阅读规范我可能会精神崩溃。
参考资料:
-
Further Adventures of the Event Loop - Erin Zimmer - JSConf EU 2018
-
What the heck is the event loop anyway? | Philip Roberts | JSConf EU
-
Tasks, microtasks, queues and schedules
-
SPEC
首图由Free-Photos在Pixabay上发布
以上是关于重温 JavaScript 之 Event Loop的主要内容,如果未能解决你的问题,请参考以下文章