Portfolio
← 返回博客列表
Nodejs

2026-08-04 · 阅读 8

Nodejs

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')

输出顺序startendnextTickpromisetimeout

解析

  1. 同步代码先执行:打印 startend
  2. 清空微任务:先执行 process.nextTick,再执行 Promise.then
  3. 进入 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 关键区别

对比项CommonJSES 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

对比项npmpnpm
依赖存储每个项目各存一份全局统一存储,用硬链接指向
磁盘占用大(重复依赖多份)小(相同版本只存一份)
幽灵依赖存在不存在(严格依赖)
安装速度
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) 有什么区别?

AsetTimeout(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 通过硬链接指向全局存储。好处是:

  1. 节省磁盘空间(相同版本只存一份)
  2. 安装更快(已有的包直接链接,不需要重新下载)
  3. 杜绝幽灵依赖(非扁平结构,只能引用声明的包)

Q10:Node.js 中 process.nextTick 和 Promise.then 有什么区别?

A:两者都是微任务,但 process.nextTick 的优先级高于 Promise.then。执行顺序:同步代码 → process.nextTickPromise.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 的大小在创建时就固定了,不能动态调整。