JavaScript 中的事件循环机制

208 阅读10分钟

本文是自己总结用的,大家可以当做参考,但是由于自己的水平有限,文档中一定会存在不合理的或者错误的地方,请大家见谅,友好观看。

如果您对某个地方有疑问,或者有更好的见解可以在评论区提出来,大家一起进步,非常感谢!


JavaScript 事件循环机制

一、进程和线程

进程和线程示意图.png

1.1 进程

进程是一个具有一定独立功能的程序在一个数据集合上依次动态执行的过程。是操作系统进行资源分配(内存)和调度(独立运行)的基本单位

  1. 动态性:进程随着程序的执行和运行而动态变化
  2. 独立性:进程与进程之间完全隔离,独立运行。一个进程崩溃不会影响其他进程。
  3. 并发性:多个进程可以在单个处理器上并发执行

1.2 线程

是 CPU 任务调度的基本单位

  1. 轻量级:线程是进程中的一个实体,创建和切换的开销更小
  2. 共享资源:同一进程内的多个线程共享进程的资源
  3. 并发性:同一个进程中的多个线程可以并发执行

1.3 区别

  1. 资源占用:进程拥有独立的内存空间和系统资源,而线程共享同一进程中的资源
  2. 开销大小:进程的创建和销毁开销比线程大
  3. 通信方式:进程间通信需要特定的机制,如管道、消息队列等。线程间通信则可以直接通过共享内存进行
  4. 依赖关系:线程依赖于进程存在。进程是线程的容器

二、浏览器中的进程与线程

  • 浏览器从启动到关闭,至少开启四个进程:browser进程 GPU进程 网络进程 渲染进程
  • 当新增其他标签页时,browser进程 GPU进程 网络进程 进程可以共用。默认每打开一个标签页,就会开启一个 渲染进程

2.1 browser 进程

负责协调、主控,只有一个

  1. 负责控制浏览器除标签页外的界面。如地址栏、书签、前进后退按钮
  2. 负责创建和销毁其他进程

2.2 GPU 进程

负责处理与图形渲染相关的任务

  1. 图形渲染
  2. 动画和过渡
  3. 视频播放和解码等

2.3 网络进程

负责处理与网络相关的任务。它主要负责管理和维护网络连接,以及处理所有的网络请求和响应

  1. 管理网络连接
  2. 处理网络请求和响应
  3. 处理 DNS 解析
  4. 处理缓存

2.4 插件进程

用于处理浏览器插件的进程(每个插件,就会创建一个进程,避免由于其中一个插件进程的崩溃导致整个浏览器崩溃)

2.5 渲染进程

负责将 HTML、CSS 和 JavaScript 转换为可视化的页面。默认情况下,有一个浏览器的 tab 就会创建一个渲染进程。(本文需要介绍的重点)

三、浏览器中的渲染进程

页面的渲染,JS 的执行,事件的循环,都在这个进程内进行,浏览器的渲染进程是多线程的。

3.1 GUI 渲染线程

  1. 负责渲染浏览器界面,解析 HTML、CSS。构建 DOM Tree、CSSOM Tree、Render Tree。页面的布局和绘制
  2. 当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程也会执行
  3. GUI 的更新会被保存在一个队列之中,等到 JS 引擎线程空闲时,GUI 线程就会立即执行

3.2 JS 引擎线程

  1. 也称为 JS 内核,负责解析 Javascript 脚本,运行代码。最出名的便数 V8 引擎
  2. JS 引擎一直等待着任务队列中任务的到来,然后加以处理。

3.3 定时器触发线程

  1. 定时器函数: setInterval 与 setTimeout
  2. 浏览器定时计数器并不是由 JS 引擎计数的(因为 JS 引擎是单线程的, 如果该线程处于阻塞状态就会影响记计时的准确)
  3. 当计时完成后,定时器的函数回调(任务)会被添加到事件队列中,等待 JS 引擎空闲后去执行

3.4 异步 HTTP 线程

  1. XMLHttpRequest 在连接后是通过浏览器新开一个线程请求
  2. 当检测到 HTTP 请求的状态发生变化后,HTTP 请求的函数回调(任务)会被添加到事件队列中,等待 JS 引擎空闲后去执行

3.5 事件触发线程

  1. 用来控制事件循环(Event Loop)的线程
  2. 周期性的检查事件队列中是否存在任务,并主动触发 JS 引擎线程来处理队列中的任务

四、JS 引擎线程和 GUI 线程的互斥

  1. 由于 JavaScript 是可操纵 DOM 的,如果在修改 DOM 的同时渲染界面(即 JS 引擎线程和 GUI 渲染线程同时运行),那么渲染线程前后获得的元素数据就可能不一致了。因此为了防止渲染出现不可预期的结果,浏览器设置 GUI 渲染线程与 JS 引擎为互斥的关系,当 JS 引擎执行时 GUI 线程会被挂起,
  2. GUI 更新则会被保存在一个队列中等到 JS 引擎线程空闲时立即被执行。(JS 阻塞页面加载的原因)

4.1 script 标签放到 body 标签的末尾

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>

  <body>
    ......
    <script src="../xxx/yyy/zzz.js"></script>
  </body>
</html>
  1. 因为 GUI 线程和 JS 引擎线程是互斥的,所以 JS 代码的加载与执行会阻塞 DOM 的解析与渲染
  2. 为了更好的用户体验(减少白屏的时间,用户能尽早看到页面)。因此需要等到页面的 DOM 标签都解析与渲染完成后,在下载和执行 JS 代码

五、异步任务与单线程

5.1 同步任务

  1. 某段程序执行会阻塞其他程序的执行,一定要等上一个任务执行完毕,拿到结果之后,才能执行下一个任务。
  2. 其表现形式为程序的执行顺序依赖程序本身的书写顺序。
  3. 如果在函数 A 返回的时候,调用者就能够得到预期结果(即拿到了预期的返回值或者看到了预期的效果),那么这个函数就是同步的。

5.2 异步任务

  1. 某段程序执行不会阻塞其他程序的执行,不必等待上一个任务执行完毕,拿到结果,就能执行下一个任务
  2. 其表现形式为程序的执行顺序不依赖本身的书写顺序。
  3. 如果在函数 A 返回的时候,调用者还不能够得到预期结果,而是需要在将来通过一定的手段(回调函数、通知)得到,那么这个函数就是异步的

单线程的解决方案:因为 JS 引擎是单线程的,所以在处理任务时必然不可能采用同步任务的编程模型。而是采用单线程 + 异步任务的编程模型

六、事件循环机制(Event Loop)

6.1 宏任务与微任务

(1) 宏任务

  1. script(执行全局的 JS 代码)
  2. setTimeout、setInterval
  3. I/O(文件、网络)
  4. UI 交互事件
  5. setImmediate(Node.js 环境)

(2) 微任务

  1. Promise.then、Promise.catch
  2. DOM 变化
  3. process.nextTick(Node.js 环境)

6.2 事件循环机制

事件循环图示.png

图例描述:一次事件循环中最多处理一个宏任务,然后会处理微任务队列中的所有任务, 然后检查 UI 是否需要重新渲染,然后开始下一轮循环

  • 任务队列:至少具有两个队列,一个宏任务队列,一个微任务队列
  • 事件循环:是指 JS 引擎线程重复从任务队列中取任务、执行任务的过程。

事件循环的实现至少应该含有一个用于宏任务的队列和一个用于微任务的队列。大部分的实现通常根据任务类型拥有更多的队列,这使得事件循环能够根据任务类型进行优化处理。例如,优先处理键盘和鼠标事件,保证该任务的优先处理

(1) 代码示例
console.log('同步代码1');
Promise.resolve().then(() => {
  console.log("微任务1");
});

setTimeout(() => {
  console.log("宏任务1");
  Promise.resolve().then(() => {
    console.log("微任务2");
  });
  Promise.resolve().then(() => {
    console.log("微任务3");
  });
  Promise.resolve().then(() => {
    console.log("微任务4");
  });
}, 1000);

setTimeout(() => {
  console.log("宏任务2");
}, 500);

setTimeout(() => {
  console.log("宏任务3");
}, 1000);
console.log('同步代码2');

// 执行结果
1. 同步代码1
2. 同步代码2
3. 微任务1
4. 宏任务2
5. 宏任务1
6. 微任务2
7. 微任务3
8. 微任务4
9. 宏任务3

事件循环过程.png

(2)微任务队列的意义
  1. 微任务是更小的任务, 必须在浏览器继续执行其他宏任务之前执行(更轻量、优先级更高)
  2. 能够让浏览器及时处理宏任务中的 DOM 变化,并且可以保证 DOM 变化在浏览器渲染之前完成,如果任务队列只有一个的话,无法将微任务的执行顺序放到下一个宏任务前。
(3) 玩转计时器
  1. 无法确保计时器延迟的时间:只能控制计时器任务何时被添加到队列,但是无法控制计时器任务何时执行。
  2. 计时器提供了一种异步延迟执行代码片段的能力,我们可以利用计时器的这个特性,使用setTimeout(() => {}, 0)将长时间运行的任务分解成较小的任务。例如,一次性渲染 2000 条数据,可以分解成 10 个任务,每次只渲染 200 条数据。这样在每个小任务执行完成后,都有机会进行页面渲染,而不必非得等到所有的数据都渲染完成后页面才能进行更新。

浏览器尝试每秒 60 次渲染页面(60 帧,平均每 16ms 渲染一帧), 这意味着在理想状态下,单个任务和该任务附属的所有微任务,都应该在 16ms 内完成。可以恰当利用计时器的特性,来对项目内的长时间任务进行拆分

6.3 单线程 + 异步编程模型的优缺点

(1) 优点
  1. 适合 I/O 密集型应用,因为允许程序在等到 I/O 完成时继续执行其他任务(非阻塞 I/O)
  2. 简化编程模型,单线程避免了多线程编程中的复杂性。例如,死锁、竞态条件、线程同步等问题
  3. 没有上线程下文切换的开销,在某些场景下可以提供更好的性能

(2) 缺点

  1. 不适合 CPU 密集型应用,需要长时间计算的任务会阻塞后续任务的执行
  2. 无法充分利用多核 CPU 的优势
  3. 异步代码的逻辑理解和调试更复杂,因为代码的执行顺序并不和代码的书写顺序相同

更新记录

  1. 2024-08-18:加入进程、线程、浏览器渲染进程的说明,优化了事件循环机制的图示,增加了代码执行过程示意图