Skip to main content

Node.js

Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境,使用了一个事件驱动、非阻塞式 I/O 模型,使其轻量又高效。

面试高频问题概览

  • Node.js 是单线程的吗?如何充分利用多核 CPU?
  • Node.js 是如何实现多个进程监听同一个端口的?为什么不会报“端口被占用”?
  • Event Loop (事件循环) 的阶段有哪些?与浏览器端有什么区别?
  • Buffer 和 Stream 是什么?
  • 模块加载机制 (CommonJS) 源码与循环引用问题。

架构模型与核心机制

面试必问:Node.js 究竟是不是单线程的?

严格来说,Node.js 并不是纯粹的单线程

  • 主线程是单线程:JavaScript 的执行、V8 引擎的运行、事件循环的调度,都是在同一个主线程(Event Loop 线程)上运行的。这保证了 JS 执行时没有锁的开销和线程上下文切换的负担。
  • 底层工作线程池 (Thread Pool):Node.js 依赖的 C++ 库 libuv 内部维护了一个线程池(默认大小为 4,可以通过 UV_THREADPOOL_SIZE 环境变量修改)。当主线程遇到 文件 I/O (fs)、DNS 查询 (dns.lookup)、复杂的密码学计算 (crypto) 和 压缩 (zlib) 时,主线程不会阻塞,而是将这些任务丢给 libuv 的线程池去多线程并行处理。处理完成后,再将回调推入事件循环队列。

面试官追问:“把任务丢给 libuv 线程池处理,难道没有线程间通信的性能损耗吗?”

  • 当然有损耗。 V8 主线程和 libuv 工作线程之间的通信确实存在 Context Switch(上下文切换)和数据拷贝的开销。
  • 为什么还要这么做? 因为文件 I/O 等操作相比于内存中的 JS 执行是非常慢的(磁盘读写耗时通常是 CPU 计算的几万倍)。如果让主线程死等磁盘,整个服务器就会瘫痪(阻塞)。“两害相权取其轻”,虽然线程通信有损耗,但换来了主线程的非阻塞,保证了高并发处理能力。
  • 网络 I/O 怎么处理的? 注意!网络请求(如 HTTP、Socket)是不走线程池的! 网络 I/O 直接由操作系统内核的异步非阻塞接口(如 Linux 的 epoll,macOS 的 kqueue)接管,性能极高,这才是 Node.js 处理高并发网络请求的真正大杀器。
  • Worker Threads (工作线程):为了弥补 Node.js 处理 CPU 密集型任务(如图像处理、大规模数据计算)的短板,Node 10.5+ 引入了 worker_threads 模块,允许开发者真正地在 JS 层面上创建多线程,并且它们可以共享内存 (SharedArrayBuffer)。
  • V8:执行 JS 代码,提供 JS 运行环境。
  • libuv:C++ 编写的事件循环和异步 I/O 库,是 Node.js 异步非阻塞的核心。它跨平台地封装了底层的系统调用(如 epoll/kqueue/IOCP)。
  • Core Modules (内置模块):JS 层面的基础模块,如 fs, http, path, crypto
  • C++ Bindings:使得 JS 能够调用 C++ 代码,起到桥梁作用。

模块加载机制

  1. 计算绝对路径
  2. 加载模块
    1. 优先从缓存中加载
    2. 加载核心模块
    3. 按路径加载模块
    4. 加载第三方模块。会从当前目录的node_modules往上查找,直到根目录。后两者会以绝对路径为 key 生成新的缓存

加载模块

得到模块的绝对路径后,就开始加载模块了。首先确定模块的后缀名,不同的文件对应不同的加载方法,以 .js 和 .json 为例

Module._extensions[".js"] = function (module, filename) {
var content = fs.readFileSync(filename, "utf8");
module._compile(stripBOM(content), filename);
};
Module._extensions[".json"] = function (module, filename) {
var content = fs.readFileSync(filename, "utf8");
try {
module.exports = JSON.parse(stripBOM(content));
} catch (err) {
err.message = filename + ": " + err.message;
throw err;
}
};

解析 js 模块的关键函数:

Module.prototype._compile = function (content, filename) {
compiledWrapper(content, this.exports, this.require, this);
};
var compiledWrapper = function (content, exports, require, module) {
eval(content);
};

模块的加载其实就是注入 exports、require、module 三个全局变量,然后执行模块的源码,最后输出模块的 exports 值

CommonJS 是如何解决循环引用问题的?

// main.js
const child = require('./child.js')
console.log('执行 main.js: ', child)
module.exports = 'main'
// child.js
const main = require('./main.js')
console.log('执行 child.js: ', main)
module.exports = 'child'
// 执行 main.js , 输出如下:
// 解析:执行到 child.js 时,main.js 还没有执行完,默认导出空对象
执行 child.js: {}
执行 main.js: child

commonjs - Node Guidebook

事件循环

事件循环是 Node.js 处理异步非阻塞 I/O 的核心机制,其底层由 libuv 实现。

Node.js 与浏览器 Event Loop 的核心区别:

  • 浏览器的 Event Loop 相对简单:执行一个宏任务 -> 清空微任务队列 -> 渲染 -> 执行下一个宏任务。
  • Node.js 的 Event Loop 按照阶段 (Phases) 划分,每个阶段都有一个执行回调的 FIFO 队列。在 Node 11 之前,Node 会在每个阶段的所有宏任务执行完毕后,才统一执行微任务;而在 Node 11 之后,为了和浏览器保持一致,Node 变成了每执行完一个宏任务,就会立刻清空微任务队列

事件循环概览如下所示:

   ┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘

Node.js 的事件循环是实现非阻塞 I/O 的基础。以上每个阶段都有一个 FIFO 队列来执行回调,当一个事件完成的时候,内核会通知 Node.js 将回调函数添加到队列中。每个阶段最后都会执行当前阶段产生的微任务(包括 nextTick)

timers

进入这个阶段,主线程会检查时间是否满足定时器(setTimeout、setInterval)触发的条件,如果满足就执行相应的回调函数,否则离开这个阶段

pending callbacks

此阶段执行某些系统操作的回调,例如 TCP 错误

idle、prepare

这个阶段只供 libuv 内部调用

poll

用于处理大部分 I/O 回调和等待 I/O 事件 进入 poll 且不存在到期定时器,此阶段可能会导致主线程阻塞,该阶段会分为以下两种情况:

  1. poll 队列不为空,同步执行队列
  2. poll 队列为空,如果执行了setImmediate,则结束 poll 阶段,进入 check 阶段;如果没有,事件循环将阻塞并等待回调添加到 poll 队列中执行

一旦 poll 队列为空,事件循环将检查到期的定时器,如果有定时器准备好,就回滚到计时器阶段以执行这些计时器的回调;否则会一直停留在这个阶段,等待 I/O 请求返回结果

check

执行 setImmediate 回调

close

该阶段执行关闭请求的回调函数,比如 socket.on('close', callback)

微任务

微任务分类:

  • process.nextTick(优先级更高)
  • Promise.then

微任务会在主线之后和事件循环的每个阶段之后立即执行

issues

把 setTimeout 和 setImmediate 放入同一个 I/O 回调函数内,setImmediate 总是被优先调用

fs.readFile(__filename, () => {
setTimeout(() => {
console.log("timeout");
}, 0);
setImmediate(() => {
console.log("immediate");
});
});

原因是 fs.readFile 的回调是在 poll 阶段执行的,该阶段过后会先轮到 check 阶段,即执行 setImmediate 回调,下一轮事件循环才执行 setTimeout 的回调

那为什么在没有 I/O 回调的普通代码中,这两者的执行顺序不确定呢?

  • Node.js 启动,执行同步代码(初始化)。
  • 执行到 setTimeout,虽然设置的是 0 毫秒,但在 Node.js 内部会被强制转换为至少 1 毫秒,加入 timers 阶段。
  • 执行到 setImmediate,加入 check 阶段。
  • 同步代码执行完毕,进入 Event Loop 的第一个阶段(timers)。
  • 关键点:如果事件循环启动得非常快(耗时不到 1ms),此时 setTimeout 还没有到期,就会跳过 timers 阶段,一路向下走到 check 阶段执行 setImmediate。但如果机器稍慢或前序准备耗时超过 1ms,timers 阶段的定时器已经到期了,就会先执行 setTimeout。这就导致了输出顺序的不确定性。

核心机制:多进程与多线程

由于 Node.js 是单线程的,为了充分利用多核 CPU 以及处理 CPU 密集型任务,Node.js 提供了完善的多进程 (child_process, cluster) 和多线程 (worker_threads) 解决方案。

👉 关于多进程/多线程的底层原理、端口共享机制以及常见面试题,请阅读专门的章节:多进程与多线程


数据处理:Buffer 与 Stream

在 Node.js 中处理文件、网络通信、音视频等底层 I/O 操作时,必定会绕不开 Buffer (堆外内存二进制缓冲区) 和 Stream (流)。它们是解决大文件处理引发 OOM 的核心机制。

👉 关于 Buffer 的内存分配机制、大文件处理策略以及高频考点“背压 (Backpressure) 问题”,请阅读专门的章节:Buffer 与 Stream


异常监控与日志管理

在单线程的 Node.js 中,一个未捕获的异常可能导致整个服务崩溃;而在生产环境中,完善的结构化日志和进程守护是排查故障的唯一救命稻草。

👉 关于 uncaughtException 的最佳实践、优雅退出、PM2 核心机制以及大厂日志管理方案(Winston/Pino, 结构化日志),请阅读专门的章节:异常监控与日志管理


跨语言与底层扩展:C++ 插件

在面临极端的性能瓶颈或需要调用底层系统库时,纯 JavaScript 往往力不从心。Node.js 允许开发者通过编写 C++ 插件 (Addons) 来打破这一限制。

👉 关于 C++ 插件的演进 (N-API)、.node 文件的加载原理,以及 WebAssembly (Wasm) 的对比,请阅读高级章节:C++ 扩展


终极进阶:高级核心概念与底层原理

在资深或专家岗位的面试中,面试官往往会跳出日常 API,考察你对 V8 引擎、模块化底层以及特定安全场景的深入理解。

👉 关于全链路追踪 (AsyncLocalStorage)、V8 引擎性能反优化、模块化底层包装机制,以及 Node 特有的安全攻防 (ReDoS / 原型链污染),请阅读终极篇:高级核心概念