解析 event loops
事件循环定义
单线程的实现方式就是事件循环(event loop
)。
存在两种 event loops
(W3C),即一种在 browsing context
下的事件循环,一种是在 web workers
下的循环。本文讨论在 browsing context
下的事件循环。
依据标准中对进程模型的流程描述(来源)可得出,一个事件循环始终以一个宏任务(如有)开始,待 execution context stack
为空 时将执行 perform a microtask checkpoint
,即执行 microtask queue
中的 microtasks
。待 microtask queue
清空后,将进入渲染进程,此刻浏览器应该判断是否有必要进入 repaint
流程。经历渲染步骤之后,一个事件循环结束。
任务源
-
宏任务(macrotask):
-
script
-
该宏任务的目的在于,将整体代码段(或理解为模块)推入执行上下文栈(
execution context stack
)中。-
执行上下文栈初始会设置
script
为当前正在运行执行上下文
(running execution context
),这期间可能因执行而创建新的执行上下文,那么就会依据模块内的代码不断的设置 当前正在运行执行上下文(running execution context
),这样模块内的代码就会依次得以执行(此处主要是执行上下文 中Running execution context 的更替
的实际应用)。 -
比如设置一些事件监听程序,一些声明,执行一些初始任务。在执行完成该任务时,会建立词法作用域等一系列相关运行参数。
-
-
setTimeout,setInterval,setImmediate(服务端 API)
-
I/O
-
可拓展至 Web API(来源):
-
DOM 操作
-
网络任务
- Ajax 请求
-
history traversal
- history.back()
-
用户交互
-
其中包括常见 DOM2(
addEventListener
)和 DOM0(onHandle
)级事件监听回调函数。如click
事件回调函数等。 -
特别地,事件需要冒泡到
document
对象之后并且事件回调执行完成后,才算该宏任务执行完成。否则一直存在于执行上下文栈中,等待事件冒泡并事件回调完成(来源:Jake Archibald blog - level 1 boss fight)。
-
-
-
- UI rendering
-
-
微任务(microtask):
-
process.nextTick(Node.js)
-
Promise 原型方法(即
then
、catch
、finally
)中被调用的回调函数 -
MutationObserver(DOM Standard)
- 用于监听节点是否发生变化
-
Object.observe(已废弃)
-
-
特别注明:在
ECMAScript
中称microtask
为jobs
(来源,其中 EnqueueJob 即指添加一个microtask
)。
macrotask
和 microtask
中的每一项都称之为一个 任务源。
以上分类中,每一项执行时均占用当前正在运行执行上下文
(running execution context
)(线程)。如,可理解为浏览器渲染线程与 JS 执行共用一个线程。
依据标准拓展:
-
在
W3C
或WHATWG
中除非特别指明,否则task
即是指macrotask
。 -
根据
W3C
(来源)关于microtask
的描述,只有两种微任务类型:单独的回调函数微任务(solitary callback microtasks),复合微任务(compound microtasks)。那么即在W3C
规范中所有的单独的回调函数都是微任务类型。-
solitary callback:Promise 原型的原型方法,即
then
、catch
、finally
能够调用单独的回调函数的方法。 -
compound microtask:
-
MutationObserver(DOM Standard - 4.3.2 步骤 5)
-
process.nextTick(Only for Node.js)
-
all callbacks passed to process.nextTick() will be resolved before the event loop continues.
-
-
-
-
特别指明,
Web API
(event loops 章节在标准中是属于 Web API 大类)是属于宏任务类型,如Ajax
属于I/O
(来源:using a resource),但Ajax
调用的Promise
类型回调函数都是微任务类型。
任务队列 task queue
任务队列分为 宏任务队列
和 微任务队列
。一个事件循环中可能有一个或多个任务队列。因为在执行一个宏任务时,可能产生微任务调用,即产生新的微任务队列。
相同类型的任务源的任务被调用时进入相同的任务队列,反之进入不同的任务队列。
标准(W3C and WHATWG)中的队列模型
注:
-
依据标准描述,除非特别指明是
microtask queue
,那么我们一般常说的任务队列(task queue
)都是指宏任务队列
(macrotask queue
)。 -
每个事件循环都有一个
当前执行中的任务
(currently running task
),用于轮询队列中的任务(handle reentrancy
)。 -
每个事件循环都有一个
已执行 microtask 检查点标志
(performing a microtask checkpoint flag
)(初始值一定为 false)表示已经执行了microtask
检查点,用于阻止执行microtask checkpoint
算法的可重入调用。-
可重入调用(reentrant invocation)是指,算法在执行过程中意外中断时,在当前调用未完成的情况下被再次从头开始执行。一旦可重入执行完成,上一次被中断的调用将会恢复执行。
-
设置该检查点的原因是:
- 执行微任务时,可能会调用其他回调函数,当其他回调函数时,并在弹出执行上下文栈时,会断言当前执行上下文栈是否为空,若为空时,那么就会再一次执行
microtask checkpoint
(来源:perform a microtask checkpoint - step 2.3、clean up after running script),若没有设置检查点执行标志的话就会再次进入microtask queue
重复执行microtask
。
- 执行微任务时,可能会调用其他回调函数,当其他回调函数时,并在弹出执行上下文栈时,会断言当前执行上下文栈是否为空,若为空时,那么就会再一次执行
-
(来源)
-
在
browsing context
事件循环的情况下(与第 8 步并列),选择当前task queue
中最早加入的 task。如果没有任务被选中(即当前task queue
为空),那么直接跳转到第 6 步Microtasks
- 如
Ajax
请求返回数据时,若当前task queue
为空时,将直接跳转执行回调函数微任务。
- 如
-
设置当前事件循环的
当前执行中的任务
为第 1 步被选出的 task。 -
Run
:执行当前被选出的 task(即 task 进入最上层执行上下文栈execution context stack
)。 -
重置当前事件循环的
当前执行中的任务
为默认值 null。 -
从当前的
task queue
中移除在第 3 步执行过的任务。 -
Microtasks
:执行microtask
检查点。-
当
已执行 microtask 检查点标志
为 false 时:-
设置
已执行 microtask 检查点标志
为 true。 -
操作(handling) microtask 队列
:在当前microtask queue
为空时,跳转到步骤Done
之后。 -
选中
microtask queue
中最早加入的microtask
。 -
设置当前事件循环的
当前执行中的任务
值为上一步选中的microtask
。 -
Run
:执行选中的microtask
(进入最上层执行上下文栈(来源 1:HTML Standard EnqueueJob 7.6、来源 2:ECMAScript EnqueueJob 步骤 4))。 -
重置置当前事件循环的
当前执行中的任务
值为 null。 -
从
microtask queue
中移除第 5 步Run
被执行的microtask
,回到第 3 步操作(handling) microtask 队列
。- 重点:为在一个事件循环中,总是要清空当前事件循环中的微任务队列才会进行重渲染(
Vue.js
的 DOM 更新原理)。
- 重点:为在一个事件循环中,总是要清空当前事件循环中的微任务队列才会进行重渲染(
-
Done
:对于每一个responsible event loop
是当前事件循环的环境设置对象(environment setting object
),向它(环境设置对象)告知关于rejected
状态的Promise
对象的信息。-
个人理解为触发浏览器
uncaught
事件,并抛出unhandled promise rejections
错误(W3C)。 -
此步骤主要是向开发者告知存在未被捕获的
rejected
状态的Promise
。
-
-
执行并清空
Indexed Database
(用于本地存储数据的 API) 的修改请求。 -
重置
已执行 microtask 检查点标志
为 false。
-
-
当一个复合微任务(
compound microtask
)执行时,客户端必须去执行一系列的复合微任务的子任务
(subtask)-
设置 parent 为当前事件循环的
当前执行中的任务
。 -
设置
子任务
为一个由一系列给定步骤组成的新 microtask。 -
设置
当前执行中的任务
为子任务
。这种微任务的任务源是微任务类型的任务源。这是一个复合微任务的子任务
。 -
执行
子任务
(进入执行上下文栈)。 -
重置当前事件循环的
当前执行中的任务
为 parent。
-
-
-
更新 DOM 渲染。
- 一个宏任务 task 至此整体执行结束(包含调用,执行,重渲染),也是一个事件循环结束。
-
(与第 1 步并列)如果当前的事件循环是
web works
的事件循环,并且在当前事件循环中的task queue
为空,并且WorkerGlobalScope
对象的closing
为 true,那么将摧毁当前事件循环,并取消以上的事件循环步骤,并恢复执行一个web worker
的步骤。 -
回到第 1 步执行下一个事件循环。
示例
以一个示例讲解事件循环:
// script
// 1
console.log('I am from script beginning')
// 2
setTimeout(() => {
// 该匿名函数称为匿名函数a
console.log('I am from setTimeout')
}, 1000)
// 3
const ins = new Promise((resolve, reject) => {
console.log('I am from internal part')
resolve()
})
// 4
ins
.then(() => console.log('I am from 1st ins.then()'))
.then(() => console.log('I am from 2nd ins.then()'))
// 5
console.log('I am from script bottom')
以上整个代码段即是,macro-task
中的 script
任务源。
执行原理(依据 Chrome 66 的 V8 实现)如下:
-
整个代码段
script
进入执行上下文栈(亦称调用栈,call stack
(来源)),执行 1 处代码调用console.log
函数,该函数进入调用栈,之前script
执行上下文执行暂停(冻结),转交执行权给console.log
。console.log
成为当前执行中的执行上下文(running execution context
)。console.log
执行完成立即弹出调用栈,script
恢复执行。 -
setTimeout
是一个任务分发器,该函数本身会立即执行,延迟执行的是其中传入的参数(匿名函数 a)。script
暂停执行,内部建立一个 1 秒计时器。script
恢复执行接下来的代码。1 秒后,再将匿名函数 a 插入宏任务队列(根据宏任务队列是否有之前加入的宏任务,可能不会立即执行)。 -
声明恒定变量
ins
,并初始化为Promise
实例。特别地,Promise
内部代码会在本轮事件循环立即执行。那么此时,script
冻结,开始执行console.log
,console.log
弹出调用栈后,resolve()
进入调用栈,将Promise
状态resolved
,并之后弹出调用栈,此时恢复 script 执行。 -
因为第 3 步,已经在本轮宏任务完成前
resolved
,否则,将跳过第 4 步向本轮事件循环的微任务队列添加回调函数(来源)。调用ins
的then
方法,将第一个then
中回调添加到微任务队列
,继续执行,将第二个then
中回调添加到微任务队列
。 -
如同 1 时的执行原理。
-
script
宏任务执行完成,弹出执行上下文栈。此时,微任务队列中有两个then
加入的回调函数等待执行。另外,若距 2 超过 1 秒钟,那么宏任务队列中有一个匿名函数 a 等待执行,否则,此时宏任务队列为空。 -
在当前宏任务执行完成并弹出调用栈后,开始清空因宏任务执行而产生的微任务队列。首先执行
console.log('I am from 1st ins.then()')
,之后执行console.log('I am from 2nd ins.then()')
。 -
微任务队列清空后,开始调用下一宏任务(即进入下一个事件循环)或等待下一宏任务加入任务队列。此时,在 2 中计时 1 秒后,加入匿名函数 a 至宏任务队列,此时,因之前宏任务 script 执行完成而清空,那么将匿名函数 a 加入调用栈执行,输出
I am from setTimeout
。
注:JavaScript
中在某一函数内部调用另一函数时,会暂停(冻结)当前函数的执行,并将当前函数的执行权转移给新的被调用的函数(具体解析见拓展阅读)。
示例总结:
-
在一个代码段(或理解为一个模块)中,所有的代码都是基于一个
script
宏任务进行的。 -
在当前宏任务执行完成后,必须要清空因执行宏任务而产生的
微任务队列
。 -
只有当前微任务队列清空后,才会调用下一个宏任务队列中的任务。即进入下一个事件循环。
-
new Promise
时,Promise
参数中的匿名函数是立即执行的。被添加进微任务队列
的是then
中的回调函数。- 特别地,只有
Promise
中的状态为resolved
或rejected
后(Promise 标准),才会调用Promise
的原型方法(即 then、catch
(因为是then
的语法糖,所以与then
同理)、finally
(onfinally
时触发)),才会将回调函数到添加微任务队列中。
- 特别地,只有
-
setTimeout
是作为任务分发器的存在,他自身执行会创建一个计时器,只有待计时器结束后,才会将setTimeout
中的第一参数函数添加至宏任务队列
。换一种方式理解,setTimeout
中的函数一定不是在当前事件循环中被调用。
以下是在客户端(Node.js 可能有不同结果)的输入结果:
I am from script beginning
I am from internal part
I am from script bottom
I am from 1st ins.then()
I am from 2nd ins.then()
I am from setTimeout
事件循环拓展应用 —— 异步操作
-
定时任务:setTimeout,setInterval
-
请求数据:Ajax 请求,图片加载
-
事件绑定
一般地,在 JS 开发过程中,凡是可能造成代码阻塞的地方都可根据实际情况考虑使用异步操作。比如,数据获取等等。
参考
JavaScript 语言精粹(修订版)