很多文章都在讨论事件循环 (Event Loop) 是什么,而几乎没有人讨论为什么 JavaScript 中会有事件循环。博主认为这是为什么很多人都不能很好理解事件循环的一个重要原因 —— 知其然不知其所以然。所以本文试图抛砖引玉,从一些更溯源的方式来与大家探讨 event loop,希望大家能从中有些收获。
本文从三个角度来研究 JavaScript 的事件循环:
JavaScript 是网景 (Netscape) 公司为其旗下的网景浏览器提供更复杂网页交互时所推出的一个动态脚本语言。其创作者 Eich 在 10 天内写出了 JavaScript 的第一个版本,通过 Eich 在 JavaScript 20 周年的演讲回顾中,我们可以发现 JavaScript 在最初设计的时候没有考虑所谓的事件循环。那么事件循环到底是怎么出现的?
首先让我们来看看引入 JavaScript 到网页端的经典用例:一个用户打开一个网页,填写完表单提交之后,等待 30s 的白屏之后发现表单中的某个地方填写错误了需要重新填写。在这个场景中,如果我们有 JavaScript 就可以在用户提交表单之前先在用户本地的浏览器端做一次校验,避免用户每次都通过网络找服务端来校验所浪费的时间。
分析一下这个场景,我们就可以发现,最早的 JavaScript 的执行就是用户通过浏览器的事件来触发的,例如用户填写完表单之后点击提交的时候,浏览器触发一个 DOM 的点击事件,而点击事件绑定了对应的 JavaScript 代码来执行校验的过程。在这个过程中,JavaScript 的代码都是被动被调用的。
仔细思考一下就会发现,JavaScript 所谓的事件和触发本质上都通过浏览器中转,更像是浏览器行为而不仅仅是 JavaScript 语言内的一个队列。顺着这个思路我们顺藤摸瓜,就会发现 EcmaScript 的标准定义中压根 就没有事件循环,反倒是 HTML 的标准中定义了事件循环(目前 HTML 有 whatwg 和 w3c 标准,这里讨论的是 wahtwg 的标准):
To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. Each agent has an associated event loop, which is unique to that agent.
根据标准中对事件循环的定义描述,我们可以发现事件循环本质上是 user agent (如浏览器端) 用于协调用户交互(鼠标、键盘)、脚本(如 JavaScript)、渲染(如 HTML DOM、CSS 样式)、网络等行为的一个机制。
了解到这个定义之后,我们就能够清楚的知道,与其说是 JavaScript 提供了事件循环,不如说是嵌入 JavaScript 的 user agent 需要通过事件循环来与多种事件源交互。
所以说事件循环本质是一个 user agent 上协调各类事件的机制,而这一节我们主要讨论一下浏览器中的这个机制与 JavaScript 的交互部分。
各种浏览器事件同时触发时,肯定有一个先来后到的排队问题。决定这些事件如何排队触发的机制,就是事件循环。这个排队行为以 JavaScript 开发者的角度来看,主要是分成两个队列:
值得注意的是,虽然为了好理解我们管这个叫队列 (Queue),但是本质上是有序集合 (Set),因为传统的队列都是先进先出(FIFO)的,而这里的队列则不然,排到最前面但是没有满足条件也是不会执行的(比如外部队列里只有一个 setTimeout 的定时任务,但是时间还没有到,没有满足条件也不会把他出列来执行)。
外部队列(Task Queue [1]),顾名思义就是 JavaScript 外部的事件的队列,这里我们可以先列举一下浏览器中这些外部事件源(Task Source),他们主要有:
可以观察到,这些外部的事件源可能很多,为了方便浏览器厂商优化,HTML 标准中明确指出一个事件循环由一个或多个外部队列,而每一个外部事件源都有一个对应的外部队列。不同事件源的队列可以有不同的优先级(例如在网络事件和用户交互之间,浏览器可以优先处理鼠标行为,从而让用户感觉更加流程)。
内部队列(Microtask Queue),即 JavaScript 语言内部的事件队列,在 HTML 标准中,并没有明确规定这个队列的事件源,通常认为有以下几种:
在标准定义中事件循环的步骤比较复杂,这里我们简单描述一下这个处理过程:
案例分析
根据上述的处理模型,我们可以来看以下例子:
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
console.log('script end');
输出结果:
script start
script end
promise1
promise2
setTimeout
对应的处理过程则是:
只要理解了外部队列与内部队列的概念,再看这类问题就会变得很简单,我们再简单扩展看看:
setTimeout(() => {
console.log('setTimeout1')
})
Promise.resolve().then(() => {
console.log('promise1')
})
setTimeout(() => {
console.log('setTimeout2')
})
Promise.resolve().then(() => {
console.log('promise2')
})
Promise.resolve().then(() => {
console.log('promise3')
})
console.log('script end');
结果输出
script end
promise1
promise2
promise3
setTimeout1
setTimeout2
可以发现加入内部队列的顺序和时间虽然后差异,但是轮到内部队列执行的时候,一定会先全部执行完内部队列才会继续往下走去执行外部队列的任务。
最后我们再看一个引入了 HTML 渲染的例子:
<html>
<body>
<pre id="main"></pre>
</body>
<script>
const main = document.querySelector('#main');
const callback = (i, fn) => () => {
console.log(i)
main.innerText += fn(i);
};
let i = 1;
while(i++ < 5000) {
setTimeout(callback(i, (i) => '\n' + i + '<'))
}
while(i++ < 10000) {
Promise.resolve().then(callback(i, (i) => i +','))
}
console.log(i)
main.innerText += '[end ' + i + ' ]\n'
</script>
</html>
通过这个例子,我们就可以发现,渲染过程很明显分成三个阶段:
有的同学看完上面的几个例子之后可能有个问题,为什么 JavaScript 代码执行到 script end 之后,是先执行内部队列然后再执行外部队列的任务?
这里不得不把上文总出现过的 HTML 事件循环标准 再拉出来一遍:
To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section...
看到这里,大家可能就反应过来了,scripts 执行也是一个事件,我们只要归类一下就会发现 JavaScript 的执行也是一个浏览器发起的外部事件。所以本质的执行顺序还是:
根据本文开头我们讨论的事件循环起源,很容易理解为什么浏览器与 Node.js 的事件循环会存在差异。如果说浏览端是将 JavaScript 集成到 HTML 的事件循环之中,那么 Node.js 则是将 JavaScript 集成到 libuv 的 I/O 循环之中。
简而言之,二者都是把 JavaScript 集成到他们各自的环境中,但是 HTML (浏览器端) 与 libuv (服务端) 面对的场景有很大的差异。首先能直观感受到的区别是:
至于内在的差异,有一个很重要的地方是 Node.js (libuv)在最初设计的时候是允许执行多次外部的事件再切换到内部队列的,而浏览器端一次事件循环只允许执行一次外部事件。这个经典的内在差异,可以通过以下例子来观察:
setTimeout(()=>{
console.log('timer1');
Promise.resolve().then(function() {
console.log('promise1');
});
});
setTimeout(()=>{
console.log('timer2');
Promise.resolve().then(function() {
console.log('promise2');
});
});
这个例子在浏览器端执行的结果是 timer1
-> promise1
-> timer2
-> promise2
,而在 Node.js 早期版本(11 之前)执行的结果却是 timer1
-> timer2
-> promise1
-> promise2
。
究其原因,主要是因为浏览器端有外部队列一次事件循环只能执行一个的限制,而在 Node.js 中则放开了这个限制,允许外部队列中所有任务都执行完再切换到内部队列。所以他们的情况对应为:
虽然 Node.js 的这个问题在 11 之后的版本里修复了,但是为了继续探究这个影响,我们引入一个新的外部事件 setImmediate。这个方法目前是 Node.js 独有的,浏览器端没有。
setImmediate 的引入是为了解决 setTimeout 的精度问题,由于 setTimeout 指定的延迟时间是毫秒(ms)但实际一次时间循环的时间可能是纳秒级的,所以在一次事件循环的多个外部队列中,找到某一个队列直接执行其中的 callback 可以得到比 setTimeout 更早执行的效果。我们继续以开始的场景构造一个例子,并在 Node.js 10.x 的版本上执行(存在一次事件循环执行多次外部事件):
setTimeout(()=>{
console.log('setTimeout1');
Promise.resolve().then(() => console.log('promise1'));
});
setTimeout(()=>{
console.log('setTimeout2');
Promise.resolve().then(() => console.log('promise2'));
});
setImmediate(() => {
console.log('setImmediate1');
Promise.resolve().then(() => console.log('promise3'));
});
setImmediate(() => {
console.log('setImmediate2');
Promise.resolve().then(() => console.log('promise4'));
});
输出结果:
setImmediate1
setImmediate2
promise3
promise4
setTimeout1
setTimeout2
promise1
promise2
根据这个执行结果 [3],我们可以推测出 Node.js 中的事件循环与浏览器类似,也是外部队列与内部队列的循环,而 setImmediate 在另外一个外部队列中。
接下来,我们再来看一下当 Node.js 在与浏览器端对齐了事件循环的事件之后,这个例子的执行结果为:
setImmediate1
promise3
setImmediate2
promise4
setTimeout1
promise1
setTimeout2
promise2
其中主要有两点需要关注,一是外部列队在每次事件循环只执行了一个,另一个是 Node.js 的固定了多个外部队列的优先级。setImmediate 的外部队列没有执行完的时候,是不会执行 timeout 的外部队列的。了解了这个点之后,Node.js 的事件循环就变得很简单了,我们可以看下 Node.js 官方文档中对于事件循环顺序的展示:
其中 check 阶段是用于执行 setImmediate 事件的。结合本文上面的推论我们可以知道,Node.js 官方这个所谓事件循环过程,其实只是完整的事件循环中 Node.js 的多个外部队列相互之间的优先级顺序。
我们可以在加入一个 poll 阶段的例子来看这个循环:
const fs = require('fs');
setImmediate(() => {
console.log('setImmediate');
});
fs.readdir(__dirname, () => {
console.log('fs.readdir');
});
setTimeout(()=>{
console.log('setTimeout');
});
Promise.resolve().then(() => {
console.log('promise');
});
输出结果(v12.x):
promise
setTimeout
fs.readdir
setImmediate
根据输出结果,我们可以知道梳理出来:
这个顺序符合 Node.js 对其外部队列的优先级定义:
timer(setTimeout)是第一阶段的原因在 libuv 的文档中有描述 —— 为了减少时间相关的系统调用(System Call)。setImmediate 出现在 check 阶段是蹭了 libuv 中 poll 阶段之后的检查过程(这个过程放在 poll 中也很奇怪,放在 poll 之后感觉比较合适)。
idle, prepare
对应的是 libuv 中的两个叫做 idle 和 prepare 的句柄。由于 I/O 的 poll 过程可能阻塞住事件循环,所以这两个句柄主要是用来触发 poll (阻塞)之前需要触发的回调:
由于 poll 可能 block 住事件循环,所以应当有一个外部队列专门用于执行 I/O 的 callback ,并且优先级在 poll 以及 prepare to poll 之前。
另外我们知道网络 IO 可能有非常多的请求同时进来,如果该阶段如果无限制的执行这些 callback,可能导致 Node.js 的进程卡死该阶段,其他外部队列的代码都没发执行了。所以当前外部队列在执行一定数量的 callback 之后会截断。由于截断的这个特性,这个专门执行 I/O callbacks 的外部队列也叫 pengding callbacks
:
至此 Node.js 多个外部队列的优先级已经演化到类似原版的程度。最后剩下的 socket close 为什么是在 check 和 timers 之间,这个具体的权衡留待大家一起探讨。
关于浏览器与 Node.js 的事件循环,如果你要问我那边更加简单,那么我肯定会说是 Node.js 的事件循环更加简单,因为它的多个外部队列是可枚举的并且优先级是固定的。但是浏览器端在对它的多个外部队列做优先级排列的时候,我们一没法枚举,二不清楚其优先级策略,甚至浏览器端的事件循环可能是基于多线程或者多进程的(HTML 的标准中并没有规定一定要使用单线程来实现事件循环)。
我们都知道浏览器端是直面用户的,这也意味着浏览器端会更加注重用户的体验(如可见性、可交互性),如果有一个优化效果是能够极大的减少 JavaScript 的执行时间,但要消耗更多 HTML 渲染的时间的话,通常来说我们都不会做这个优化。通过这个例子来观察,可以发现我们在浏览器并不是主要关注某件事整体所消耗的时间是否更少,而是用户是否能快的体验到交互(感受到 HTML 渲染)。而到了 Node.js 这个服务端 JavaScript 的场景下,这一点是明确不一样的。在服务端为了保持应用的流畅,早期甚至出现了一次事件循环执行多个外部事件的优化方式。
很多同学在理解事件循环时感到隔靴搔痒的一个重要原因,便是把事件循环与 JavaScript 的关系弄错了。JavaScript 的事件循环与其说是 JavaScript 的语言特性,更准确的理解应该是某个设备/端(如浏览器)的事件循环中与 JavaScript 交互的部分。
造成浏览器端与 Node.js 端事件循环的差异的一个很大的原因在于 。事件循环的设计初衷更多的是方便 JavaScript 与其嵌入环境的交互,所以事件循环如何运作,也更多的会受到 JavaScript 嵌入环境的影响,不同的设备、嵌入式环境甚至是不同的浏览器都会有各自的想法。
注 [1]: 关于 Task,常有人称它为 Marcotask (宏任务),但 HTML 标准中没有这种说法。
注 [2]: 定时器操作主要依赖 JavaScript 外部的 agent 实现。所以归类为外部事件。
注 [3]: 这里 setTimeout 在 setImmediate 后面执行的原因是因为 ms 精度的问题,想要手动 fix 这个精度可以插入一段 const now = Date.now(); wihle (Date.now() < now + 1) {}
即可看到 setTimeout 在 setImmediate 之前执行了。
参考文献:
https://html.spec.whatwg.org/multipage/webappapis.html#event-loops https://nodejs.org/zh-cn/docs/guides/event-loop-timers-and-nexttick/#what-is-the-event-loop https://zhuanlan.zhihu.com/p/34229323 https://juejin.im/post/5c337ae06fb9a049bc4cd218
本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/q81WVi7b-fNPysSiHUxeDA
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。