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/promises 的 pipeline,确保任一环节失败时整条管线能正确销毁和清理。
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 和字符串有什么区别?
答案:
| 特性 | Buffer | String |
|---|---|---|
| 内容 | 二进制字节序列 | 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.json 的 type 和条件导出决定模块格式。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 如何处理错误?
答案:
先按错误来源和能否恢复分层处理:
- 同步代码用
throw与try/catch。 - Promise/async 函数通过 rejection 传播,在能够补充上下文或恢复的边界捕获。
- Node 风格回调遵循 error-first 约定,必须检查第一个参数。
- Stream 和 EventEmitter 的错误通过
error事件或pipeline处理,遗漏监听可能导致进程异常。 - HTTP 边界把已知业务错误映射为稳定状态码,未知错误记录关联 ID 后返回通用响应。
不要空 catch、不要对所有错误盲目重试。未捕获异常和未处理拒绝说明错误越过了预期边界,通常应记录、停止接流量、优雅清理并由进程管理器重启,而不是继续运行在未知状态。
Q12: Express 和 Koa 中间件模型有什么区别?
答案:
| 特性 | Express | Koa |
|---|---|---|
| 执行模型 | 线性(瀑布流) | 洋葱模型 |
| 异步处理 | 回调 + 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 内存泄漏?
答案:
步骤:
- 监控内存:观察
process.memoryUsage()持续增长 - 生成堆快照:在不同时间点生成多个快照
- 对比分析:在 Chrome DevTools 中对比快照差异
- 定位问题:找出持续增长的对象和引用链
- 修复验证:修复后重新监控确认
// 监控脚本
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 的加载过程是什么?
答案:
- 路径解析:将模块标识符解析为绝对路径
- 缓存检查:检查
require.cache,有则直接返回 - 模块创建:创建新的 Module 对象
- 缓存模块:将模块加入缓存(处理循环依赖)
- 加载执行:读取文件内容,包装后执行
- 返回导出:返回
module.exports
// 查看缓存
console.log(require.cache);
// 清除缓存
delete require.cache[require.resolve('./module')];
// 重新加载
const fresh = require('./module');
Q24: fs.readFile 和 fs.createReadStream 有什么区别?
答案:
| 特性 | readFile | createReadStream |
|---|---|---|
| 加载方式 | 一次性加载到内存 | 流式加载(分块) |
| 内存占用 | 文件大小 | highWaterMark(默认 64KB) |
| 适用场景 | 小文件(< 100MB) | 大文件 |
| 返回类型 | Buffer/String | Readable 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 install | npm 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 注入?
答案:
| 方法 | SQL | NoSQL |
|---|---|---|
| 参数化查询 | 使用占位符 $1, ? | 使用 ORM/ODM |
| 输入验证 | 类型、长度、格式检查 | Zod/Joi 验证 |
| ORM | Prisma, TypeORM | Mongoose |
| 最小权限 | 数据库用户权限限制 | 角色限制 |
// 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.json 的 exports 字段解决什么问题?
答案:
exports 明确一个包允许外部导入的入口,并可按 import、require、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(如有异常)
详细展开:
- Middleware:最先执行,全局中间件 → 模块中间件,用于通用的请求预处理(日志、CORS、Cookie 解析等)
- Guard:全局 → 控制器 → 路由,决定请求是否有权继续(认证、授权)。返回
false则抛出ForbiddenException - Interceptor(前置):全局 → 控制器 → 路由,可在 Handler 执行前添加逻辑(如记录开始时间)
- Pipe:全局 → 控制器 → 路由 → 参数级别,负责参数验证和转换
- Route Handler:控制器方法执行
- Interceptor(后置):路由 → 控制器 → 全局(反序),可对响应数据做转换(如统一包装
{ code, data, message }) - 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 中间件统一由框架适配错误传播,不要同时
throw又next(error)。 - 响应发送后不要继续调用普通
next()。 - 给中间件写成功、失败、超时和重复回调测试。
Koa 的 await next() 也不能调用两次,框架通常会直接报错;洋葱模型并不会自动消除控制流错误。
Q36: Node.js 如何处理高并发?
答案:
Node.js 通过以下机制处理高并发:
- 事件循环:单线程轮询处理事件
- 非阻塞 I/O:I/O 操作异步执行,不阻塞主线程
- libuv 线程池:文件 I/O 等操作在后台线程执行
- 事件驱动:通过回调/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 通过主进程监听端口,将连接分发给工作进程:
- 端口共享:主进程监听端口
- 连接分发:新连接到达时,主进程选择一个工作进程处理
- 负载均衡:默认使用轮询(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 有什么区别?
答案:
| 特性 | 内置 fetch | node-fetch |
|---|---|---|
| 安装 | 无需安装 | 需要安装 |
| API | Web 标准 | 接近标准 |
| 依赖 | 内置 | 外部依赖 |
| Node 版本 | 18+ | 任意版本 |
| 底层 | Undici | http 模块 |
// 内置 fetch(Node 18+)
const res = await fetch(url);
// 向后兼容
const fetch = globalThis.fetch || (await import('node-fetch')).default;
Q51: NestJS 的依赖注入是怎么实现的?三种注入作用域有什么区别?
答案:
NestJS 的依赖注入基于 TypeScript 的装饰器元数据反射(reflect-metadata)实现:
@Injectable()装饰器会在编译时将类的构造函数参数类型记录为元数据- 应用启动时,IoC 容器扫描所有 Module,收集 providers
- 当需要实例化一个 Provider 时,容器读取其构造函数参数的类型元数据,递归解析并注入依赖
- 默认创建单例并缓存
// 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 使用线性数组匹配路由();Fastify 使用 find-my-way 基于 Radix Tree(前缀树)的路由查找(,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 条件导出中的 import、require 和 default 应怎样设计?
答案:
包可以在 exports 中为不同加载方式提供入口。Node 会按匹配条件和对象声明顺序选择目标,因此通常把更具体的 import、require、node 等条件放在 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_threads | Web Workers |
|---|---|---|
| 环境 | Node.js | 浏览器 |
| 共享内存 | SharedArrayBuffer + Atomics | SharedArrayBuffer(需 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();