
2026-08-04 · 阅读 8
Nodejs
Node.js 基础 - 面试复习笔记
前言
作为前端开发者,Node.js 在日常开发中主要扮演构建工具和运行环境的角色(npm install、npm run dev、next dev 等)。但面试中仍会考察对 Node.js 核心原理的理解,本文整理了面试高频考点。
一、事件循环(Event Loop)
1.1 什么是事件循环?
Node.js 是单线程的,通过事件循环机制来处理异步 I/O 操作。
用一个生活中的例子理解:
想象一个餐厅只有一个厨师(主线程),厨师不会傻等水烧开(I/O 操作),而是先记下来、去干别的事、等水烧好了再回来处理。这个"来回查看、依次处理"的流程就是事件循环。
1.2 事件循环的 6 个阶段
┌───────────────────────────┐
┌─>│ timers │ 定时器回调(setTimeout、setInterval)
│ └───────────┬───────────────┘
│ ┌───────────▼───────────────┐
│ │ pending callbacks │ 上轮循环遗留的 I/O 回调
│ └───────────┬───────────────┘
│ ┌───────────▼───────────────┐
│ │ idle, prepare │ 内部使用
│ └───────────┬───────────────┘
│ ┌───────────▼───────────────┐
│ │ poll │ I/O 事件回调(核心阶段)
│ └───────────┬───────────────┘
│ ┌───────────▼───────────────┐
│ │ check │ setImmediate 回调
│ └───────────┬───────────────┘
│ ┌───────────▼───────────────┐
└──│ close callbacks │ 关闭事件回调(socket.on('close'))
└───────────────────────────┘
1.3 执行优先级
同步代码(立刻执行)
↓ 执行完了
微任务(process.nextTick → Promise.then)
↓ 清空了
宏任务(timers → pending → poll → check → close)
核心规则:
- 同步代码优先级最高,无论后面写的是 setTimeout 还是 Promise
- 每个宏任务阶段执行完后,会立即清空所有微任务,再进入下一阶段
process.nextTick优先级高于Promise.then
1.4 经典面试题
console.log('start')
setTimeout(() => {
console.log('timeout')
}, 0)
Promise.resolve().then(() => {
console.log('promise')
})
process.nextTick(() => {
console.log('nextTick')
})
console.log('end')
输出顺序:start → end → nextTick → promise → timeout
解析:
- 同步代码先执行:打印
start和end - 清空微任务:先执行
process.nextTick,再执行Promise.then - 进入 timers 阶段:执行
setTimeout
1.5 setTimeout vs setImmediate
| 对比项 | setTimeout(fn, 0) | setImmediate(fn) |
|---|---|---|
| 所属阶段 | timers 阶段 | check 阶段 |
| 执行顺序 | 在 I/O 回调之后 | 在 poll 阶段之后 |
| 在 I/O 回调中 | 后执行 | 先执行 |
在 I/O 回调中 setImmediate 先执行的原因:I/O 回调在 poll 阶段执行,poll 结束后立即进入 check 阶段,而 timers 要等下一轮。
二、模块系统
2.1 CommonJS(Node.js 默认)
// 导出
module.exports = { name: 'test' }
// 或
exports.name = 'test'
// 导入
const mod = require('./mod')
2.2 ES Module(ESM)
// 导出
export const name = 'test'
export default { name: 'test' }
// 导入
import mod, { name } from './mod.js'
2.3 关键区别
| 对比项 | CommonJS | ES Module |
|---|---|---|
| 加载方式 | 运行时动态加载 | 编译时静态分析 |
| 导出方式 | 值的拷贝 | 值的引用(live binding) |
| 重复引用 | 缓存结果,只执行一次 | 同样只执行一次 |
2.4 值拷贝 vs 值的引用(高频面试题)
// ====== CommonJS - 值的拷贝 ======
// counter.js
let count = 0
module.exports = { count, increment() { count++ } }
// main.js
const { count, increment } = require('./counter')
increment()
console.log(count) // 0 !导出的是拷贝,count 变量没变
// ====== ESM - 值的引用 ======
// counter.js
export let count = 0
export function increment() { count++ }
// main.js
import { count, increment } from './counter.js'
increment()
console.log(count) // 1 !导出的是引用,能拿到最新值
三、Buffer 与 Stream
3.1 Buffer
Buffer 用于处理二进制数据,类似于固定长度的字节数组。
const buf = Buffer.from('hello')
console.log(buf.toString('utf-8')) // 'hello'
console.log(buf[0]) // 104(h 的 ASCII 码)
3.2 Stream
Stream 是数据的"流",分批处理数据,不需要一次性加载全部到内存。
const fs = require('fs')
// 读取流
const readStream = fs.createReadStream('big-file.txt')
// 写入流
const writeStream = fs.createWriteStream('output.txt')
// 管道:直接把读取流导向写入流
readStream.pipe(writeStream)
3.3 Stream 的四种类型
| 类型 | 说明 | 典型用途 |
|---|---|---|
| Readable | 可读流 | 文件读取、HTTP 请求 |
| Writable | 可写流 | 文件写入、HTTP 响应 |
| Duplex | 双工流(可读可写) | TCP socket |
| Transform | 转换流(读+写+转换) | zlib 压缩/解压 |
面试回答"为什么要用 Stream":
大文件处理时,不用 Stream 需要一次性加载全部内容到内存(可能导致 OOM),用 Stream 可以分块处理,内存占用恒定。
四、EventEmitter
Node.js 事件驱动的核心,很多内置模块都继承自它。
const EventEmitter = require('events')
class MyEmitter extends EventEmitter {}
const emitter = new MyEmitter()
emitter.on('event', (msg) => {
console.log('收到消息:', msg)
})
emitter.emit('event', 'Hello World') // 收到消息: Hello World
关键 API
| 方法 | 说明 |
|---|---|
on(event, listener) | 注册监听器 |
once(event, listener) | 注册一次性监听器 |
emit(event, ...args) | 触发事件 |
off(event, listener) | 移除监听器 |
注意事项:内存泄漏
// 错误示范:每次请求都添加新的监听器,永不移除
server.on('request', (req, res) => {
emitter.on('data', handler) // 每次请求都注册,越来越多!
})
// 解决方案
emitter.once('data', handler) // 用 once
// 或
emitter.off('data', handler) // 手动移除
Node.js 默认每个事件最多 10 个监听器,超过会发出
MaxListenersExceededWarning警告。
五、包管理 — npm vs pnpm
| 对比项 | npm | pnpm |
|---|---|---|
| 依赖存储 | 每个项目各存一份 | 全局统一存储,用硬链接指向 |
| 磁盘占用 | 大(重复依赖多份) | 小(相同版本只存一份) |
| 幽灵依赖 | 存在 | 不存在(严格依赖) |
| 安装速度 | 慢 | 快 |
| node_modules 结构 | 扁平化 | 非扁平化 + .pnpm 存储 |
什么是幽灵依赖?
// package.json 中只声明了 lodash
{
"dependencies": {
"lodash": "4.17.21"
}
}
npm 扁平化后,可以直接 require axios(axios 是 lodash 的依赖),但 package.json 里没声明。如果 lodash 更新移除了 axios,代码就会报错。
pnpm 用非扁平结构解决:只能 require 自己声明了的依赖,无法访问未声明的嵌套依赖。
六、面试策略总结
作为前端/全栈开发者,Node.js 知识点的掌握策略:
| 知识点 | 掌握程度 | 面试目标 |
|---|---|---|
| 事件循环 | 理解执行顺序 | 能做对输出顺序题 |
| 模块系统 | 知道 CommonJS 和 ESM 区别 | 值拷贝 vs 值引用 |
| Buffer/Stream | 知道概念和大文件场景 | 答出"Stream 分批处理避免内存溢出" |
| EventEmitter | 知道 on/emit/once | 能说出"监听器泄漏" |
| 包管理 | npm vs pnpm 区别 | 幽灵依赖、硬链接 |
更重要的是结合实际项目经验回答:
- "我用 Next.js 写过 API 路由,理解 SSR 和 CSR 的区别"
- "用 NestJS 写过后端服务,用过装饰器、依赖注入、模块系统"
- "了解 pnpm 用硬链接节省空间、解决幽灵依赖问题"
Q1:Node.js 是单线程的吗?怎么处理高并发?
A:Node.js 的主线程是单线程的,通过事件循环和异步 I/O 实现高并发。遇到 I/O 操作(文件读写、网络请求)时,不会阻塞主线程,而是交给底层的 libuv 线程池去处理,操作完成后通过回调放入事件队列等待主线程处理。所以 Node.js 适合I/O 密集型场景(如 API 服务、实时聊天),不适合CPU 密集型场景(如视频编码、大量计算)。
Q2:事件循环的执行顺序是什么?
A:同步代码 → 微任务(process.nextTick > Promise.then)→ 宏任务(timers → pending → poll → check → close)。每个宏任务阶段执行完后,会先清空所有微任务,再进入下一阶段。
Q3:setTimeout(fn, 0) 和 setImmediate(fn) 有什么区别?
A:setTimeout(fn, 0) 在 timers 阶段执行,setImmediate(fn) 在 check 阶段执行。在主模块中两者顺序不确定,但在 I/O 回调中 setImmediate 一定先执行(因为 I/O 在 poll 阶段,poll 结束直接进入 check,而 timers 要等下一轮循环)。
Q4:CommonJS 和 ES Module 有什么区别?
A:
- 加载时机:CommonJS 运行时加载(动态),ESM 编译时加载(静态)
- 导出方式:CommonJS 导出的是值的拷贝,ESM 导出的是值的引用(live binding)
- 使用场景:CommonJS 是 Node.js 默认,ESM 是前端和 Node.js 新项目的趋势
Q5:什么是幽灵依赖?pnpm 怎么解决的?
A:幽灵依赖是指在 npm 扁平化的 node_modules 中,你可以 require 没有在 package.json 中声明的包(因为它是某个依赖的子依赖)。如果上游更新移除了这个子依赖,你的代码就会报错。pnpm 通过非扁平化的 node_modules 结构解决:只能引用自己声明了的依赖,无法访问嵌套的子依赖。
Q6:Stream 有什么用?什么场景下使用?
A:Stream 用于分批处理数据,不需要一次性加载全部到内存。典型场景:
- 大文件读写(避免内存溢出)
- 网络传输(HTTP 请求/响应)
- 文件压缩/解压(Transform 流)
核心优势:内存占用恒定,适合处理大文件和高并发数据流。
Q7:EventEmitter 的监听器泄漏怎么理解?
A:如果在循环或高频调用的场景中(如每次 HTTP 请求),每次都 emitter.on() 注册新的监听器而不移除,监听器会越来越多导致内存泄漏。Node.js 默认每个事件最多 10 个监听器,超过会发出 MaxListenersExceededWarning。解决方案:用 once() 注册一次性监听器,或用 off() 手动移除。
Q8:package.json 中版本号 ^、~、* 有什么区别?
A:
^1.2.3(插入号):允许次版本和补丁版本更新 → 可更新到1.x.x~1.2.3(波浪号):只允许补丁版本更新 → 可更新到1.2.x*:允许任何版本更新 → 不限制1.2.3(精确版本):不允许任何更新
Q9:npm 和 pnpm 的本质区别是什么?
A:npm 每个项目都完整安装依赖到各自的 node_modules,相同包的相同版本会被重复存储。pnpm 在全局统一存储所有包的内容,各项目的 node_modules 通过硬链接指向全局存储。好处是:
- 节省磁盘空间(相同版本只存一份)
- 安装更快(已有的包直接链接,不需要重新下载)
- 杜绝幽灵依赖(非扁平结构,只能引用声明的包)
Q10:Node.js 中 process.nextTick 和 Promise.then 有什么区别?
A:两者都是微任务,但 process.nextTick 的优先级高于 Promise.then。执行顺序:同步代码 → process.nextTick → Promise.then → 宏任务。process.nextTick 在当前操作结束后、进入事件循环下一阶段之前执行;Promise.then 在事件循环的每个阶段结束后、进入下一阶段之前执行。
Q11:为什么说 Node.js 适合 I/O 密集型而不是 CPU 密集型?
A:Node.js 是单线程的,事件循环机制让它在等待 I/O 时可以去处理其他任务,所以 I/O 多的时候效率很高。但 CPU 密集型任务(如大量计算、加密、压缩)会阻塞主线程,导致事件循环无法处理其他回调,整个服务会卡住。解决方案:用 Worker Threads(worker_threads 模块)把 CPU 密集型任务分配给子线程,或者用 cluster 模块 启动多个进程。
Q12:Buffer 是什么?和字符串有什么区别?
A:Buffer 是 Node.js 中用于处理二进制数据的类,类似于固定长度的字节数组。字符串是字符序列,Buffer 是字节序列。Buffer 常用于处理文件、网络协议、图片等二进制数据。可以通过 Buffer.from('hello') 从字符串创建,通过 buf.toString() 转回字符串。Buffer 的大小在创建时就固定了,不能动态调整。