引言:

事件循环不是浏览器独有的,从字面上看,“循环”可以简单地认为就是重复,比如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主线程空闲后,会去查询队列中是否还有任务,开启新一轮的循环。

这个步骤我大概画了个图:
浏览器事件循环Event Loop-LMLPHP

现在我们照着最开始的面试题进行举例。

首先,这段代码是一整个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中异步的具体实现方式,它的实现需要来自宿主环境的支持,比如浏览器中的各种线程,运行时中的消息队列等等。

11-14 19:00