跳到主要内容

Node.js 热门面试题

使用说明

本篇共 60 道题。

Node.js 面试会把 JavaScript 运行时与服务端工程结合起来。回答时要区分主线程、libuv、操作系统能力和业务框架,不要简单说“Node 是单线程”。

Q1: Node.js 为什么适合 I/O 密集型服务?

答案

JavaScript 主线程通过事件循环组织回调,把大量网络和部分文件/加密工作交给操作系统或 libuv,等待期间可以继续处理其他连接。

它适合高并发 I/O,但 CPU 密集计算会长时间占用事件循环,需要 Worker Threads、子进程或独立计算服务。

Q2: Node.js 真的是单线程吗?

答案

执行用户 JavaScript 的主事件循环通常是一个线程,但 Node 进程还包含 libuv 线程池、垃圾回收和运行时内部线程,也可主动创建 Worker。

“单线程”强调的是共享 JavaScript 状态和事件循环模型,不代表整个进程只有一个操作系统线程。

Q3: Node.js Event Loop 有哪些主要阶段?

答案

可以概括为 timers、pending callbacks、poll、check、close callbacks 等阶段,I/O 在 poll 附近处理,setImmediate 在 check 阶段。

每个阶段之间还会处理微任务。面试重点是理解调度关系,不建议脱离 Node 版本死背所有边缘执行顺序。

Q4: process.nextTick、Promise 和 setImmediate 的顺序怎么理解?

答案

不要只背一条全局顺序,要先看代码处于事件循环的哪个阶段:

  • process.nextTick 使用 Node 专有的 next tick 队列,在当前 JavaScript 操作结束后优先排空。
  • Promise reaction 和 queueMicrotask 使用微任务队列,通常在 next tick 队列之后处理。
  • setImmediate 回调进入事件循环的 check 阶段。
  • setTimeout(..., 0) 进入 timers 调度,实际先后受当前阶段和计时器阈值影响。

在 I/O 回调中注册时,setImmediate 通常会在下一轮 timers 之前执行;在主模块顶层同时注册 setTimeout(0)setImmediate,顺序不应作为稳定业务契约。递归 process.nextTick 还会饿死 I/O,应谨慎使用。

Q5: 什么是 Stream?

答案

Stream 是 Node.js 处理连续数据的抽象,让程序边读取边处理,而不是先把全部内容加载进内存。四类核心流是 Readable、Writable、Duplex 和 Transform。

它的价值包括:

  • 控制内存峰值,适合大文件、HTTP Body 和压缩管线。
  • 通过背压协调生产和消费速度。
  • 可以组合成处理管线,并统一传递错误与取消。
  • Chunk 不等于业务消息边界,文本还要处理字符编码和分包。

实际代码优先使用 stream.pipeline()stream/promisespipeline,确保任一环节失败时整条管线能正确销毁和清理。

Q6: 什么是背压?

答案

背压(Backpressure)是当数据生产速度超过消费速度时的一种流控机制。

// 不处理背压的问题
readable.on('data', (chunk) => {
// 如果 writable 较慢,数据会堆积在内存中
writable.write(chunk);
});

// 处理背压
readable.on('data', (chunk) => {
if (!writable.write(chunk)) {
readable.pause();
writable.once('drain', () => readable.resume());
}
});

// 最佳实践:使用 pipeline
import { pipeline } from 'stream/promises';
await pipeline(readable, transform, writable);

Q7: Buffer 和字符串有什么区别?

答案

特性BufferString
内容二进制字节序列Unicode 字符序列
长度字节数字符数
可变性可修改不可变
编码无编码,原始字节UTF-16
用途二进制数据、I/O文本处理
const str = '你好';
const buf = Buffer.from(str);

console.log(str.length); // 2(字符数)
console.log(buf.length); // 6(字节数,UTF-8 编码)

// Buffer 可修改
buf[0] = 0x00;

// String 不可变
// str[0] = 'x'; // 无效

Q8: CommonJS 和 ESM 在 Node 中怎么共存?

答案

Node 根据扩展名、package.jsontype 和条件导出决定模块格式。CommonJS 是运行时 require 和导出对象,ESM 使用静态 import/export 与 URL 语义。

互操作、默认导出、路径和加载时机存在差异。库要明确 exports、类型声明和测试矩阵,不要假设构建器会自动修正所有情况。

Q9: require 缓存有什么影响?

答案

CommonJS 模块首次加载后按解析后的文件名缓存,后续 require 通常返回同一导出对象,因此模块级状态会在进程内共享。

测试和多租户服务要谨慎使用可变单例。循环依赖时还可能拿到尚未初始化完成的导出。

  • CommonJS 首次 require 会执行模块并按解析后的绝对路径缓存导出值,后续调用直接复用,所以模块级状态天然是进程内单例。
  • 测试隔离或热加载时可以清理 require.cache,但要同时处理它的依赖图和副作用;生产代码不应依赖频繁删缓存刷新配置。

Q10: Worker Threads、Child Process 和 Cluster 怎么选?

答案

三者隔离和通信模型不同:

  • Worker Threads:同一进程中的独立 JavaScript 线程,适合 CPU 密集计算;可传递 ArrayBuffer 或使用 SharedArrayBuffer,共享内存需要自行同步。
  • Child Process:独立进程和内存空间,适合运行外部命令、不同运行时或需要强故障隔离的任务,通信成本更高。
  • Cluster:用多个 Node 进程共同承载网络服务,主要解决多核监听和连接分发;现代部署也常由容器编排器直接运行多个实例。

I/O 请求通常不需要“每请求一个 Worker”。应根据 CPU 时间、数据传输量、崩溃隔离、扩缩容和部署平台压测选择,并使用池而不是频繁创建线程或进程。

Q11: Node.js 如何处理错误?

答案

先按错误来源和能否恢复分层处理:

  1. 同步代码用 throwtry/catch
  2. Promise/async 函数通过 rejection 传播,在能够补充上下文或恢复的边界捕获。
  3. Node 风格回调遵循 error-first 约定,必须检查第一个参数。
  4. Stream 和 EventEmitter 的错误通过 error 事件或 pipeline 处理,遗漏监听可能导致进程异常。
  5. HTTP 边界把已知业务错误映射为稳定状态码,未知错误记录关联 ID 后返回通用响应。

不要空 catch、不要对所有错误盲目重试。未捕获异常和未处理拒绝说明错误越过了预期边界,通常应记录、停止接流量、优雅清理并由进程管理器重启,而不是继续运行在未知状态。

Q12: Express 和 Koa 中间件模型有什么区别?

答案

特性ExpressKoa
执行模型线性(瀑布流)洋葱模型
异步处理回调 + next()async/await
后置处理需要特殊处理自然支持(await next() 后)
响应时机可以多次ctx.body 赋值即可
// Express - 无法简单实现后置处理
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
console.log(`耗时: ${Date.now() - start}ms`);
});
next();
});

// Koa - 自然支持后置处理
app.use(async (ctx, next) => {
const start = Date.now();
await next();
console.log(`耗时: ${Date.now() - start}ms`);
});

Q13: 如何设计 Node.js 的优雅停机?

答案

收到终止信号后先停止接受新请求,把实例从负载均衡摘除,等待进行中的请求和后台任务完成,再关闭数据库、队列和日志资源。

必须设置最大超时,超时后强制退出;部署平台的健康检查、终止宽限期和应用超时要协调。

  • 收到 SIGTERM 后先把实例标记为未就绪并停止接收新请求,再等待进行中的 HTTP、队列任务和数据库事务完成。
  • 随后关闭连接池、消费者和定时器,并设置最长退出时间;超时后记录未完成工作再退出,避免容器无限卡在 terminating。

Q14: Node.js 服务如何避免阻塞事件循环?

答案

核心是让每次主线程回调尽快结束:

  • 避免同步文件、加密、压缩和子进程 API 出现在请求热路径。
  • 大 JSON 解析、正则、模板渲染和循环也可能阻塞,应限制输入、拆分任务或移到 Worker。
  • 数据处理使用 Stream 和背压,不把大文件完整读入内存。
  • 为数据库和上游请求设置超时、取消和并发上限,等待 I/O 本身不会占 CPU,但回调后的处理仍会。
  • 监控事件循环延迟、Event Loop Utilization、CPU Profile 和长请求,而不只看总体 CPU。

Worker 适合可并行的 CPU 工作,但线程创建和数据复制也有成本,应复用 Worker 池并压测。

Q15: 如何排查 Node.js 内存泄漏?

答案

步骤

  1. 监控内存:观察 process.memoryUsage() 持续增长
  2. 生成堆快照:在不同时间点生成多个快照
  3. 对比分析:在 Chrome DevTools 中对比快照差异
  4. 定位问题:找出持续增长的对象和引用链
  5. 修复验证:修复后重新监控确认
// 监控脚本
setInterval(() => {
const used = process.memoryUsage();
console.log(`Heap: ${Math.round(used.heapUsed / 1024 / 1024)} MB`);

// 超过阈值报警
if (used.heapUsed > 500 * 1024 * 1024) {
console.warn('Memory usage high!');
}
}, 5000);

常见原因

  • 全局缓存无限增长
  • 事件监听器未移除
  • 闭包持有大对象引用
  • 定时器未清理

Q16: Node.js 服务有哪些常见安全风险?

答案

依赖供应链、注入、路径遍历、SSRF、原型污染、命令执行、弱认证和敏感日志都很常见。

输入做 Schema 校验,数据库参数化,Shell 避免拼接,网络和文件使用允许列表;依赖最小化并持续扫描,Secrets 不进入代码与日志。

  • 基础防线包括输入 Schema 校验、参数化查询、认证授权、请求体限制、速率限制和安全响应头。
  • 依赖要锁版本并持续扫描,Secrets 不进代码和日志;文件、URL 抓取和模板渲染还要分别防上传攻击、SSRF 与注入。

Q17: 锁文件为什么重要?

答案

锁文件记录解析后的依赖版本和完整性,使本地与 CI 安装更可重复,也便于审查传递依赖变化。

应用通常提交锁文件;升级时检查 diff、许可证和安全影响。仅有锁文件仍不能替代可信 Registry、签名和供应链策略。

  • 锁文件记录完整依赖树和完整性哈希,让本地、CI 与生产安装得到同一版本,避免传递依赖漂移。
  • 它必须提交到仓库,并在 CI 使用 frozen/immutable 安装;依赖升级应通过明确 PR 完成,而不是手工修改锁文件。

Q18: 如何设计 Node.js HTTP 服务的超时?

答案

要区分客户端请求、连接、读取、上游调用和整体业务截止时间,不能只设一个很长的全局超时。

超时后要取消下游操作、释放资源并返回一致错误;重试只用于幂等且可恢复的失败,并加入退避和抖动。

  • 区分连接、首字节、请求总时长和空闲连接超时,并让上游、网关、应用和下游的截止时间逐层递减。
  • 超时后要真正取消底层请求、释放 socket,并只对幂等操作做有限次退避重试;否则超时只是调用方不再等待,资源仍在消耗。

Q19: NestJS 的核心设计是什么?

答案

Nest 通过模块、控制器、Provider 和依赖注入组织服务,并提供 Guard、Pipe、Interceptor、Filter 等请求生命周期扩展点。

它适合大型团队建立约定,但抽象不能替代领域分层。Provider 作用域、循环依赖和装饰器魔法需要控制。

Q20: Node.js 服务上线前要关注哪些可观测性指标?

答案

至少包括请求量、成功率、延迟分位数、事件循环延迟、CPU、内存/GC、连接池、队列堆积和外部依赖。

日志、Metrics 和 Trace 使用统一请求 ID;健康检查区分存活和就绪;告警围绕用户影响和错误预算,而不是每个瞬时峰值。

  • 至少监控请求量、错误率、P50/P95/P99 延迟、事件循环延迟、CPU、堆内存、GC、活跃句柄和连接池。
  • 日志、Metrics 与 Trace 通过 request ID 关联;告警基于 SLO 和用户影响,不只看单机 CPU 阈值。

Q21: AsyncLocalStorage 解决什么问题?

答案

AsyncLocalStorage 在一条异步调用链中保存请求级上下文,例如 request ID、租户、用户和 Trace 信息,避免把上下文参数层层手工传递。

const storage = new AsyncLocalStorage<{ requestId: string }>();

server.on('request', (req, res) => {
storage.run({ requestId: crypto.randomUUID() }, () => {
handleRequest(req, res);
});
});

内部代码可用 storage.getStore() 读取当前上下文。它不是全局可变对象:每条异步链有各自 Store。使用时要从正确入口建立上下文,避免把大型对象长期挂载,并验证第三方异步库是否保留上下文传播。

Q22: 事件循环延迟和 Event Loop Utilization 有什么区别?

答案

两者都能观察事件循环健康度,但回答的问题不同:

  • 事件循环延迟:关注一个本应及时执行的回调实际晚了多久。monitorEventLoopDelay() 可以统计延迟分布,长同步任务、GC 停顿或 CPU 争抢都会让 P95、P99 升高。
  • Event Loop Utilization(ELU):关注一段时间内事件循环处于 active 与 idle 的比例。ELU 长期接近饱和,说明进程几乎没有空闲时间,但不直接告诉你单次卡顿有多长。

实际排查时要结合使用:高 ELU 且高延迟通常表示持续过载;ELU 不高但延迟尖峰,可能是偶发长任务或 GC。还应关联 CPU、请求延迟、吞吐量和火焰图,不能仅凭一个阈值下结论。

Q23: require 的加载过程是什么?

答案

  1. 路径解析:将模块标识符解析为绝对路径
  2. 缓存检查:检查 require.cache,有则直接返回
  3. 模块创建:创建新的 Module 对象
  4. 缓存模块:将模块加入缓存(处理循环依赖)
  5. 加载执行:读取文件内容,包装后执行
  6. 返回导出:返回 module.exports
// 查看缓存
console.log(require.cache);

// 清除缓存
delete require.cache[require.resolve('./module')];

// 重新加载
const fresh = require('./module');

Q24: fs.readFile 和 fs.createReadStream 有什么区别?

答案

特性readFilecreateReadStream
加载方式一次性加载到内存流式加载(分块)
内存占用文件大小highWaterMark(默认 64KB)
适用场景小文件(< 100MB)大文件
返回类型Buffer/StringReadable Stream
可中断
// 小文件
const data = await readFile('small.txt', 'utf8');

// 大文件
const stream = createReadStream('large.txt');
for await (const chunk of stream) {
// 处理每个块
}

Q25: UV_THREADPOOL_SIZE 影响哪些 Node.js 任务?应该怎样调整?

答案

libuv 线程池用于一部分没有直接异步系统接口或由 Node 选择在线程池执行的任务,常见包括文件系统、部分 DNS、加密和压缩操作;普通网络 Socket I/O 通常由事件通知机制处理,不是“每个请求占一个线程池线程”。

调大线程池可能提高并行任务吞吐,但也会增加 CPU 争抢、内存和尾延迟。调整前应:

  • 用压测和 Trace 确认瓶颈确实在线程池排队。
  • 结合 CPU 核数、同进程任务类型和容器配额设置。
  • 考虑把重 CPU 任务移到 Worker Thread 或独立服务。
  • 在线程池初始化前设置,并在目标 Node 版本验证。

重点是容量规划,不是背一个固定线程数。

Q26: Node.js 中如何处理未捕获的异常?

答案

// 1. uncaughtException - 同步异常
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
// 记录日志
// 优雅退出
process.exit(1);
});

// 2. unhandledRejection - Promise 拒绝
process.on('unhandledRejection', (reason) => {
console.error('Unhandled Rejection:', reason);
// 可选择抛出或记录
});

// 3. 域(已废弃,不推荐)
// const domain = require('domain');

// 最佳实践
// - 始终处理 Promise 错误
// - 使用 try-catch 包装同步代码
// - uncaughtException 后应该退出进程
// - 使用 PM2 等进程管理器自动重启

Q27: 如何处理大文件上传?

答案

import { createServer } from 'http';
import { createWriteStream } from 'fs';
import { pipeline } from 'stream/promises';

const server = createServer(async (req, res) => {
if (req.method === 'POST' && req.url === '/upload') {
// 检查文件大小
const contentLength = parseInt(req.headers['content-length'] || '0');
const maxSize = 100 * 1024 * 1024; // 100MB

if (contentLength > maxSize) {
res.writeHead(413);
res.end('File too large');
return;
}

// 流式写入文件
const fileName = `upload_${Date.now()}.bin`;
const writeStream = createWriteStream(fileName);

try {
await pipeline(req, writeStream);
res.writeHead(200);
res.end('Upload successful');
} catch (err) {
res.writeHead(500);
res.end('Upload failed');
}
}
});

Q28: Node.js 中怎样统一取消多个异步操作?

答案

现代 Node API 和许多第三方客户端支持 AbortSignal。可以为一次请求创建 AbortController,把同一个 signal 传给 fetch、定时器、Stream 或业务函数;客户端断开、截止时间到达或上游取消时统一调用 abort()

取消必须向下传播到真正持有 socket、文件或任务的操作,仅让上层 Promise 提前 reject 不会自动释放底层资源。

还要区分取消、超时和真实故障,避免对主动取消上报高等级错误;清理监听器,并保证取消后的数据库事务和幂等性边界正确。

Q29: npm install 和 npm ci 有什么区别?

答案

特性npm installnpm ci
场景开发环境CI/CD 环境
package-lock.json可能更新必须存在,不更新
node_modules增量安装先删除再安装
速度较慢更快
一致性可能不一致保证一致
# CI/CD 推荐
npm ci # npm
yarn install --frozen-lockfile # yarn
pnpm install --frozen-lockfile # pnpm

Q30: 如何防止 SQL/NoSQL 注入?

答案

方法SQLNoSQL
参数化查询使用占位符 $1, ?使用 ORM/ODM
输入验证类型、长度、格式检查Zod/Joi 验证
ORMPrisma, TypeORMMongoose
最小权限数据库用户权限限制角色限制
// Prisma 自动防注入
const users = await prisma.user.findMany({
where: {
name: { contains: userInput } // 自动转义
}
});

// Zod 输入验证
const schema = z.object({
id: z.string().uuid(),
name: z.string().max(100).regex(/^[\w\s]+$/)
});

Q31: 什么是连接池?为什么需要连接池?

答案

连接池预先创建并维护一定数量的数据库连接,复用这些连接处理请求。

优点

  • 减少开销:避免频繁创建/销毁连接
  • 提高性能:复用已建立的连接
  • 控制资源:限制最大连接数
  • 提高稳定性:连接管理和健康检查
// 无连接池:每次请求新建连接
async function queryWithoutPool() {
const conn = await mysql.createConnection(config);
const result = await conn.query('SELECT 1');
await conn.end(); // 销毁连接
return result;
}

// 有连接池:复用连接
const pool = mysql.createPool(config);

async function queryWithPool() {
const result = await pool.query('SELECT 1');
return result; // 连接自动归还池
}

Q32: PM2 有哪些主要功能?

答案

功能说明
进程管理启动、停止、重启、删除
集群模式多实例负载均衡
日志管理统一日志收集
监控CPU、内存使用监控
零停机重启reload 滚动重启
开机自启系统重启后自动恢复
环境变量不同环境配置
# 集群模式:充分利用多核 CPU
pm2 start app.js -i max

# 零停机重启:逐个重启实例
pm2 reload my-app

Q33: package.jsonexports 字段解决什么问题?

答案

exports 明确一个包允许外部导入的入口,并可按 importrequire、Node、浏览器或类型声明提供条件映射。它能阻止消费者依赖包内部私有路径,并帮助 ESM/CJS 双格式发布。

设计时注意:

  • 同时声明根入口和需要公开的子路径。
  • 条件顺序和运行时支持要经过真实消费测试。
  • types、源码映射和实际 JavaScript 入口必须对齐。
  • 一旦启用,未列出的深层路径可能变为不可访问,这是有意的封装但也可能造成破坏性升级。
  • 不要让 ESM 和 CJS 分别创建两份单例状态,避免 dual package hazard。

Q34: NestJS 的请求生命周期是怎样的?Middleware、Guard、Interceptor、Pipe、ExceptionFilter 的执行顺序?

答案

NestJS 的请求管线完整执行顺序为:

Middleware → Guard → Interceptor(前置) → Pipe → Route Handler → Interceptor(后置) → ExceptionFilter(如有异常)

详细展开:

  1. Middleware:最先执行,全局中间件 → 模块中间件,用于通用的请求预处理(日志、CORS、Cookie 解析等)
  2. Guard:全局 → 控制器 → 路由,决定请求是否有权继续(认证、授权)。返回 false 则抛出 ForbiddenException
  3. Interceptor(前置):全局 → 控制器 → 路由,可在 Handler 执行前添加逻辑(如记录开始时间)
  4. Pipe:全局 → 控制器 → 路由 → 参数级别,负责参数验证和转换
  5. Route Handler:控制器方法执行
  6. Interceptor(后置):路由 → 控制器 → 全局(反序),可对响应数据做转换(如统一包装 { code, data, message }
  7. ExceptionFilter:路由 → 控制器 → 全局,管线中任何环节抛出异常都会被捕获处理
执行顺序验证
// 全局注册各组件后,一个请求的控制台输出:
// [Middleware] → Request incoming: GET /users
// [Guard] → Checking authorization...
// [Interceptor] → Before handler...
// [Pipe] → Validating params...
// [Handler] → UserController.findAll()
// [Interceptor] → After handler, +15ms
// [Filter] → (仅在异常时触发)
关键区别
  • Middleware 无法知道下一步会执行哪个 Handler(没有 ExecutionContext
  • Guard 可以通过 ExecutionContext 获取即将执行的 Handler 和 Class 信息
  • Interceptor 可以同时处理请求和响应(通过 RxJS Observable
  • Pipe 只处理输入参数
  • ExceptionFilter 只处理异常

Q35: 中间件为什么会出现“重复调用 next”问题?

答案

中间件应只把控制权向下传递一次。回调分支、异常路径或异步回调里重复调用 next(),可能导致同一请求继续执行两次、重复写响应或出现“headers already sent”。

治理方式:

  • 每个分支明确 return next() 或返回响应,避免后续继续落入另一个分支。
  • Promise/async 中间件统一由框架适配错误传播,不要同时 thrownext(error)
  • 响应发送后不要继续调用普通 next()
  • 给中间件写成功、失败、超时和重复回调测试。

Koa 的 await next() 也不能调用两次,框架通常会直接报错;洋葱模型并不会自动消除控制流错误。

Q36: Node.js 如何处理高并发?

答案

Node.js 通过以下机制处理高并发:

  1. 事件循环:单线程轮询处理事件
  2. 非阻塞 I/O:I/O 操作异步执行,不阻塞主线程
  3. libuv 线程池:文件 I/O 等操作在后台线程执行
  4. 事件驱动:通过回调/Promise 处理异步结果
// 处理 1 万个并发连接
import { createServer } from 'http';

const server = createServer((req, res) => {
// 每个请求快速处理,不阻塞
res.writeHead(200);
res.end('OK');
});

server.listen(3000);

// 单线程可以轻松处理上万并发
// 因为大部分时间在等待 I/O,不是在计算

Q37: Node.js 中怎样用 AbortSignal 统一超时和取消?

答案

AbortController 可以把同一个取消信号传给支持 Signal 的 fetch、定时器、文件或流式 API,并在请求断开、业务超时或服务停机时统一中止下游工作。现代 Node 还可用 AbortSignal.timeout() 创建超时信号,并按目标版本评估 AbortSignal.any() 组合多个来源。

关键原则:

  • 取消信号从入口向下传递,底层不要偷偷创建无法被上层控制的新 Controller。
  • 区分用户取消、超时和真实失败,日志与重试策略不同。
  • 中止后仍要释放自定义资源、移除监听器,并防止 Promise 结果晚到后写入响应。
  • 并非所有第三方库都支持 Signal,不支持时要接入其取消或销毁 API。

超时只是发出取消请求,不代表远端事务一定回滚,涉及副作用时还需幂等与状态查询。

Q38: CommonJS 和 ESM 的加载与导出语义有什么不同?

答案

CommonJS 的 require() 是运行时函数,通常同步执行模块并返回缓存中的 module.exports 对象引用;代码可以在条件分支中调用它。

ESM 的导入导出结构可被静态分析,模块先链接再按依赖图求值,导入的是只读 live binding,而不是导出值的普通拷贝。ESM 还使用 URL 语义,__dirname、扩展名和 JSON 导入规则与 CommonJS 不同。

循环依赖时,CommonJS 可能拿到尚未执行完成的部分导出,ESM 则受初始化顺序和暂时性死区约束。库发布必须实际测试两种消费方式,不能只靠文件扩展名猜测互操作行为。

Q39: stream.pipeline() 相比连续 .pipe() 有什么优势?

答案

连续 .pipe() 能传递数据,但任一中间流报错时,开发者容易遗漏关闭其他流和结束 Promise。pipeline 会把整条链视为一个操作,统一传播错误并销毁相关流;Promise 版本还能自然配合 await

await pipeline(
createReadStream(source),
createGzip(),
createWriteStream(target),
{ signal },
);

它仍不能替代业务级事务:目标文件可能已经写入一部分,远端请求也可能产生副作用。失败后是否删除临时文件、重试或断点续传,需要由上层协议决定。

Q40: 如何实现文件复制?

答案

import { copyFile, createReadStream, createWriteStream } from 'fs';
import { pipeline } from 'stream/promises';

// 方式一:copyFile(简单快速)
await copyFile('src.txt', 'dest.txt');

// 方式二:Stream(大文件,可监控进度)
async function copyWithProgress(src: string, dest: string) {
const stat = await fs.promises.stat(src);
const totalSize = stat.size;
let copiedSize = 0;

const readStream = createReadStream(src);
const writeStream = createWriteStream(dest);

readStream.on('data', (chunk) => {
copiedSize += chunk.length;
const progress = (copiedSize / totalSize * 100).toFixed(2);
console.log(`进度: ${progress}%`);
});

await pipeline(readStream, writeStream);
}

// 方式三:cp(目录复制)
await cp('src-dir', 'dest-dir', { recursive: true });

Q41: cluster 的工作原理是什么?

答案

cluster 通过主进程监听端口,将连接分发给工作进程:

  1. 端口共享:主进程监听端口
  2. 连接分发:新连接到达时,主进程选择一个工作进程处理
  3. 负载均衡:默认使用轮询(Round-Robin)策略
// 简化原理
if (cluster.isPrimary) {
const server = net.createServer();
server.listen(3000);

server.on('connection', (socket) => {
// 选择一个 worker
const worker = selectWorker();
// 将 socket 发送给 worker
worker.send('socket', socket);
});
}

Q42: 操作错误和编程错误有什么区别?

答案

操作错误(Operational Errors)

  • 运行时预期可能发生的错误
  • 可以优雅处理
  • 示例:文件不存在、网络超时、用户输入无效

编程错误(Programmer Errors)

  • 代码 bug
  • 应该修复代码而非处理
  • 示例:TypeError、undefined 访问、类型错误
// 操作错误 - 处理
try {
await readFile('config.json');
} catch (err) {
if (err.code === 'ENOENT') {
// 文件不存在,使用默认配置
return defaultConfig;
}
throw err;
}

// 编程错误 - 修复代码
const user = null;
console.log(user.name); // TypeError - 这是 bug

// 区分处理
class AppError extends Error {
isOperational: boolean;

constructor(message: string, isOperational = true) {
super(message);
this.isOperational = isOperational;
}
}

process.on('uncaughtException', (err) => {
if (err instanceof AppError && err.isOperational) {
// 操作错误,可以恢复
logger.error(err);
} else {
// 编程错误,退出
logger.error(err);
process.exit(1);
}
});

Q43: 如何实现 HTTP 代理?

答案

import { createServer, request as httpRequest } from 'http';

const proxyServer = createServer((clientReq, clientRes) => {
const url = new URL(clientReq.url!, `http://${clientReq.headers.host}`);

// 转发请求
const proxyReq = httpRequest({
hostname: url.hostname,
port: url.port || 80,
path: url.pathname + url.search,
method: clientReq.method,
headers: clientReq.headers
}, (proxyRes) => {
// 转发响应
clientRes.writeHead(proxyRes.statusCode!, proxyRes.headers);
proxyRes.pipe(clientRes);
});

// 转发请求体
clientReq.pipe(proxyReq);

proxyReq.on('error', (err) => {
clientRes.writeHead(502);
clientRes.end('Bad Gateway');
});
});

proxyServer.listen(8080);

Q44: 什么是洋葱模型?为什么 Koa 采用洋葱模型?

答案

洋葱模型是指中间件的执行像剥洋葱一样,请求从外到内穿过各层中间件,响应再从内到外穿出。

优势

  • 支持后置处理(计时、日志、错误处理)
  • 代码逻辑清晰
  • 更好的错误处理
// 洋葱模型实现计时
app.use(async (ctx, next) => {
const start = Date.now();
await next(); // 等待内层中间件执行完
const ms = Date.now() - start;
ctx.set('X-Response-Time', `${ms}ms`);
});

Q45: dependencies 和 devDependencies 有什么区别?

答案

类型作用示例
dependencies生产环境需要express, lodash
devDependencies仅开发时需要typescript, jest
peerDependencies宿主环境提供react(插件)
optionalDependencies可选依赖fsevents
{
"dependencies": {
"express": "^4.18.0" // 生产代码使用
},
"devDependencies": {
"typescript": "^5.0.0", // 开发时编译
"jest": "^29.0.0" // 测试
},
"peerDependencies": {
"react": ">=16.8.0" // React 组件库
}
}
# 生产环境只安装 dependencies
npm install --production

Q46: Node.js 如何分析 CPU 密集型问题?

答案

# 1. 使用 --prof 生成 profile
node --prof app.js
node --prof-process isolate-*.log > processed.txt

# 2. 使用 clinic flame 生成火焰图
clinic flame -- node app.js

# 3. Chrome DevTools CPU Profiler
node --inspect app.js
# 在 DevTools 中录制 CPU profile
// 识别阻塞事件循环的操作
import { monitorEventLoopDelay } from 'perf_hooks';

const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();

setInterval(() => {
console.log({
min: h.min / 1e6, // ms
max: h.max / 1e6,
mean: h.mean / 1e6,
p99: h.percentile(99) / 1e6
});
}, 5000);

Q47: Node.js 应用如何防止 DDoS 攻击?

答案

// 1. 速率限制
import rateLimit from 'express-rate-limit';

// 2. 请求体大小限制
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ limit: '10kb', extended: true }));

// 3. 超时设置
const server = app.listen(3000);
server.setTimeout(30000); // 30 秒超时

// 4. 负载均衡 + CDN

// 5. 使用 slowloris 防护
import slowDown from 'express-slow-down';

const speedLimiter = slowDown({
windowMs: 15 * 60 * 1000,
delayAfter: 100,
delayMs: (hits) => hits * 100
});

Q48: ORM 和原生 SQL 各有什么优缺点?

答案

特性ORM原生 SQL
开发效率
类型安全好(Prisma)需手动定义
性能有开销最优
灵活性较低最高
学习曲线需学习 API需学习 SQL
复杂查询可能受限完全支持
// ORM - 简洁但灵活性受限
const users = await prisma.user.findMany({
where: { age: { gte: 18 } },
include: { posts: true }
});

// 原生 SQL - 灵活但繁琐
const [users] = await pool.query(`
SELECT u.*, GROUP_CONCAT(p.title) as posts
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
WHERE u.age >= ?
GROUP BY u.id
`, [18]);

Q49: Docker 部署 Node.js 有哪些最佳实践?

答案

# 1. 使用 Alpine 小镜像
FROM node:20-alpine

# 2. 多阶段构建减小体积
FROM node:20-alpine AS builder
# ... 构建阶段
FROM node:20-alpine AS runner
COPY --from=builder ...

# 3. 使用非 root 用户
RUN addgroup -S app && adduser -S app -G app
USER app

# 4. 只复制必要文件
COPY package*.json ./
RUN npm ci --only=production

# 5. 使用 .dockerignore
# node_modules
# *.log
# .git

# 6. 健康检查
HEALTHCHECK --interval=30s CMD curl -f http://localhost:3000/health

# 7. 正确处理信号
CMD ["node", "dist/index.js"] # 直接运行,不用 npm

Q50: Node.js 内置 fetch 和 node-fetch 有什么区别?

答案

特性内置 fetchnode-fetch
安装无需安装需要安装
APIWeb 标准接近标准
依赖内置外部依赖
Node 版本18+任意版本
底层Undicihttp 模块
// 内置 fetch(Node 18+)
const res = await fetch(url);

// 向后兼容
const fetch = globalThis.fetch || (await import('node-fetch')).default;

Q51: NestJS 的依赖注入是怎么实现的?三种注入作用域有什么区别?

答案

NestJS 的依赖注入基于 TypeScript 的装饰器元数据反射reflect-metadata)实现:

  1. @Injectable() 装饰器会在编译时将类的构造函数参数类型记录为元数据
  2. 应用启动时,IoC 容器扫描所有 Module,收集 providers
  3. 当需要实例化一个 Provider 时,容器读取其构造函数参数的类型元数据,递归解析并注入依赖
  4. 默认创建单例并缓存
DI 原理简化演示
// TypeScript 编译后,@Injectable() 会存储参数类型信息:
// Reflect.getMetadata('design:paramtypes', UserController)
// => [UserService]

// IoC 容器的简化逻辑:
function resolve<T>(target: Type<T>): T {
// 1. 读取构造函数参数类型
const params = Reflect.getMetadata('design:paramtypes', target) || [];

// 2. 递归解析每个依赖
const injections = params.map((param: Type) => resolve(param));

// 3. 实例化并返回
return new target(...injections);
}

三种作用域的区别:

作用域实例数量创建时机销毁时机适用场景
DEFAULT全局 1 个(单例)应用启动时应用关闭时无状态的 Service(绝大多数场景)
REQUEST每个请求 1 个请求到来时请求结束后 GC需要请求上下文的场景(多租户、请求级缓存)
TRANSIENT每次注入 1 个每次被注入时消费者被销毁时需要独立状态的工具类(如独立的 Logger 实例)
性能注意

REQUEST 和 TRANSIENT 作用域会频繁创建实例,影响性能。此外,REQUEST 作用域会导致作用域冒泡 -- 依赖它的上游 Provider 也会被提升为 REQUEST 作用域。建议大多数场景使用默认的 DEFAULT 作用域。

Q52: 为什么 Fastify 比 Express 快?

答案

Fastify 的性能优势来自多个底层优化:

1. JSON 序列化优化

Express 使用 JSON.stringify(),每次调用都需要遍历对象;Fastify 使用 fast-json-stringify,根据 JSON Schema 预编译序列化函数:

序列化对比
// Express - 运行时动态序列化
res.json({ id: 1, name: 'Alice', secret: 'xxx' });
// 每次调用 JSON.stringify(),遍历所有字段

// Fastify - 编译时生成序列化函数
// 启动时根据 schema 生成如下函数(伪代码):
function serialize(obj: { id: number; name: string }) {
return `{"id":${obj.id},"name":"${obj.name}"}`;
// 不会序列化 schema 外的字段(如 secret)
}

2. 路由查找优化

Express 使用线性数组匹配路由(O(n)O(n));Fastify 使用 find-my-way 基于 Radix Tree(前缀树)的路由查找(O(k)O(k),k 为路径长度),路由越多差距越大。

3. 请求/响应处理

  • 避免不必要的对象创建和原型链查找
  • 使用高效的 Header 解析
  • 内置高性能日志 pino(JSON 日志,无字符串拼接)

4. Schema 验证

使用 Ajv 预编译验证函数,避免运行时解释执行。

Q53: 什么是 libuv?它的作用是什么?

答案

libuv 是 Node.js 的底层库,提供:

功能说明
事件循环实现非阻塞 I/O
异步文件操作文件读写、监控
异步网络TCP/UDP Socket
线程池处理阻塞操作
信号处理进程信号
定时器setTimeout/setInterval
跨平台抽象统一 Windows/Unix API
// libuv 线程池默认 4 个线程
// 可通过环境变量调整
process.env.UV_THREADPOOL_SIZE = '8';

// 以下操作使用线程池:
// - fs 文件操作(除了 fs.watch)
// - crypto 加密操作
// - dns.lookup
// - 某些压缩操作

Q54: 为什么在 I/O 回调中 setImmediate 通常先于 setTimeout(0)

答案

I/O 回调通常在 poll 阶段执行,poll 之后进入 check 阶段,因此同一 I/O 回调里注册的 setImmediate 可以在本轮 check 执行;setTimeout(0) 需要等后续 timers 调度且还受最小阈值影响。

这条结论依赖“在 I/O 回调内注册”的上下文。若在主模块顶层同时注册,两者先后不应作为稳定业务逻辑。需要表达“本轮 I/O 后尽快执行”时使用 setImmediate;需要时间语义时使用定时器。

Q55: Node.js 条件导出中的 importrequiredefault 应怎样设计?

答案

包可以在 exports 中为不同加载方式提供入口。Node 会按匹配条件和对象声明顺序选择目标,因此通常把更具体的 importrequirenode 等条件放在 default 前面。

设计时要避免“双包风险”:同一进程若通过 ESM 和 CommonJS 加载到两份独立实例,单例状态、类身份和缓存可能不一致。可让两个入口共享同一底层实现,或明确只支持一种主格式。

类型声明也要与每个入口匹配,并测试 Node、TypeScript 和打包器的解析结果。条件导出解决加载兼容,不应提供语义不同的 API。

Q56: Stream 的流动模式和暂停模式有什么区别?

答案

模式触发方式数据获取控制方式
流动模式data 事件自动推送pause/resume
暂停模式readable 事件手动 read()按需读取
// 流动模式
readable.on('data', (chunk) => {
// 数据自动推送
});

// 暂停模式
readable.on('readable', () => {
let chunk;
while ((chunk = readable.read()) !== null) {
// 手动读取
}
});

// 切换到流动模式
readable.resume();
readable.pipe(writable);

// 切换到暂停模式
readable.pause();

Q57: 同步和异步 API 应该如何选择?

答案

异步 API(推荐)

  • 不阻塞事件循环
  • 适用于服务端场景
  • 支持高并发

同步 API

  • 启动时加载配置
  • CLI 工具
  • 脚本任务
// ❌ 服务端使用同步 API 会阻塞
app.get('/file', (req, res) => {
const data = fs.readFileSync('large.txt'); // 阻塞!
res.send(data);
});

// ✅ 使用异步 API
app.get('/file', async (req, res) => {
const data = await fs.promises.readFile('large.txt');
res.send(data);
});

// ✅ 启动时使用同步 API 可以
const config = JSON.parse(
fs.readFileSync('config.json', 'utf8')
);

Q58: worker_threads 和 Web Workers 有什么区别?

答案

特性worker_threadsWeb Workers
环境Node.js浏览器
共享内存SharedArrayBuffer + AtomicsSharedArrayBuffer(需 COOP/COEP)
可转移对象MessagePort、ArrayBuffer 等类似
require/import✅ 支持❌ 需 importScripts
Node.js API✅ 可访问❌ 无
// worker_threads
import { Worker } from 'worker_threads';
const worker = new Worker('./task.js');

// Web Workers
const worker = new Worker('/task.js');
worker.postMessage(data);

Q59: 为什么 uncaughtException 后应该退出进程?

答案

未捕获异常后,应用可能处于不确定状态:

  • 请求可能未完成
  • 数据可能不一致
  • 资源可能未释放
process.on('uncaughtException', async (err) => {
console.error('Uncaught Exception:', err);

// 记录日志
await logger.error(err);

// 关闭服务器,停止接受新请求
server.close(() => {
// 关闭数据库连接
db.close();
// 退出进程
process.exit(1);
});

// 如果关闭超时,强制退出
setTimeout(() => {
process.exit(1);
}, 10000);
});

// 使用 PM2 自动重启
// pm2 start app.js

Q60: http.Agent 的作用是什么?

答案

Agent 管理 HTTP 客户端连接的复用:

import { Agent, request } from 'http';

// 默认 Agent
// - keepAlive: false
// - 每次请求创建新连接

// 自定义 Agent
const agent = new Agent({
keepAlive: true, // 复用连接
keepAliveMsecs: 1000, // Keep-Alive 间隔
maxSockets: 10, // 每个主机最大并发
maxFreeSockets: 5, // 空闲连接数
timeout: 60000 // 超时
});

// 使用 Agent
request({ hostname: 'api.example.com', agent });

// 获取状态
console.log(agent.sockets); // 活跃连接
console.log(agent.freeSockets); // 空闲连接
console.log(agent.requests); // 等待队列

// 关闭所有连接
agent.destroy();

相关链接