写这篇文源起于一个不务正业、不写 JavaScript 的老友问我 JavaScript 的事件循环是怎么回事,他还不甘于一知半解。按说这方面的知识网上也是一大堆,但从他的检索结果来看,确实没有适合他口味的。特写此文,兼做事件循环知识点总结。

知识准备

我感觉网上的大多数相关资料要么太专业、细碎,让新手(甚至老手)抓不住重点,要么就是太粗陋甚至有严重错误。另外,一个很显著的问题就是缺少必要的相关知识介绍。我试试看能不能把事件循环这件事说清楚。

1)JavaScript 与宿主

document.getElementById 这种函数是宿主提供的,不属于 JS 语言本身,此时的宿主一般是浏览器。另一个常见的宿主是 Node 环境,比如它提供的 fs 模块下的函数,都不属于语言本身。

JS 解释器被宿主环境调用,宿主会“植入”自己特有的 API。这个知识点写 JS 的人都知道。需要特别指出的是 setTimeout/setInterval 函数,是绝大多数宿主会提供的函数,它不属于语言规范。这个函数会成为事件循环队列的生产者,后文展开讲。

2)JavaScript 与单线程

首先声明,此文不讨论 web worker 。常说 JS 是单线程的,要合理理解这里的“单线程”。它是指 JS 执行环境(执行模型)是单线程,并不是说 JS 解释器是单线程,更不是说宿主环境是单线程。以浏览器宿主为例——

我们发 Ajax 请求的时候,浏览器会使用其 HttpClient 模块进行网络请求,它是独立的线程。也就是说当你 send 一个请求时,实际的操作已经交接给了宿主的其它线程(实际的实现也可能是进程,后文不再特殊说明此类情况,这里为了避免杠精。后文针对对本文不重要的知识我也将讲得不那么严谨)。

UI 事件也类似,浏览器有独立的事件监听器接收处理来自操作系统的事件,比如用户点击了鼠标,浏览器中独立的线程监听到点击,并计算出点击的是 DOM 渲染后页面上的一个 <button>

3)JavaScript 执行过程

JS 的执行过程中会创建个栈来存放参数、作用域链、this 等,每个函数是在独立的栈帧中执行。函数的返回事实上是栈帧指针的变动。这里不展开讲了。你可以将一个个的回调函数当作整体理解,后文的图中我也只把它们画成一个个的方框,不探讨内部细节。我也不会刻意指出“执行上下文栈”、“调用栈”这些名词的含义。

事件循环

直接上图:

image.png

事件循环(Event Loop)的核心逻辑极其简单。首先要有一个事件队列(或者叫消息队列,总之是一个队列),里面放着一个个事件,事件就是我刚才说的小方框,可以理解为一个函数或者你知道 Execution Context。主观理解即可。主线程中会无限循环地去取出并调用这个队列里的一个个“事件”。这就是事件循环的所有内容!

代码层面可以理解成:

while (true) {
    // 每一轮循环为一个 tick
    if (hasNextEvent()) {
        callNextEvent()
    }
}

我知道这种程度的解释并不能满足你。现在结合实际的例子详细讲一下。

例子1: UI 事件

假设我们使用 Vue 书写了如下代码:

<button @click="myClickHandler">点我</button>

这段代码会通过框架调用宿主浏览器提供的 API——比如 element.onclick ——向宿主的 UI 事件监听线程中注册一个回调函数。当用户点击该按钮时,UI 事件监听线程接收到来自操作系统的原始鼠标点击事件,分析其点击的位置后发现对应到了 button 上,这时 button 上已经注册了一个回调函数 myClickHandler,故该次用户点击的 UI 事件,变成了一个 Event Loop 中的事件。事件监听线程成为了事件队列的生产者。

后续主线程不断地从事件队列中取出事件执行,直到取了 myClickHandler。所以我们会发现,如果在主线程阻塞的时候点击按钮,它不会立刻响应,但也不会丢失响应,而是过了一会再响应。这淡淡的延迟感,就来自事件队列的等待。如果事件队列中没有其它事件,myClickHandler 就会在被放入队列的瞬间同时被取出执行,便感受不到延迟。

例子2: Ajax 请求

假设你写了:

axios({ method: 'get', url: 'http://bit.ly/2mTM3nY'})
    .then(function (response) {
        console.log(response.data)
    });

类库 axios 会通过宿主浏览器提供的 Ajax API 唤起 Http 请求,网络线程负责处理它。当服务端成功返回数据时,网络线程会将事件(例子中 then 里的匿名函数)放入主线程的事件队列里。网络线程也成为了事件队列的生产者。

后续的执行过程和 UI 事件一样,主线程会逐渐执行到那个匿名函数,打印出返回的数据。

例子3: setTimeout 和 setInterval

这个很常用,我展开讲一下。把目光聚焦到图里的调用栈。为了便于理解,我省去取事件的过程,把事件队列中的事件直接画在调用栈中,表示事件会被不间断地拿到调用栈中执行。

假设调用栈中正在执行的 JavaScript 代码如下:

button.onclick = function myClickHandler() { ... }
// ...5ms后
setTimeout(function A() {}, 30)
// ...5ms后
setInterval(function B() {}, 35)
// ...

我们按图示时间点设置了一个 UI 事件和两个定时调度,同时我们假设上面的代码会执行 25ms:

image.png

现在假设第 8ms 时用户点击了 button(上图画不开了,我只好分开画了,显然该事件发生在 setTimeout 和 setInterval 中间),此时会将 myClickHandler 事件送入 Event Loop 系统等待执行。

image.png

随着时间的延续,25ms 后上述 JavaScript 代码执行完毕,myClickHandler 被取出执行。我们假设它会执行 15ms。

时间来到第 35ms ,由于在第 5ms 时执行了 setTimeout(function A() {}, 30),所以此时 A 被送入事件队列等待执行。然而此时正在执行 myClickHandler ,所以一直等到 40ms 时,A 才被实际执行。我们期望延迟 30ms 执行的回调被延迟了 35ms。

image.png

40ms 时 A 开始执行,它是一个耗时任务,足足执行了 50ms 之久。我们很容易知道,定时事件会在这期间发生:

image.png

注意第 80ms 时,由于第 45ms 的事件 B 仍未被执行,此时定时调度线程会主动丢弃本次事件。接下来的事情应该很容易推想得知了。第 45ms 被加入的事件 B 会在 90ms 时开始执行,80ms 时不会执行 B 事件。

image.png

假设 B 只需要 10ms 执行完毕,第 100ms 时 B 会第一次执行完毕。接下来事件队列会处于空置期,队列中没有需要执行的事件。到了第 115ms 时,事件 B 再次触发,并立即被取出执行。这样一来,我们在第 10ms 设置的定时器,期望它每 35ms 执行一次,它却在 90ms、115ms... 的时刻执行。

设想一下,如果你使用 setInterval 做定时,而回调函数的执行时间往往大于你设置的定时周期,会发生什么?是的,会丟帧,有一些时刻并未执行回调。因此,多数时候更推荐嵌套 setTimeout 来代替 setInterval 做定时。

最后絮叨一句,JavaScript 引擎本身并没有时间的概念,定时调度能力来源于宿主环境。 setTimeout 是宿主提供的设置定时的能力,调用该函数并不会把回调放入事件循环队列中,而只是将回调任务交给宿主的定时调度模块,宿主来决定何时将回调放入事件循环队列。

宏任务与微任务

看懂上面“例子3”之后这个问题变得简单。在 ES6 之前是没有所谓“宏任务”与“微任务”的,JS 从语言规范层面也没有约束事件循环的工作机制。大概是为了 ES6 Promise 的诞生,语言规范对事件循环的工作机制做了要求,并在事件队列之外引入“任务队列”的概念。

我们把事件循环过程中,每一次从事件队列取出一个事件并执行的过程称为一个 Tick 。任务队列就是加在 Tick 后面要处理的另一个循环:

while (true) {
    // 每一轮循环为一个 tick
    if (hasNextEvent()) {
        callNextEvent()
        while (hasNextJob()) {
            callNextJob()
        }
    }
}

有了“任务队列”后,前面说的“事件队列”中的事件就是宏任务,任务队列中的就是微任务。微任务的一个典型代表是 Promise 。

你可以去网上搜更严谨的概念,我这里想讲的是它们在语言设计层面的价值。事件循环中一个Tick 是用来完成一次完整的事情的,就像你去银行排队办业务,你的业务办完了柜台就完成了一次 Tick,也就是完成一个宏任务。但是你的业务可能需要填单子,填单子的过程中你有些信息 A 不明确,于是给别人打电话咨询,他说帮你查一查后回复你。你不需要一直等他回电话,这期间你完全可以继续填单子后面的内容 B,此时的你便创建了一个微任务——等电话回复后再填内容 A。很显然你希望等到电话、填完完整的单子后才轮到下一位办理人。微任务的意义便如此!

所以说,宏任务与微任务让事件循环机制更细致了,各个异步任务本身,又可以被拆分成更细粒度不同的异步任务,这些异步任务不会乱序穿插执行。

举个例子,在宏任务中使用 Promise.resolve 创建微任务:

image.png

这些调用栈会把任务队列中的任务全执行完(如果微任务继续创建微任务,也会加入任务队列),然后开始执行下一个宏任务。

代码层面,setTimeout、UI 事件监听等,都是宏任务,Promise 是微任务。其它的你去网上查阅吧。这里提出一个问题:

执行下面的代码后,你的点击事件还会触发吗?或者说浏览器会被下面的代码卡死吗?

function foo() {
  setTimeout(foo, 0);
}
foo();

如果换成这样呢?

function foo() {
  return Promise.resolve().then(foo);
}
foo();

Qiang
271 声望25 粉丝

Hello segmentfault