Event Loop事件循环机制
一、JS单线程执行却不堵塞的秘密
在开始之前,我们先看一张图:
此图为事件在浏览器中的角色,不用看太久,有个概念就好,等你读完这篇文章,就能看懂这张图了。
首先要声明,事件循环是与 js 的运行环境相关的机制,它与 js (引擎) 本身无关。常见的运行环境有浏览器、Node.js,每种运行环境可能都有自己实现事件循环的方式,而本文只探讨浏览器的时间循环。
为什么要进行事件循环?因为 js 是单线程执行的,一个函数运行过久就会卡住后面的函数执行,如果这些函数恰好是做与 UI rendering 相关的事,那么画面便会延迟更新,这对使用者来说是非常不友好的。
深入点说,js 内部有个堆栈是用来执行任务的,这个堆栈又称执行上下文,是用来追踪程序所执行的函数。每当程序调用一个函数,这个函数所产生的执行上下文便会被压入(push)栈中;当这个函数被执行完,便会被弹出(pop)。函数的执行顺序是“后进先出”的模式。
我们常用的AJAX,计时器 setTimeout,与动画密切相关的 requestAnimationFrame,还有提升网页性能的requestIdleCallback……他们统称为 Web APIs。
接下来我们就来研究是谁跳出回调并将回调塞进执行栈的?
事件循环的基本概念
同样,在进到事件循环的流程前,先来看看一张图:
事件循环的运作流程。
看得懂在干嘛吗?看不懂?没关系,继续看下去,一切都会豁然开朗。
为什么要进行事件循环?
为了协调事件,使用者互动,脚本,渲染和网络活动等,使用者代理必须使用本节所描述的事件循环,每个代理都有一个相关且唯一的事件循环。
接下来,根据规范不同代理对事件循环做了三种分类:window event loop、worker event loop、worklet event loop。第一个代理是我们最经常碰到的 Window 物件;第二个代理告诉我们 service worker 会有自己的事件循环;第三个代理我不知道是什么,worklet 似乎是一个实验性的 API。
一个事件循环会有一到多个任务队列(task queues)
每个任务(task)都来自特定的任务源(task source)。每个任务源都必须对应一个特定的任务队列。
这两段话出现了三个专有名词:任务、任务源、任务队列。
首先任务就是宏任务,他可以干很多事情,包括发布 Event物件、解析HTML、调用回调、获取资源、响应dom操作。
任务源也有很多,但大都被归类为以下四种:
- DOM操作
- 使用者互动:比如键盘输入或鼠标点击
- 网络活动
- 历史记录访问:比如 history.back() API
那任务队列又是干什么的呢?它是用来分类任务源的;换而言之,不同任务源可能会被塞进统一任务队列。但为什么要对任务源进一步分类?这个例子说明的很清楚:
例如使用者代理可以将任务队列分为鼠标和键盘事件(即使用者互动任务源)用的,和其他任务源用的。为此,使用者代理可以在事件循环的处理模型的初始步骤中,给使用者互动任务队列关联的任务队列四分之三的优先处理权,已让界面保持响应,但又不会卡死其他任务队列。
换句话说,说到任务的顺序时,并没有谁先触发就谁先执行的道理,这一切都要看你的浏览器如何操作。
最后再将一件事:
微任务队列(microtask queque)不是任务队列。
微任务是一种通俗的说法,指的是通过 queque a microtask 算法创建的任务。
微任务虽然不是一种任务,但它所形成的队列与任务队列不同,而这是因为它们在成为队列时所使用的的算法不同。
事件循环是怎么运作的?
只要事件循环存在,便必须一直运行以下步骤:
-
挑出一个任务队列,要怎么挑由使用者代理决定。如果没有任务队列,那就直接跳到微任务步骤。
-
将 oldestask 设为该任务队列中第一个任务,并移除。
-
将当前正在运行的任务设为 oldstTask。
-
运行 oldstTask
-
执行完后,将当前正在运行的任务设为 null。
-
开始处理微任务。
- 首先检查 performing a microtask checkpoint 这个 flag。(为什么要这么做?因为微任务检查并不只有在事件循环的这个环节才触发,其他触发时机比如回调;为了避免在微任务队列时重复施行微任务检查,因此需要一个 flag 来控制)
- 将 oldestMicrotask 设为对微任务队列出列的结果
- 将当前正在运行的任务设为 oldestMicrotask
- 运行 oldestMicrotask
- 将当前正在运行的任务设回 null
- 检查微任务队列是否为空,若不为空,便执行第一个微任务。重复这个过程,直到所有微任务都执行完毕。
-
将 hasARenderingOpportunity 设为 false
从字面上理解,hasARenderingOpportunity 便是“是否有渲染机会”,一开始是 false,它将跟更新画面(
Update the rendering)有关。
-
将 now 设为 current high resolution time。
这里的 now 将成为 requestAnimationFrame 回调的第一个参数。
-
更新画面
要怎么决定有无渲染机会?如果使用者代理当前能够将浏览器上下文内容呈现给使用者,那该浏览上下文就有渲染机会,这需要考虑硬件刷新率的限制和使用者代理出于性能考量的节流,也需要考虑内容的可呈现性,即使它在视窗之外。
简单点来说,不是每轮事件循环都会更新画面,其频率高低,全看浏览器怎么操作。
-
事件循环的更新画面结束后,会根据以下几个条件:
- 事件循环是 window event loop
- 在任务队列中没有任何任务
- 微任务队列为空
- hasARenderingOpportunity 为 false
若条件都满足,requestIdleCallback 的回调将在这一步执行。
这样一轮事件循环就跑完了。
从案例了解事件循环
setTimeout(function onTimeout() {
console.log('timeout');
}, 0);
Promise.resolve()
.then(function onFulfill1() {
console.log('promise1');
})
.then(function onFulfill2() {
console.log('promise2');
});
console.log('main');
logSomething();
function logSomething() {
console.log('something');
}
当程序执行,会发生什么事?
- 建立一个全局执行上下文(global execution context),压入执行栈。
- 遇到setTimeout ,有 Web API 接管计时器的处理,事件一到便把 onTimeout 放入(宏)任务队列
- 遇到 Promise.then() ,将 onFulfill1 放入微任务队列
- 在控制台(console)输出 main
- 调用 logSomething,将其函数执行上下文压入执行栈
- 打印 something
- logSomething 执行完毕,出栈
- 程序执行完毕,全局上下文弹出。
- 施行微任务检查:微任务不为空,出列。
- 调用 onFulfill1,将其函数的执行上下文 放进微任务队列。
- 微任务不为空,出列
- 调用 onFulfill2,将其函数的执行上下文压入执行栈
- 打印 promise2
- onFulfill2 执行完毕,弹出。
- 微任务队列为空,微任务检查完毕
- 执行栈为空,事件循环从任务队列取出最旧的任务
- 调用 onTimeout,将其函数执行上下文压入执行栈,
- 打印 timeout
- onTimeout执行完毕,弹出。
再来看一个 例子
var btn = document.getElementById('btn');
btn.addEventListener('click', function onClick() {
setTimeout(function onTimeout() {
console.log('timeout');
requestIdleCallback(function onIdle2() {
console.log('idle2');
});
}, 0);
Promise.resolve().then(function onFulfill1() {
console.log('promise1');
});
requestAnimationFrame(function onAf() {
console.log('raf');
Promise.resolve().then(function onFulfill2() {
console.log('promise2');
});
});
requestIdleCallback(function onIdle1() {
console.log('idle1');
});
});
当你点击按钮,控制台会打印什么?
答案不只一种,在不同浏览器或同一浏览器的不同时间,可能出现不同结果。若只看最常出现的结果,Chrome 89.0.4389.90 是:
promise
raf
promise2
timeout
idle1
idle2
Firefox 87.0 是:
promise1
timeout
raf
promise2
idle1
idle2
或是:
promise1
timeout
raf
promise2
idle1
idle2
但无论是哪种结果,都能被事件循环的处理模型解释;换而言之,差异并不来自规范失败,而是来自规范赋予浏览器的弹性。
- click 事件已由 Web API 接手处理,将其回调放入任务队列
- 调用 onClick,将其函数执行上下文压入执行栈
- 遇到 setTimeout,由 Web API 接管计时器的处理,时间一到便把 onTimeout 放入任务队列
- 遇到 requestAnimationFrame,将其回调 onAf 放入 map of animation frame callbacks
- 遇到 requestIdleCallback,将其回调 onIdle1 放入 list of idle request callbacks
- onClick 执行完毕,弹出
- 施行微任务检查,微任务队列不为空,出列
- 调用 onFulfill,将其函数式执行上下文压入执行栈
- 印出 promise1
- onFulfill 执行完毕,出栈
接下来,就要发生分歧了:究竟是先执行计时器的回调 onTimeout,还是 requestAnimationFrame 的回调 onAf?要看此时 onTimeout 有没有被放入任务队列。
Chrome 的情况是:
- 执行栈为空,由于没有合格的任务队列,且微任务队列为空,事件循环进到更新页面步骤。
- 运行 animation frame callbacks
- 调用 onAf,将其函数的执行上下文压入执行栈
- 打印 raf
- 遇到 Promise.then(),将 onFulfill2 放入为任务队列
- onAf 执行完毕,出栈
- 开始微任务检查,微任务队列不为空,出列
- 调用 onFulfill2,将其函数的执行上下文压入执行栈
- 打印 promise2
- onFulfill2 执行完毕,出栈
- 由于 hasARenderingOpportunity 为 true,或任务队列中有任务,跳过 start an idle period algorithm
- 执行栈为空,事件循环从任务队列取出最旧的任务
- 打印 timeout
- 遇到 requestIDCallback,将其回调 onIdle2 放入 list of idle request callbacks
- onTimeout 执行完毕,出栈
- start an idle period algorithm,调用 onIdle1,将其函数的执行上下文压入执行栈
- 打印 idle1
- onIdle1 执行完毕,出栈
- 调用 onIdle2,将其函数执行上下文压入执行栈
- 打印 idle2
- onIdle2 执行完毕,出栈
Firefox 的情况呢,我就不讲了,可自行推导。
我只讲 Firefox 的第二种结果——onIdle1 怎么会在 onTimeout 之后执行?一个合理的猜测是:在这轮事件循环,用户代理跳过了更新画面,而且在 Rendering opportunities 这步就把本来要被更新画面的 Document 移除了,这样 hasARenderingOpportunity 才不会被设为 true,requestIdleCallback 的回调也才有办法被执行。