美文网首页
JS 事件循环机制

JS 事件循环机制

作者: elle0903 | 来源:发表于2023-02-20 11:46 被阅读0次

前言

我们知道 JS 是单线程的,这也意味着,同一个时间,它只能做一件事,那么,它是怎么做到在执行代码的同时,去监听一些事件的呢?

想要回答这个问题,我们就得去深入了解 JS 的事件循环机制。

现阶段来说,JS 的主要运行环境包含两个:

  1. 浏览器
  2. Node.js

两者的实现从结果上来说,可以说殊途同归,深入到内部却大相径庭。

本文的内容也将包含这两个部分:

  1. 浏览器内部的事件循环机制。
  2. Node.js内部的事件循环机制。

废话不多说,直接开始吧。

浏览器的事件循环机制

进入正题之前,我们需要先了解一些基本概念。

首先,JS 虽然是单线程的,运行它的浏览器却不是,浏览器是多进程的框架,每一个标签页都包含一个独立的渲染进程,那里面又包含若干独立线程。

多进程架构的浏览器

JS 引擎线程负责 JS 代码的解释和运行,除此之外,那里面还包含一个定时器触发线程、一个事件触发线程、一个异步 HTTP 请求线程

有了这个基本概念之后,我们就可以详细来看看 JS 的事件循环机制了。

在浏览器里,JS 的事件循环机制是怎样的?

我们知道 JS 引擎在执行一段代码时,会首先将这些代码按顺序排放好,再依次执行这些内容。同步的代码,它当然可以直接执行,但如果遇到异步的,它就会触发一些操作。

  • 遇到计时器(setTimeout、setInterval 等),它会找到定时器触发线程
  • 遇到事件监听(鼠标点击、键盘操作等),它会找到事件触发线程
  • 遇到异步 HTTP 请求,它会找到异步 HTTP 请求线程

把事情交给这些线程去处理,JS 引擎继续往下执行。

接下来,在 JS 引擎忙着执行余下的代码时,定时器触发线程开始了倒计时,事件触发线程开始了事件监听,异步 HTTP 请求线程发起了 HTTP 请求。

当倒计时结束、事件被触发、HTTP请求有了回应,这些线程会再次跟 JS 引擎线程通信,它们会告诉它:好消息,异步操作执行结束了,可以开始执行回调函数了!

但这时候 JS 引擎可能正忙,前面我们又说了,JS 是单线程的,同一个时刻,只能做一件事,那么,该设计一个怎么样的机制,才能让 JS 引擎能够及时收到这些消息,并以最快的速度去执行这些回调呢?

答案是任务队列

JS 引擎会维护一个任务队列,当它闲置下来时,便会去查询这个任务队列,发现那里面不为空,它便按照次序,去执行那里面的回调。

于是那些异步任务处理线程需要做的事,也就是在异步操作执行结束之后,把需要执行的回调函数,推到这个队列里面去。

这样一来,事情就很清晰了。
我们来给浏览器里的 JS 事件循环机制下一个定义吧。

浏览器里的 JS 事件循环机制指的是:JS 引擎会维护一个任务队列,当异步操作发生时,它会将该异步操作分配给不同的线程,等异步操作执行结束,这些线程再将各自的回调加入任务队列中,等待 JS 引擎闲置时去执行。

浏览器的 JS 引擎循环执行任务队列内容的操作,就被称为浏览器的 JS 事件循环机制

来个示意图。

浏览器里的 JS 事件循环机制

有了这个概念之后,我们就可以进一步深入了,——是的,还没完,我们还可以进一步深入!

下面来介绍两个新概念:宏任务和微任务。

不要被名字吓到,你其实跟它们很熟悉。

宏任务(macro task)和微任务(micro task)

前面我们提到一个任务队列,它由 JS 引擎创建,事件循环也是针对它发生。
在上面,我们为了方便理解,假设只有一个这样的任务队列,JS 引擎有空时,便去按顺序执行那里面的内容。

但其实这样的任务队列不止一个。

这样的任务队列有两个:宏任务队列微任务队列

这两个队列的作用很好理解,宏任务队列就是用来保存宏任务的,微任务队列就是用来保存微任务,一眼就能看明白,那么问题来了,宏任务是什么?微任务又是什么?

宏任务是什么?微任务又是什么?

简单来说,这就是一个分类。

我们知道,JS 代码在执行过程中会产生很多异步任务,前面提到的计时器是其中一种,事件监听是另一种,除此之外,还有 HTTP 请求、Promise 和 MutaionObserver 等。

出于某种原因,JS 引擎对它们进行了分类,将一部分称为宏任务,剩下的称为微任务

常见宏任务如下:

  • 计时器:setTimeout、setInterval、setImmediate
  • 事件监听:鼠标点击和键盘事件等
  • 异步 HTTP 请求

常见微任务如下:

  • promise.then、promise.catch
  • MutaionObserver
  • process.nextTick

浏览器的 JS 引擎,对宏任务队列和微任务队列的处理方式也是不同的。

简单来说,微任务队列的优先级高于宏任务队列

微任务队列不为空的情况下,JS 引擎优先执行微任务队列的内容,等微任务队列空了,它才会去执行宏任务队列的内容。

考虑一下下面一段代码。

console.log('同步代码1');

setTimeout(() => console.log('宏任务:setTimeout'), 0);

new Promise((resolve) => {
    console.log('同步代码2');
    resolve();
})
.then(() => console.log('微任务:promise.then'));

console.log('同步代码3');

这段代码的输出结果是:

  1. 同步代码1
  2. 同步代码2
  3. 同步代码3
  4. 微任务:promise.then
  5. 宏任务:setTimeout

虽然 setTimeout 先于 promise.then 被添加进任务队列,但因为微任务队列的优先于高于宏任务队列的缘故,promise.then 被先一步执行。

Node.js 的事件循环机制

首先,我们知道,事件循环机制之所以出现,最重要一个原因,就是 JS 是单线程的。

它不像其他多线程语言一样,可以在需要的时候,随时开一个线程,去单独处理任务,再在结束之后,将线程销毁。它避开了新建、销毁线程,以及线程切换的资源消耗,同时也带来了新的问题。

如何处理异步操作?

为了解决这个问题,有了 JS 的事件循环机制。

一言以蔽之,JS 事件循环机制的诞生,就是为了解决 JS 执行过程中所遇到的异步操作问题。

在浏览器端,我们通常可能遇见的异步操作有:

  • 计时器
  • 事件监听
  • 异步请求
  • 其他种种

到了 Node.js 方面,跟用户有关的鼠标和键盘操作的用户事件监听虽然没了,却多了很多服务端特有的异步任务。

  • 文件读写相关的 I/O 任务
  • 线程相关的 process.nextTick
  • 服务相关的 server.close,socket.on('close', ...)

所以虽然在 Node.js 端,事件循环机制仍然涉及到主线程、任务队列、宏任务、微任务的概念,微任务的优先级,也仍旧高于宏任务,其他方面却还存在一些不同。

首先,它重新给宏任务分了类。
其次,它添加了任务阶段的概念。

下面我们逐一来了解。

宏任务的新分类

  • 计时器 Timers:用于存储和执行 setTimeoutsetInterval 等的回调函数。
  • 系统任务回调 Pending Callbacks:用于存储和执行系统任务执行结束之后的回调,比如端口监听、比如 TCP 错误。
  • I/O 回调 I/O Polls:用于存储和执行 I/O 执行结束之后的回调,比如读写文件、比如读写数据库。
  • 检查 Check:用于存储和执行 setImmediate 的回调。
  • 关闭回调 Close Callbacks:用于存储和执行关闭相关的回调,比如 socket.on('close', ...)

任务阶段这个新概念

在浏览器里,JS 引擎单独维护一个宏任务队列,队列内的宏任务按照入队时间,依次被执行。

但在 Node.js 内,事情变得有一点点不相同。

它会按照宏任务的类型,维护不同的宏任务队列内。

计时器有一个单独的计时器队列;系统任务回调有一个单独的系统任务回调队列;I/O 回调也有单独的I/O 回调队列。

当事件循环函数开始执行,它会依次去读取这些队列,再依次执行这些队列内的回调函数,等一个队列被清空,它进入下一个。

相当于在浏览器里,宏任务们不分组,只要是宏任务,大家都待在一起,执行时按照入队顺序。到了 Node.js 里,不同的宏任务被分了组,只有前一组执行结束,后一组才有执行的机会。

举一个具体的例子吧。

计时器队列内有 3 个待执行回调,系统任务队列内有 5 个待执行回调,I/O 队列内有 2 个,那么等事件循环函数开始执行时,它不会把这些任务混杂在一起,先后去执行,它会先去执行计时器队列内的回调,等计时器队列的三个回调执行结束,它再去执行系统任务回调,之后来到 I/O。

Node.js 的事件循环机制

Node.js 的跨平台能力是基于 Libuv 库实现的,JS 引擎并不会真正去操作数据库或者读写文件,它所做的事情,只是让 JS 去调用 Node.js 的 API,而 Node.js 的 API 真正调用的,又都是底层的C++代码。

Livub 库给不同类型的宏任务进行了分组,这些分组,就是 Node.js 事件循环的不同任务阶段。

它们的执行顺序如下图所示:

Node.js 事件循环的不同任务阶段

循环开始时,循环函数会先去读取计时器回调的内容,等那里的回调队列被清空,它继续去读取系统任务的回调队列,之后来到 I/O 回调阶段。

I/O 回调阶段跟其他几个阶段有些不同。

这个阶段的回调队列被清空后,循环函数不会立刻往下,它会先进行一些检查。

  • 如果它的下一个阶段,也就是 check 阶段的回调队列(setImmediate 的回调)不为空,那么它继续往下走去。
  • 如果 check 阶段的回调队列为空,但 timers 阶段的回调队列不为空,那么循环函数将从头开始,——从 timers 阶段开始,重新执行。
  • 如果这两者都为空,那么它将暂停执行,等待新的回调进入。

有了这个了解之后,我们再来看看 Node.js 微任务。

浏览器端区分了宏任务和微任务,Node.js 端同样如此,这儿的微任务定义也跟浏览器端没有差别。对于 Node.js 来说,常见的微任务同样是那些,但在执行的时机上,两者还是存在一些细微的差别。

Node.js 11 之前,Libuv 会在执行完一个任务阶段的所有回调之后,再去检查和执行微任务队列的内容,等微任务队列被清空,它进入下一个任务阶段

也就是说,下面这段代码的输出,可能不尽如人愿。

setTimeout(() => {
    console.log('宏任务 timers 阶段:timeout1')
    Promise.resolve().then(function () {
        console.log('微任务:promise1');
    });
}, 0);
setTimeout(() => {
    console.log('宏任务 timers 阶段:timeout2')
    Promise.resolve().then(function () {
        console.log('微任务:promise2');
    });
}, 0);

在浏览器里,这段同步代码执行结束后,定时器触发线程会多出两条计时任务。

由于计时任务属于宏任务,所以时间到了之后,这两条计时任务的回调函数,会被先后添加进 JS 引擎的宏任务队列。

第一个回调函数先被执行,控制台输出宏任务 timers 阶段:timeout1,Promise.resolve 属于同步代码,直接执行,其后的 Promise.then 向 JS 引擎的微任务队列,添加一条微任务。

第一个回调函数执行结束后,回调函数本身被从宏任务队列当中移除,之后,由于微任务的优先级高于宏任务,所以浏览器会暂停遍历宏任务队列,先去检查微任务队列的内容,发现不为空,于是第二条微任务:promise1被输出。

微任务队列被清空后,JS 引擎会继续往下,检查宏任务队列的内容,于是第三条信息宏任务 timers 阶段:timeout2 被输出,之后再去检查微任务队列,第四条信息微任务:promise2 被输出。

于是在浏览器,这段代码的输出依次是:

  1. 宏任务 timers 阶段:timeout1
  2. 微任务:promise1
  3. 宏任务 timers 阶段:timeout2
  4. 微任务:promise2

但到了 Node.js 11 之前的环境里,情况变得不太相同,在这里,这段代码的输出内容是:

  1. 宏任务 timers 阶段:timeout1
  2. 宏任务 timers 阶段:timeout2
  3. 微任务:promise1
  4. 微任务:promise2

这是因为,在 Node.js 11 之前的环境里,事件循环会先将一个阶段的所有任务先清空,再去检查微任务队列的内容,在这里,代码的执行次序如下:

  1. 同步代码执行结束后,timers 阶段多出两条计时任务。
  2. 时间到了,两条计时任务的回调函数,先后被添加进 timers 阶段的待执行回调队列中。
  3. 事件循环来到 timers 阶段,发现任务队列不为空,于是,先执行第一条计时任务的回调函数:输出 timeout1,将输出 promise1 添加进微任务队列;再执行第二个计时任务的回调函数:输出 timeout2,将输出 promise2 添加进微任务队列。
  4. timers 阶段的回调队列被清空,往下个阶段进发之前,先去检查微任务队列,发现微任务队列不为空,按照顺序依次执行,promise1promise2 也相继被输出。

于是输出的内容就成了上面描述的那样。这个偏差看起来不大,却会给编写程序的程序员带来不小影响,于是 Node.js 11 之后,事件循环的机制被调整,Node.js 端的输出内容,也开始跟浏览器端一致,也就是说,每个阶段的每个回调执行结束后,在继续执行下一个回调之前,事件循环相关函数会先去检查和执行微任务队列的内容,之后再回来。

好嘞,以上就是本文的全部内容啦,感谢看到这里的大小可爱们,有机会再见!

参考链接

相关文章

  • js事件循环

    js 是单线程的, js 的异步事件就是依赖于事件循环机制 事件循环 首先,我们来解释下事件循环是个什么东西: 就...

  • 2019-03-28

    JS 事件循环机制 - 任务队列、web API、JS主线程的相互协同 这篇文章的核心是分析js的事件循环,在此我...

  • Vue源码分析—响应式原理(四)

    nextTick JS 运行机制 JS 执行是单线程的,它是基于事件循环的。事件循环大致分为以下几个步骤: 所有同...

  • node 事件

    1、事件 1.1普通事件的使用 1.2、Node.js 的事件循环机制解析 1)Node 由事件循环开始,到事件循...

  • js 事件循环机制

    事件循环机制: 以任务队列形式,同步任务,异步任务 同步任务:在主线程上排队的任务,前一个执行完,执行后一个 异步...

  • js事件循环机制

    程序开始执行之后,主程序开始执行同步任务,碰到异步任务就把它放到任务队列中,等到同步任务全部执行完之后,js引擎就...

  • js 事件循环机制

    日期:2020 年 7 月 10 日 js 事件循环机制 前言 众所周知,JavaScript 是一门 单线程 的...

  • JS事件循环机制

    1、事件循环 图中heap表示堆和stack表示执行栈,web apis表示一些异步事件,callback que...

  • js事件循环机制

    基本知识 js是单线程的,只有一个主线程处理逻辑 事件循环的作用 保证主线程, 有序并高效 或非阻塞 的处理 在j...

  • js事件循环机制

    1. 什么是事件循环? js引擎并不是独立运行的,它运行在宿主环境中,对多数开发者来说通常就是web浏览器。经过最...

网友评论

      本文标题:JS 事件循环机制

      本文链接:https://www.haomeiwen.com/subject/sinykdtx.html