本文主要介绍了 JS 中的事件循环概念,详细讲解了 Browser 中的事件循环以及 Node.js 中的事件循环。

查看参考资料
参考方向教程原帖
面试必问之 JS 事件循环(Event Loop),看这一篇足够面试必问之 JS 事件循环(Event Loop)
Node.js 事件循环Event Loop
通俗移动的 Node.js 事件循环node.js 事件循环

前言

JS 是一门单线程的语言,执行过程中每一步代码都有严格的先后顺序,但 JS 也能够实现异步编程,JS 本身并不具备异步编程的能力,其异步编程是依赖其宿主实现的,而实现异步编程的核心机制就是事件循环

浏览器中 JS 异步执行的原理

例如发送ajax请求以及设定setTimeout定时器,实现这种异步,主要依赖于浏览器的定时触发线程以及 HTTP 请求线程,即浏览器才是执行发送请求以及定时功能的角色,JS 引擎只负责执行这些事件回调的代码。

浏览器的进程与线程

以 Chrome 为例,浏览器不仅有多个线程,还有多个进程,如渲染进程、GPU 进程和插件进程等。而每个 tab 标签页都是一个独立的渲染进程,所以一个 tab 异常崩溃后,其他 tab 基本不会被影响。作为前端开发者,主要重点关注其渲染进程,渲染进程下包含了 JS 引擎线程、HTTP 请求线程和定时器线程等,这些线程为 JS 在浏览器中完成异步任务提供了基础。

深入探讨事件循环

有关js同步与异步问题的深入探讨。

  • 进程是一个程序的执行实例,它拥有自己的内存空间和资源。一个程序可以同时运行多个进程,互不干扰。
  • 线程是进程内部的一个执行单元,它共享进程的内存空间和资源。一个进程可以同时运行多个线程,提高效率和并发性。

浏览器是一个多进程的应用程序,它由以下几种进程组成:

  • 浏览器主进程:负责浏览器的界面显示、用户交互、子进程管理等。
  • 渲染进程:负责渲染网页,每个标签页对应一个渲染进程。
  • 网络进程:负责网络请求的处理,如 HTTP、WebSocket 等。
  • GPU 进程:负责 GPU 的使用,如 3D 绘制、视频解码等。
  • 插件进程:负责插件的运行,如 Flash、PDF 等。

渲染进程是我们关注的重点,因为它涉及到前端开发中的同步异步以及线程相关的概念。渲染进程内部有以下几种线程:

  • GUI 线程:负责渲染网页的界面,如 HTML、CSS、图片等。
  • JS 引擎线程:负责执行 JS 代码,如 V8 引擎。
  • 事件触发线程:负责监听和处理事件,如点击、滚动、定时器等。
  • 定时器触发线程:负责处理定时器的回调函数,如 setTimeout、setInterval 等。
  • 异步 HTTP 请求线程:负责处理异步的网络请求,如 XMLHttpRequest、fetch 等。
  • Web Worker 线程:负责执行后台任务,不影响主线程。

JS 引擎线程是一个单线程的执行环境,也就是说,在同一时间只能执行一段 JS 代码。这是因为 JS 是一门设计用来与用户交互的脚本语言,如果允许多个 JS 代码同时运行,可能会导致 DOM 的操作冲突和数据不一致。因此,JS 的执行机制是基于事件循环(Event Loop)的。

事件循环是 JS 实现异步编程的核心机制,它可以简单地理解为以下几个步骤:

  1. JS 引擎线程从执行栈中取出一个同步任务并执行,直到执行栈为空。
  2. JS 引擎线程从任务队列中取出一个异步任务(也叫微任务)并执行,直到任务队列为空。
  3. JS 引擎线程从事件队列中取出一个异步任务(也叫宏任务)并执行,然后回到第二步。

同步任务是指那些不需要等待任何其他条件就可以立即执行的任务,如普通的赋值、计算、循环等。异步任务是指那些需要等待一定条件才能执行的任务,如网络请求、定时器、事件监听等。异步任务又分为微任务和宏任务,微任务是指那些在当前同步任务结束后立即执行的任务,如 Promise 的回调函数、MutationObserver 的回调函数等。宏任务是指那些在下一轮事件循环开始时才执行的任务,如 setTimeout 的回调函数、setInterval 的回调函数、DOM 事件的回调函数等。

JS 单线程存在的问题是,在执行一些耗时长或者阻塞的同步任务时,会导致后续的任务无法及时执行,影响用户体验和程序性能。解决方案是,尽量将这些任务转化为异步任务,或者使用 Web Worker 线程来执行这些任务,从而避免阻塞主线程。

JS 线程和 GUI 线程是互斥的,也就是说,在 JS 线程执行时,GUI 线程会暂停,反之亦然。这是为了保证 DOM 的渲染和操作的一致性。因此,如果 JS 代码执行时间过长,会导致页面的渲染和更新被延迟,影响用户体验。解决方案是,尽量优化 JS 代码的性能,或者使用 requestAnimationFrame 等 API 来实现动画效果,从而避免卡顿和闪烁。

JS 执行上下文、作用域以及作用域链

查看 JS 中执行上下文

在 JS 中,执行上下文是一个抽象的概念,它表示代码在运行时的环境。执行上下文可以分为三种类型:

  • 全局执行上下文:它是为运行代码主体而创建的执行上下文,也就是说它是为那些存在于函数之外的任何代码而创建的。全局执行上下文只有一个,它在浏览器中对应于 window 对象,在 Node.js 中对应于 global 对象。
  • 函数执行上下文:它是为每个函数调用而创建的执行上下文,也就是说它是为那些存在于函数内部的代码而创建的。函数执行上下文可以有多个,每次调用函数时都会创建一个新的执行上下文,并且在函数返回后销毁。
  • eval 执行上下文:它是为 eval 函数内部的代码而创建的执行上下文,也就是说它是为那些通过 eval 函数执行的代码而创建的。eval 执行上下文很少使用,因为 eval 函数通常被认为是不安全和低效的。

每个执行上下文都有以下三个重要的组成部分:

  • 变量对象(Variable Object):它是一个用于存储变量和函数声明的对象,它包含了该执行上下文中定义的所有变量和函数。
  • 作用域链(Scope Chain):它是一个用于确定变量访问权限的链表,它包含了该执行上下文及其所有父级执行上下文的变量对象。
  • this 值(This Value):它是一个用于指代当前对象的值,它根据函数调用方式的不同而有所不同。

当 JS 引擎解析到可执行代码片段(通常是函数调用阶段)的时候,就会先做一些执行前的准备工作,这个 “准备工作”,就叫做 “创建执行上下文”。创建执行上下文分为以下两个阶段:

  • 创建阶段(Creation Phase):在这个阶段,JS 引擎会做以下三件事:
    • 创建变量对象,并初始化其中的变量和函数声明。
    • 创建作用域链,并将当前变量对象添加到作用域链的最前端。
    • 确定 this 值,并将其赋值给当前执行上下文。
  • 执行阶段(Execution Phase):在这个阶段,JS 引擎会逐行执行代码,并根据变量对象、作用域链和 this 值来访问和操作变量和函数。
查看 JS 中的作用域以及作用域链

JS 中的作用域是指代码中变量和函数的可访问范围,它决定了变量和函数的生命周期和可见性。JS 中有三种类型的作用域:

  • 全局作用域:在代码中任何地方都能访问到的对象拥有全局作用域,例如最外层函数、最外层变量、未定义直接赋值的变量、window 对象的属性等。全局作用域有一个缺点,就是容易造成命名冲突和污染全局命名空间。
  • 函数作用域:在函数内部定义的变量和函数拥有函数作用域,它们只能在函数内部被访问,而不能在函数外部被访问。函数作用域可以隔离变量,避免与外部发生冲突。
  • 块级作用域:在 ES6 中,使用 let 和 const 关键字声明的变量拥有块级作用域,它们只能在当前代码块(由一对花括号包裹)内部被访问,而不能在代码块外部被访问。块级作用域可以更细粒度地控制变量的生命周期和可见性。

JS 中的作用域链是指在查找变量时沿着作用域层级所形成的链条,它由当前执行上下文及其所有父级执行上下文的变量对象组成。当一个变量在当前执行上下文中没有找到时,就会沿着作用域链向上一级查找,直到找到为止或者到达全局作用域。

作用域链的创建过程如下:

  • 当执行全局代码时,会创建一个全局执行上下文,并将其压入执行栈。
  • 当执行全局代码中的一个函数时,会创建一个函数执行上下文,并将其压入执行栈。
  • 在函数执行上下文中,会先创建一个变量对象,并初始化其中的变量和函数声明。
  • 然后会创建一个作用域链,并将当前变量对象添加到作用域链的最前端。
  • 接着会确定 this 值,并将其赋值给当前执行上下文。
  • 最后会逐行执行函数内部的代码,并根据作用域链来访问和操作变量和函数。

浏览器中的事件循环

执行栈与任务队列

  1. 执行栈:JS 按照顺序执行同步代码的时候,代码的执行依赖于一个栈结构,我们把这个栈叫做执行栈。

  2. 任务队列:

    1. 宏任务:有明确的异步任务需要执行和回调;需要其他异步线程支持。如 script(整体代码)setTimeoutsetIntervalsetImmediate

      注意 html 中的script标签,以及浏览器中的Dev Tools中控制台中的执行代码都是包装在宏任务中执行的。

    2. 微任务:没有明确的异步任务需要执行,只有回调;不需要其他异步线程支持。如promise.then()MutationObserverprocess.nextTick

      requestAnimationFrame在宏任务之后,在每次浏览器视图重绘前进行,所以其既不属于宏任务也不属于微任务。

      查看requestAnimationFrame的使用
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      const element = document.getElementById(
        "some-element-you-want-to-animate"
      );
      let start, previousTimeStamp;
      let done = false;
      
      function step(timeStamp) {
        if (start === undefined) {
          start = timeStamp;
        }
        const elapsed = timeStamp - start;
      
        if (previousTimeStamp !== timeStamp) {
          // Math.min() is used here to make sure the element stops at exactly 200px
          const count = Math.min(0.1 * elapsed, 200);
          element.style.transform = `translateX(${count}px)`;
          if (count === 200) done = true;
        }
      
        if (elapsed < 2000) {
          // Stop the animation after 2 seconds
          previousTimeStamp = timeStamp;
          if (!done) {
            window.requestAnimationFrame(step);
          }
        }
      }
      
      window.requestAnimationFrame(step);

JS 执行过程

执行顺序:同步代码->微任务队列->宏任务队列,注意对嵌套层级深的代码一定需要结合执行栈进行分析。

1
2
3
4
5
6
7
8
9
10
11
12
console.log("同步代码1");
setTimeout(() => {
  console.log("setTimeout");,
}, 0);
new Promise((resolve) => {
  console.log("同步代码2");
  resolve();
}).then(() => {
  console.log("promise.then");
});
console.log("同步代码3");
// 最终输出"同步代码1"、"同步代码2"、"同步代码3"、"promise.then"、"setTimeout"

NodeJS 中的事件循环

循环周期

NodeJSJS 的执行,我们主要需要关心的过程分为以下几个阶段,下面每个阶段都有自己单独的任务队列,当执行到对应阶段时,就判断当前阶段的任务队列是否有需要处理的任务。

  • timers阶段:执行所有 setTimeout()setInterval() 的回调,事件循环首先进入此阶段
  • pending callbacks阶段:某些系统操作相关的回调函数,如 TCP 链接错误。
  • idle, prepare:系统内部使用,程序员无需关心。
  • poll 阶段:轮询等待新的链接和请求等事件,执行 I/O 回调等。优先执行poll阶段的任务队列。如果此阶段任务队列已经执行完了,则进入 check 阶段执行 setImmediate 回调(如果有 setImmediate),或等待新的任务进来(如果没有 setImmediate)。在等待新的任务时,如果有 timers 计时到期,则会直接进入 timers 阶段。此阶段可能会阻塞等待。
  • check 阶段:setImmediate 回调函数执行。
  • close callbacks阶段:关闭回调执行,如socket.on('close', ...)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

上面每个阶段都会去执行完当前阶段的任务队列,然后继续执行当前阶段的微任务队列,只有当前阶段所有微任务都执行完了,才会进入下个阶段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const fs = require("fs");
fs.readFile(__filename, (data) => {
  // poll(I/O 回调) 阶段
  console.log("readFile");
  Promise.resolve().then(() => {
    console.error("promise1");
  });
  Promise.resolve().then(() => {
    console.error("promise2");
  });
});
setTimeout(() => {
  // timers 阶段
  console.log("timeout");
  Promise.resolve().then(() => {
    console.error("promise3");
  });
  Promise.resolve().then(() => {
    console.error("promise4");
  });
}, 0);
// 下面代码只是为了同步阻塞1秒钟,确保上面的异步任务已经准备好了
var startTime = new Date().getTime();
var endTime = startTime;
while (endTime - startTime < 1000) {
  endTime = new Date().getTime();
}
// 最终输出 timeout promise3 promise4 readFile promise1 promise2

nextTick 和 setImmediate

NodeJS 中的 process.nextTick()setImmediate() 也有类似效果。其中 setImmediate() 我们前面已经讲了是在 check 阶段执行的,而 process.nextTick() 的执行时机不太一样,它比 promise.then() 的执行还早,在同步任务之后,其他所有异步任务之前,会优先执行 nextTick。可以想象是把 nextTick 的任务放到了当前循环的后面,与 promise.then() 类似,但比 promise.then() 更前面。意思就是在当前同步代码执行完成后,不管其他异步任务,先尽快执行 nextTick。如下面的代码,因此这里的 nextTick 其实应该更符合setImmediate这个命名才对。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// 引入 fs 模块
const fs = require("fs");

// 定义一个异步读取文件的函数
function readFile(callback) {
  // 读取文件
  fs.readFile("./test.txt", (err, data) => {
    // 如果出错,抛出异常
    if (err) throw err;
    // 打印文件内容
    console.log("test3");
    // 调用回调函数
    callback();
  });
}

// 定义一个定时器函数
function timer() {
  // 设置一个 100 毫秒后执行的定时器
  setTimeout(() => {
    // 打印定时器信息
    console.log("Timer is executed");
  }, 100);
}

// 调用读取文件函数,并传入一个匿名回调函数
readFile(() => {
  // 在回调函数中,使用 process.nextTick() 调用另一个匿名回调函数
  fs.readFile("./test.txt", (err, data) => {
    // 如果出错,抛出异常
    if (err) throw err;
    // 打印文件内容
    console.log(data.toString());
  });
  setImmediate(() => {
    console.log("test5");
  });
  process.nextTick(() => {
    // 打印 nextTick 信息
    console.log("Next tick is executed");
  });
  Promise.resolve(1).then(() => {
    console.log("promise");
  });
});
console.log("test1");
process.nextTick(() => {
  console.log("test2");
});
// 调用定时器函数
timer();

// 执行结果
// test1
// test2
// test3
// Next tick is executed
// promise
// test5
// test4
// Timer is executed