引言:
事件循环不是浏览器独有的,从字面上看,“循环”可以简单地认为就是重复,比如for循环,就是重复地执行for循环体中的语句,所以事件循环,可以理解为重复地处理事件,那么下一个问题是,处理的是什么事件,事件的相关信息从哪里获取。
因为我没有用nodejs做过什么项目,所以这里我暂且只关注浏览器的事件循环,但我想就“事件循环”本身而言,原理应该是相同的,不过就具体的实现可能存在一些差异。
一道面试题
相信应该有部分小伙伴和我一样,在面试中曾遇到过类似于这种问打印结果的题目。
(async function main() {
console.log(1);
setTimeout(() => {
console.log(2);
}, 0);
setTimeout(() => {
console.log(3);
}, 100);
let p1 = new Promise((resolve, reject) => {
console.log(4);
resolve(5);
console.log(6);
});
p1.then((res) => {
console.log(res);
});
let result = await Promise.resolve(7);
console.log(result);
console.log(8);
})()
这种题目就是变相的在考察事件循环的知识。
我个人感觉事件循环这个点,也是随着Promise的出现,成为了一个常见的考点。
什么是事件循环
一提到事件循环,我想很多人会和我一样,立刻想到异步、宏任务、微任务什么的。
WIKI
先不着急,我们先看下Wiki上,对事件循环的通用性描述。
简而言之,事件循环是一种编程结构或设计模式,用于在程序中等待和派发事件或消息。
它的工作原理是,向内部或外部的“事件提供者”发出请求(通常会阻止请求,直到事件发生)这就回答了我们之前的问题:事件的信息从哪里来,是由“事件提供者”提供
,然后调用相关的事件处理程序(“派发事件”)关于如何处理事件
。
事件循环有时也被称为消息派发器、消息循环、消息泵或者运行循环。
事件循环几乎总是与消息发送者异步运行。
这里我觉得可以这么理解,“消息发送者”这边将事件的消息交给了“事件提供者”,而事件循环这边会向“事件提供者”发出请求获取事件,然后调用相关的事件处理程序;所以说,事件循环与消息发送者是异步运行。
事件循环必然是在“消息发送者”将事件的消息交出之后,才会去执行事件处理程序;也就是说,事件循环的操作是在当下之后,在”将来“才会发生的。
当事件循环构成程序的中心控制流结构时(通常如此),它可以被称为主循环或主事件循环。这个称谓是恰当的,因为这样的事件循环处于程序的最高控制层。
MDN
WIKI上提供的是通用性的描述。我们再看一下MDN,MDN上直接搜索事件循环,可以看到是位于JavaScript路径下,针对JavaScript事件循环的描述。
第一段很直白的描述:JavaScript的运行时模型,是基于事件循环的,负责执行代码、收集和处理事件以及执行队列中的子任务。
执行队列中的子任务
:这基本上等于说,JavaScript的运行时给JavaScript提供了事件循环的能力;可以说JavaScript运行时中事件循环的部分,提供了JavaScript异步的具体实现方式。给JavaScript提供了支持异步的能力。
那么JavaScript为什么要处理异步呢?这就不得不提JavaScript的单线程运行特性 ,线程是什么?是进行运算调度的最小单位,而JavaScript设计之初,是为了处理网页上的交互事件,如果JavaScript允许多线程,也就是允许多个触发的事件同时进行运算,这可能就会呈现出各种不一样的计算结果,在用户看来就会显得交互很混乱,为了减少不确定性,JavaScript干脆就选择了单线程运行,所有代码都在同一个线程中执行;另外,JavaScript中的交互事件很多,如果每个触发事件都单独开辟线程来处理,也是不小的开销吧。
但是呢,虽然JavaScript是单线程运行的,但也存在需要在将来完成的操作,也就是存在异步代码,比如定时器。如果在Java中,我们也许可以选择new一个线程,sleep多少秒,然后再执行,但是JavaScript中不能这样做,因为它没有多线程,而如果直接在主线程等待,必定会引发阻塞和卡顿。事件循环就是对这种情况的一种解决方案,为了协调浏览器中的各种事件,必须使用事件循环;而事件循环中的消息队列就由JavaScript运行时来管理。
运行时概念
相信不少前端同学都听过“运行时”这个词,那运行时到底是什么呢?我觉得可以这么简单理解,既然运行时的功能是负责执行代码、收集和处理事件以及执行队列中的子任务,那么运行时中必须定义一套规则,关于如何去处理这些事情。所以可以简单地把运行时认为是定义了一套执行规则的JavaScript执行环境。
关于运行时,可以看到MDN上有一个直观演示的图,其中包含了函数调用形成的执行栈、分配对象的堆,以及消息队列。
根据WIKI给出的描述,运行时模型中,与事件循环关系最密切的,是消息队列,也就是我们前面提到的“事件提供者”。现在我们来看这个队列。
我们来看翻译的内容:
JavaScript 运行时使用消息队列,这是一个待处理消息列表。每条消息都有一个相关函数被调用来处理该消息。
在事件循环中的某个时刻,运行时开始处理队列中的消息,从最旧的消息开始。(”队“这个数据结构我们知道,是先进先出的,所以先进队的消息会先被处理。)为此,会从队列中移除消息,并将消息作为输入参数调用相应的函数。一如既往,调用函数会创建一个新的堆栈框架供该函数使用。
函数的处理将一直持续到堆栈再次清空为止。然后,事件循环将处理队列中的下一条消息(如果有的话)。(也就是,消息队列中的消息是一条接一条处理的。这里的堆栈指的就是函数调用形成的执行栈和分配对象的堆)
那么队列中的消息是哪里来的呢? 从这段内容中我们可以知道,进队的消息已经在等待处理了;所以比如有个定时器setTimeout,定义了有段代码需要等待3秒才执行,那这段代码就不能直接就进队,为了保证动作3秒后才执行,会在3秒后才进队,也就是说,setTimeout的第二个参数代表的是将消息推入队列的延迟时间。
那么肯定需要有什么东西,来管理这段代码,将这段代码在给定的延时后,推入消息队列。既然js没法去开线程管理,所以也是浏览器在管理;Chrome就有一个定时器线程,专门用于处理定时器,在定时器计时结束后,通知事件触发线程将消息推入队列;同样的,在用户触发交互事件时,事件触发线程也会将已在代码中定义的消息推入队列,也就是在事件监听程序addEventListener中监听的操作;还有异步HTTP请求线程,来管理请求回调的消息入队。等等,浏览器的这些线程共同作用来实现事件循环这个机制。
在JS主线程空闲时,就会将这些消息队列中的消息出列,交由主线程来执行。
那么接下来就是事件循环的执行步骤的问题。
事件循环执行步骤
首先,关于微任务:我们来看HTML的文档。
每个事件循环都有一个微任务队列,这是一个初始为空的微任务队列。微任务是一种通俗的说法,指通过微任务队列算法创建的任务。
也就是说,每个事件循环都会维护一个自己的微任务队列。它和我们之前看的消息队列,不是同一个队列,消息队列指的是这个文档中的任务队列,也就是task queue。
总所周知,常见的产生宏任务的方式有script、setTimeout、setInterval、UI事件等等;常见的产生微任务的方式有Promise.prototype.then、MutationObserver等等。
假设我们在浏览器中加载了一个页面,现在我们来看事件循环的处理步骤:
- 初始状态:运行时的调用栈空。微任务队列空,消息队列里有且仅有一个script脚本(整体代码)
- 然后消息队列中的script脚本被推入调用栈,同步代码开始执行。
- 当碰到微任务时,比如Promise.then,就将微任务推入事件循环的微任务队列中;这里要注意一下,Promise执行器函数中的代码属于同步代码,会被顺序执行;
- 当碰到宏任务时,就将它们丢给相应的浏览器线程;
- 当本次代码中的同步代码都执行完毕后,就将微任务队列中的任务一一处理并出队;
- 这样就完成了一次循环;
- 本次的宏任务script脚本也被出队。
- 此时DOM修改完成,然后浏览器会执行渲染操作,更新界面。
- 如果宏任务在各自的线程中被处理完毕后,就会被推入消息队列。
- 再接着就是当JS主线程空闲后,会去查询队列中是否还有任务,开启新一轮的循环。
这个步骤我大概画了个图:
现在我们照着最开始的面试题进行举例。
首先,这段代码是一整个script脚本,其中的同步代码会首先被按顺序执行,
可以看到这个script脚本中有一个async异步函数,async函数中的同步代码会首先被执行,所以先会打印1,
然后碰到两个产生宏任务的setTimeout,丢给定时器线程,为了后面方便讲述,这里分别把它们叫做宏任务1和宏任务2;
然后执行promise执行器函数中的同步代码,打印4和6;
接着碰到Promise.then这个微任务,我们给它记为微任务1,将它推入微任务队列,
然后我们又碰到一个await,await之后的代码相当于是Promise.then中的代码,也就是会被推入微任务队列,我们给它记为微任务2;
到这里,本次循环中的同步代码都执行完毕了;
接着就是开始把微任务队列中的微任务取出执行,首先是执行微任务1,打印5;
接着执行微任务2,打印7和8;
本次事件循环就结束了。
等到计时结束,宏任务1会先被推入消息队列,在JS主线程空闲,去查询消息队列后,代码就会被执行,会打印2;
同理,宏任务2后面也会被执行,并打印3。
这样我们就完成了这道面试题的解答。
总结
总的来说,事件循环就是JS中异步的具体实现方式,它的实现需要来自宿主环境的支持,比如浏览器中的各种线程,运行时中的消息队列等等。