Node.js + Express 面试题
生成时间:2026-06-06 20:46 | 共 10 题
1. 请详细描述 Node.js 事件循环的六个阶段,以及各阶段的执行顺序
难度: ⭐⭐⭐
标准答案:
Node.js 事件循环分为六个主要阶段,按顺序循环执行:
- timers:执行
setTimeout和setInterval到期的回调。 - pending callbacks:执行上一轮循环推迟到下一轮的 I/O 回调(如 TCP 错误回调)。
- idle, prepare:仅内部使用,开发者通常不接触。
- poll:检索新的 I/O 事件,执行 I/O 相关回调。如果队列为空:
- 有
setImmediate回调等待 → 进入 check 阶段 - 无
setImmediate→ 阻塞等待回调加入队列
- 有
- check:执行
setImmediate回调。 - close callbacks:执行
socket.on('close', ...)等关闭事件回调。
每个阶段执行完毕后,会检查 microtask 队列(Promise.then、process.nextTick),其中 process.nextTick 优先级高于 Promise。
延伸思考: process.nextTick 与 setImmediate 的区别是什么?为什么官方建议尽量用 setImmediate 替代 process.nextTick?
2. Node.js 的异步 I/O 是如何实现的?libuv 在其中扮演什么角色?
难度: ⭐⭐⭐
标准答案:
Node.js 的异步 I/O 采用 多线程 + 事件循环 的混合模型:
- JavaScript 主线程:单线程执行业务代码,发起 I/O 请求后立即返回,不阻塞。
- libuv 线程池:默认 4 个线程(可通过
UV_THREADPOOL_SIZE调整,最大 128),处理文件 I/O、DNS 查询等没有原生异步支持的操作系统操作。 - 操作系统异步 I/O:网络 I/O(epoll/kqueue/IOCP)由操作系统原生提供异步接口,libuv 直接调用,不经过线程池。
工作流程:
- JavaScript 调用 Node.js C++ 绑定层
- C++ 层调用 libuv 接口发起 I/O
- libuv 根据操作类型选择操作系统异步接口或线程池
- I/O 完成后,libuv 将回调加入事件循环的对应阶段
- 事件循环在 poll 阶段执行回调,结果返回 JavaScript 层
延伸思考: 如果所有文件 I/O 都走线程池,当并发文件操作超过线程池大小时会发生什么?如何优化?
3. CommonJS 与 ES Modules 在 Node.js 中的核心差异是什么?如何在一个项目中混用?
难度: ⭐⭐
标准答案:
| 特性 | CommonJS (CJS) | ES Modules (ESM) |
|---|---|---|
| 语法 | require() / module.exports | import / export |
| 加载方式 | 同步(运行时加载) | 异步(静态解析 + 异步加载) |
| 加载时机 | 代码执行到 require() 时加载 | 编译时静态分析依赖 |
| 值引用 | 值拷贝(基本类型)/ 引用拷贝 | 绑定(live binding,导出方修改可同步) |
| Tree-shaking | 不支持 | 支持(静态分析可确定未使用导出) |
顶层 this | module.exports | undefined |
__dirname | 自动可用 | 需从 import.meta.url 派生 |
混用方式:
.mjs文件默认 ESM,.cjs文件默认 CJSpackage.json中"type": "module"使.js默认 ESM- CJS 中可用
import()动态导入 ESM 模块 - ESM 中不能用
require(),但可通过createRequire桥接:jsimport { createRequire } from 'module'; const require = createRequire(import.meta.url);
延伸思考: 为什么 ESM 的 live binding 设计对循环依赖更友好?CJS 的循环依赖会导致什么问题?
4. Node.js 中 Stream 和 Buffer 的关系是什么?Stream 有哪四种类型?背压(backpressure)如何解决?
难度: ⭐⭐
标准答案:
Buffer 是 Node.js 中处理二进制数据的核心类,在堆外内存(V8 堆之外)分配,避免 GC 压力。所有文件 I/O、网络 I/O 底层都通过 Buffer 传递原始字节。
Stream 是基于事件的流式数据处理抽象,四种类型:
| 类型 | 描述 | 典型场景 |
|---|---|---|
| Readable | 可读流 | fs.createReadStream、HTTP 请求体 |
| Writable | 可写流 | fs.createWriteStream、HTTP 响应 |
| Duplex | 双工流(读写独立) | TCP Socket、net.Socket |
| Transform | 变换流(读入→变换→写出) | zlib.createGzip()、加密流 |
背压(Backpressure): 当可读流生产数据速度 > 可写流消费速度时,内部缓冲区会膨胀,可能导致内存溢出。解决方案:
pipe()自动处理背压:当可写流write()返回false,可读流自动调用pause();drain 事件触发后恢复resume()- 手动处理:监听
drain事件控制流速 - 推荐使用
pipeline()(stream/promises),比pipe()更安全,自动处理错误和资源清理
import { pipeline } from 'stream/promises';
await pipeline(
fs.createReadStream('input.gz'),
zlib.createGunzip(),
fs.createWriteStream('output.txt')
);延伸思考: 为什么 pipeline 比 pipe 更安全?如果不处理背压,具体会造成什么后果?
5. Express 中间件的执行原理是什么?如何实现一个可配置的中间件?
难度: ⭐⭐
标准答案:
Express 中间件本质是一个函数 (req, res, next) => {},核心执行原理基于 洋葱模型(简化版):
- 内部维护一个 中间件栈(stack 数组)
- 每个请求到来时,按注册顺序依次执行栈中中间件
- 调用
next()将控制权传递给下一个中间件 - 不调用
next()则请求链终止(响应已发出或请求被挂起) next('route')跳过当前路由剩余中间件next(err)跳过所有普通中间件,直接进入错误处理中间件(4 参数函数)
实现可配置中间件:
function rateLimit(options = {}) {
const {
windowMs = 60_000,
max = 100,
message = 'Too many requests'
} = options;
const hits = new Map();
return (req, res, next) => {
const key = req.ip;
const now = Date.now();
const record = hits.get(key) || { count: 0, startTime: now };
if (now - record.startTime > windowMs) {
record.count = 0;
record.startTime = now;
}
record.count++;
hits.set(key, record);
if (record.count > max) {
return res.status(429).json({ error: message });
}
next();
};
}
// 使用
app.use(rateLimit({ windowMs: 60_000, max: 50 }));延伸思考: Express 中间件与 Koa 洋葱模型的核心区别是什么?为什么 Koa 能在中间件执行后"回头"执行下游逻辑?
6. Express 的路由机制是如何实现的?express.Router() 的作用和实现原理是什么?
难度: ⭐⭐
标准答案:
Express 路由底层基于 path-to-regexp 库,将路径模式(如 /users/:id)编译为正则表达式,用于匹配请求路径并提取参数。
路由匹配流程:
- 请求到来,遍历中间件栈
- 遇到路由(
Route实例),用编译好的正则匹配req.path - 匹配成功 → 提取参数(
req.params),执行路由的中间件栈 - 匹配失败 →
next()继续下一个
express.Router() 原理:
Router本质是一个 迷你 Express 实例,拥有独立的中间件栈和路由表- 它是一个中间件函数,同时也是一个挂载点
- 挂载时使用
app.use('/api', router),/api成为该 Router 的 路由前缀 - Router 内部路径相对于前缀匹配,实现路由模块化
// routes/users.js
const router = express.Router();
router.get('/:id', (req, res) => { /* ... */ });
router.put('/:id', (req, res) => { /* ... */ });
module.exports = router;
// app.js
const users = require('./routes/users');
app.use('/users', users); // /users/:id 匹配延伸思考: 如果同一个路径注册了多个路由处理器,Express 如何决定执行哪个?路由参数和正则匹配的优先级关系是什么?
7. Express 中如何正确处理同步异常和异步异常?有哪些最佳实践?
难度: ⭐⭐⭐
标准答案:
同步异常:Express 4.x 能自动捕获,将 error 传给错误处理中间件。
异步异常(重点坑):
- 回调风格:必须调用
next(err)传递错误,否则进程崩溃 - Promise / async-await:Express 4.x 不会自动捕获未处理的 Promise rejection,必须手动
try/catch+next(err) - Express 5.x 原生支持自动捕获 async 错误
最佳实践:
// 1. 全局兜底:监听 unhandledRejection
process.on('unhandledRejection', (err) => {
console.error('Unhandled Rejection:', err);
// 可选:process.exit(1)
});
// 2. 包装 async 中间件
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/data', asyncHandler(async (req, res) => {
const data = await fetchData(); // 抛出异常会被 catch(next) 捕获
res.json(data);
}));
// 3. 错误处理中间件(必须 4 个参数,放在最后)
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({
error: err.message || 'Internal Server Error'
});
});
// 4. 404 处理(放在路由之后、错误处理之前)
app.use((req, res) => {
res.status(404).json({ error: 'Not Found' });
});延伸思考: 为什么 Express 4.x 不能自动捕获 async 错误?从 V8 事件循环的角度解释这个设计限制。
8. Node.js + Express 应用有哪些关键的性能优化手段?
难度: ⭐⭐⭐
标准答案:
应用层优化:
- 集群模式:
cluster模块或 PM2 cluster 模式,充分利用多核 CPU - 中间件精简:移除不必要的中间件,按路由粒度挂载而非全局挂载
- 路由优化:将高频路由注册在前面,减少匹配次数
- 压缩响应:
compression中间件启用 gzip/brotli - 静态资源:Nginx 托管静态资源 + CDN,不走 Node.js
I/O 与内存优化:
- 流式处理:大文件用 Stream +
pipeline,避免fs.readFile全量加载 - 连接池:数据库/Redis 使用连接池,避免频繁建连
- 缓存策略:Redis 缓存热点数据,HTTP 缓存头(ETag/Cache-Control)
- 避免内存泄漏:注意闭包、全局变量、事件监听器未移除
运行时优化:
- JIT 预热:生产环境预热 V8 优化编译
- UV_THREADPOOL_SIZE:默认 4 线程,I/O 密集型可增大至 128
--max-old-space-size:调大 V8 堆内存上限(默认 ~1.5GB)
监控:
clinic.js/0x火焰图分析process.memoryUsage()监控内存- APM 工具(New Relic、Datadog)
延伸思考: cluster 模式下多个进程如何共享端口?master 进程在此模型中的职责是什么?
9. Node.js + Express 应用的生产部署策略有哪些?如何实现零停机部署?
难度: ⭐⭐
标准答案:
部署架构:
客户端 → CDN/Nginx(反向代理 + 负载均衡) → Node.js 集群 → 数据库/缓存进程管理:
- PM2:最常用的 Node.js 进程管理器,支持集群、自动重启、日志管理
- Systemd:Linux 原生服务管理,配合
node直接运行 - Docker:容器化部署,配合 K8s 编排
零停机部署方案:
PM2 热重载:
bashpm2 reload app # 逐个重启 worker,保持服务可用蓝绿部署:两套环境交替上线,Nginx 切换 upstream
滚动更新(K8s):
yamlspec: strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 maxSurge: 1
关键配置:
- Nginx:
proxy_next_upstream容错、keepalive长连接池 - 健康检查端点:
/healthz供负载均衡器探活 - 优雅关闭:监听
SIGTERM,停止接收新请求,等待现有请求完成jsprocess.on('SIGTERM', () => { server.close(() => process.exit(0)); setTimeout(() => process.exit(1), 10_000); // 超时强制退出 });
延伸思考: 在 K8s 滚动更新中,如果 Pod 还在处理请求就被终止,如何保证请求不丢失?preStop 钩子的作用是什么?
10. 如何在 Express 项目中集成 TypeScript?有哪些常见陷阱和最佳实践?
难度: ⭐⭐
标准答案:
基础集成步骤:
初始化 TypeScript:
bashnpm i -D typescript @types/node @types/express ts-node npx tsc --inittsconfig.json关键配置:json{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "resolveJsonModule": true }, "include": ["src"] }类型安全的路由写法:
tsimport { Request, Response, NextFunction, Router } from 'express'; interface AuthRequest extends Request { user?: { id: string; role: string }; } const router = Router(); router.get('/profile', (req: AuthRequest, res: Response, next: NextFunction) => { res.json(req.user); });开发环境:
ts-node-dev或tsx热重载生产构建:
tsc编译 +node dist/index.js运行
常见陷阱:
- 类型不安全的
req.body:必须配合zod/joi做运行时校验,TypeScript 类型只在编译时有效 - 中间件类型丢失:Express 中间件链的
req类型不会自动传递扩展属性,需用declare global或泛型 import与require混用:module: "NodeNext"下.js后缀必须写对- 忽略
strict: true:未开启严格模式会放过大量类型问题 @types/xxx版本不匹配:大版本差异导致类型定义缺失或冲突
最佳实践:
- 使用
zod定义 schema → 同时生成 TypeScript 类型和运行时校验 - 错误处理中间件用自定义
AppError类,携带statusCode和code - 使用
tsc --noEmit在 CI 中做纯类型检查 - 生产不用
ts-node,先编译再运行
延伸思考: 为什么"编译时类型安全"不等于"运行时安全"?在实际项目中,如何平衡类型严格度和开发效率?