Node.js 是一个开源的 JavaScript 运行时环境,旨在运行可扩展的应用程序。 Node.js 允许开发人员使用 JavaScript 编写服务器端脚本代码。此外,Node.js 具有能够异步 I/O 的事件驱动架构。它基于 Google Chrome 的 V8 引擎构建,用于开发I/O 密集型 Web 应用程序,例如视频流、单页应用程序、在线聊天应用程序和其他网页。在本文中,我将讨论 Node.js 架构,并深入探讨 Node.js 内部更小的组件。
可以将 Node.js 架构分为两个主要组件:V8 引擎和 LIBUV。下边分别看看每个组件的功能。
V8 Engine
V8 引擎是 Node.js 架构的基础部分。如果没有 V8 引擎,就无法识别 JavaScript。 V8引擎帮助将JavaScript代码转换为机器可以理解代码。
LIBUV
LIBUV 是一个专注于异步 I/O 的开源库。该库提供Node.js 对计算机操作系统、文件系统和网络的访问。以下是 LIBUV 的一些功能
- 异步TCP(net模块)和UDP(dgram模块)
- 异步DNS解析(部分用于DNS模块)
- 异步文件、文件系统操作和事件(fs 模块)
- ANSI 转义码控制的 TTY
- 线程池和信号处理
- 子进程
- 高分辨率时钟
- 线程和同步原语。
- 使用套接字和 Unix 域套接字 (Windows) 进行进程间通信
事件队列(Event Queue)、事件循环(Event Loop)和线程池(Thread Pool)是LIBUV中最重要的组件。
Event Queue 事件队列
事件队列将传入的客户端请求按顺序传递给事件循环
Event Loop 事件循环
事件循环负责处理小任务,例如执行回调函数或网络 I/O。这些是非阻塞任务,不会阻塞主线程。它负责处理所有传入事件,并通过将较繁重的任务卸载到线程池中并自行执行较简单的任务来执行平衡负载。以下是事件循环的一些功能
- 事件循环是一个无限循环,它等待任务,执行它们,然后休眠直到接收到更多任务。
- 仅当调用栈为空(即没有正在进行的任务)时,事件循环才会执行事件队列中的任务。
- 事件循环允许我们使用回调和promise。
- 事件循环从最早的(oldest first)任务开始执行任务。
Thread Pool 线程池
线程池为我们提供了 4 个独立的线程。事件循环会将繁重的任务卸载到线程池,这是自动的。线程池负责处理繁重的任务,例如
- 件访问
- 密码学相关的东西
- 缓存密码
- 文件压缩
- DNS 查找
其他库
除了上述主要组件之外,Node.js 架构中还使用以下库来实现其他目的
- HTP Parser — parsing HTTP
- C-ARES — DNS queries
- OpenSSL — cryptography
- Zlib — file compression
Node.js 架构流程
- 客户端向服务器发送请求。请求可以是阻塞的或非阻塞的。
- Node.js 检索传入请求并将其添加到事件队列event queue中。
- 事件队列中将每个请求将 一 一 传递到事件循环。
- 事件循环检查请求是否足够小,可以在其自身中执行,否则将请求传递给线程循环
- 当线程池收到请求时,它会执行该请求并将响应再次传递给事件循环。
Node.js 架构的优点
- 处理多个并发客户端请求既快速又简单
- 无需创建太多线程
- 需要更少的资源和内存
了解宏任务与微任务
考虑下面的代码(
setTimeout(() => {
while (true) {
console.log("a");
}
}, 1000);
setTimeout(() => {
while (true) {
console.log("b");
}
}, 1000);
这里,如果你运行这个程序,你在屏幕上遇到的唯一内容将是“a”。这是因为只要还有可用的指令,NodeJS 解释器就会继续执行当前的回调。
一旦主代码中的所有指令执行完毕,NodeJS 运行时环境就会开始调用回调函数。您还可以将您编写的主要代码视为默认情况下作为回调调用。在上面的例子中,第一个setTimeout是使用提供的回调函数执行的,第二个setTimeout是使用提供的回调函数执行的。 1 秒后,它开始发送“a”。您永远不会看到“b”,因为一旦调用第一个回调,它就会以 while 循环永远控制主线程!因此,第二个回调永远不会被调用。
虽然你写了 1000 ms后回调输出b,但永远没有机会了
宏任务与微任务的定义
宏任务包括:
- setTimeout 和 setInterval
- setImmediate(Node.js 环境)
- I/O 操作
- UI 渲染任务
- 整个 script 代码块
微任务包括:
- Promise.then、catch、finally
- queueMicrotask
- MutationObserver
- process.nextTick(Node.js 环境,优先级最高)
执行顺序规则
- 同步代码优先执行,属于当前宏任务的一部分。
- 同步代码执行完后,清空微任务队列。
- 微任务执行完毕后,开始执行下一个宏任务。
- 每个宏任务执行完毕后,都会检查并清空微任务队列。
示例代码与解析
以下代码展示了宏任务与微任务的执行顺序:
console.log('同步代码1');
setTimeout(() => {
console.log('宏任务1');
}, 0);
Promise.resolve().then(() => {
console.log('微任务1');
return Promise.resolve();
}).then(() => {
console.log('微任务2');
});
console.log('同步代码2');
输出结果:
同步代码1
同步代码2
微任务1
微任务2
宏任务1
解析:
- 执行同步代码,输出 同步代码1 和 同步代码2。
- Promise.then 回调加入微任务队列。
- setTimeout 回调加入宏任务队列。
- 清空微任务队列,依次执行 微任务1 和 微任务2。
- 执行下一个宏任务,输出 宏任务1。
嵌套任务的处理
当宏任务中创建了新的微任务,这些微任务会在当前宏任务执行完毕后立即执行。例如:
setTimeout(() => {
console.log('宏任务开始');
Promise.resolve().then(() => console.log('宏任务中的微任务1'));
Promise.resolve().then(() => console.log('宏任务中的微任务2'));
console.log('宏任务结束');
}, 0);
输出结果:
宏任务开始
宏任务结束
宏任务中的微任务1
宏任务中的微任务2
注意事项
- Promise 构造函数中的代码是同步执行的,而 then 回调是微任务。
- 微任务优先级高于宏任务,可能导致宏任务长时间被阻塞。- 在数据变化后通过
Promise.resolve().then(...)代替setTimeout(..., 0),更快执行逻辑 - 在循环中创建大量微任务可能导致性能问题。