Node.js + Express 面试题
生成时间:2026-06-06 21:29 | 共 10 题
1. 请详细描述 Node.js 事件循环的六个阶段,以及每个阶段的职责
难度: ⭐⭐⭐
标准答案:
Node.js 事件循环分为六个阶段,按顺序循环执行:
- timers:执行
setTimeout和setInterval到期的回调 - pending callbacks:执行系统级回调(如 TCP 错误回调),推迟到下一轮循环的 I/O 回调
- idle, prepare:仅内部使用,开发者无需关心
- poll:检索新的 I/O 事件,执行 I/O 相关回调。如果队列为空:
- 有
setImmediate回调则进入 check 阶段 - 无
setImmediate则阻塞等待回调加入队列
- 有
- check:执行
setImmediate回调 - close callbacks:执行
socket.on('close', ...)等关闭事件回调
每个阶段执行时,会将该阶段队列中的回调全部执行完或达到系统限制后,才进入下一阶段。process.nextTick 和 Promise 微任务在各阶段切换间隙执行,且 nextTick 优先级高于 Promise。
延伸思考: 为什么 setTimeout(fn, 0) 和 setImmediate(fn) 在不同场景下执行顺序不一致?在 I/O 回调中 setImmediate 一定先于 setTimeout 执行,而在主模块中顺序不确定,原因是什么?
2. Node.js 的异步 I/O 是如何实现的?libuv 在其中扮演什么角色?
难度: ⭐⭐⭐
标准答案:
Node.js 异步 I/O 的核心架构:
- 应用层:JavaScript 通过
fs.readFile等 API 发起异步调用 - 桥接层:Node.js C++ 绑定(
node_file.cc)将 JS 调用转为 C++ 层请求,通过uv_fs_t结构体封装 - libuv 层:跨平台异步 I/O 库,负责:
- 在 Linux 上使用
epoll,macOS 上使用kqueue,Windows 上使用IOCP - 维护事件循环,管理文件描述符和回调队列
- 线程池(默认 4 线程)处理不支持异步的文件 I/O 和 DNS 解析
- 在 Linux 上使用
- 回调执行:I/O 完成后,libuv 将回调加入事件循环的 poll 阶段队列
关键点:网络 I/O 由操作系统原生异步支持,不经过线程池;文件 I/O 因操作系统缺乏原生异步 API,必须通过 libuv 线程池模拟。线程池大小可通过 UV_THREADPOOL_SIZE 环境变量调整(最大 128)。
延伸思考: 如果所有线程池线程都被长时间的文件 I/O 占满,会对网络请求产生什么影响?如何通过架构设计避免这种问题?
3. Node.js 模块机制:CommonJS 与 ES Modules 有什么区别?如何在一个项目中安全地混用?
难度: ⭐⭐
标准答案:
| 特性 | CommonJS (CJS) | ES Modules (ESM) |
|---|---|---|
| 语法 | require() / module.exports | import / export |
| 加载方式 | 同步,运行时加载 | 异步,编译时静态分析 |
| 输出 | 值的拷贝(可修改) | 值的引用(只读实时绑定) |
| this 指向 | module.exports | undefined |
| 循环依赖 | 返回已执行部分的快照 | 返回引用,但可能未初始化 |
| Tree-shaking | 不支持 | 支持 |
安全混用方案:
package.json设置"type": "module"开启 ESM,CJS 文件改用.cjs后缀- ESM 中导入 CJS:使用
import pkg from './pkg.cjs'(Node 自动将module.exports作为 default 导出) - CJS 中导入 ESM:必须使用动态
import()(异步),不能用require() - 推荐策略:新项目统一 ESM,旧项目渐进迁移
延伸思考: 为什么 CJS 中不能直接 require() 一个 ESM 模块?这与 Node.js 的模块解析机制有什么本质联系?
4. 请解释 Node.js 中 Stream 和 Buffer 的关系,以及四种 Stream 类型的使用场景
难度: ⭐⭐
标准答案:
Buffer:固定大小的原始二进制数据缓冲区,是 Node.js 处理二进制数据的基础单元。在 V8 堆外分配内存,不可调整大小。
Stream:基于事件的流式数据处理接口,核心思想是"边读边处理",避免一次性加载大文件到内存。Stream 内部使用 Buffer 作为数据块(chunk)的载体。
四种 Stream 类型:
| 类型 | 描述 | 典型场景 |
|---|---|---|
| Readable | 可读流,数据的来源 | fs.createReadStream、HTTP 请求体、process.stdin |
| Writable | 可写流,数据的目的地 | fs.createWriteStream、HTTP 响应体、process.stdout |
| Duplex | 双工流,可读可写(独立缓冲区) | TCP Socket、net.Socket |
| Transform | 转换流,读入数据经变换后输出 | zlib.createGzip()、crypto.createCipheriv() |
核心模式:
- 流动模式(Flowing):数据自动推送,通过
.on('data')消费 - 暂停模式(Paused):需显式调用
.read()读取 - 使用
.pipe()可连接可读流和可写流,自动处理背压(backpressure)
延伸思考: 什么是背压(backpressure)机制?如果消费速度远低于生产速度,Node.js Stream 是如何避免内存溢出的?
5. Express 中间件的执行原理是什么?如何实现一个可配置的中间件?
难度: ⭐⭐
标准答案:
Express 中间件本质是一个函数 (req, res, next) => {},核心原理是洋葱模型的简化版——栈式顺序执行:
app.use()或app.METHOD()将中间件推入router.stack数组- 请求到达时,Router 按注册顺序逐个调用
layer.handle_request() - 每个中间件调用
next()后,将控制权传递给下一个匹配的中间件 - 如果不调用
next(),请求处理链终止 next('route')跳过当前路由剩余中间件next(err)跳到错误处理中间件(4 参数函数)
可配置中间件实现模式:
function rateLimit(options = {}) {
const { windowMs = 60000, max = 100 } = 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: 'Too many requests' });
}
next();
};
}
// 使用
app.use(rateLimit({ windowMs: 60_000, max: 50 }));延伸思考: Express 的中间件模型与 Koa 的洋葱模型有什么区别?为什么 Koa 选择 async/await + 洋葱模型,而 Express 选择回调 + 线性栈?这影响了哪些功能的设计?
6. Express 路由机制的内幕:app.get() 和 router.get() 有什么区别?
难度: ⭐⭐
标准答案:
app.get():在应用根级别注册路由,直接绑定到 app._router。
router.get():在独立的 Router 实例上注册路由,实现模块化路由管理。
底层实现:
app本质上委托给内部的Router实例(懒初始化),app.get('/path', fn)等价于app._router.get('/path', fn)- 每个
Router实例维护独立的stack(Layer 数组) app.use('/api', router)将 router 作为 Layer 压入 app 的 stack,请求匹配/api前缀后,剩余路径交给 router 处理- Layer 对象包含
regexp(路径正则)、handle(中间件函数)、keys(路径参数)
路由匹配流程:
- 请求进入 →
router.handle(req, res, out) - 遍历
stack,用layer.match(path)匹配 - 匹配成功 → 提取
params,执行layer.handle_request(req, res, next) - 不匹配 →
next()继续
关键区别:app.use('/api', router) 的路径是"前缀匹配",而 app.get('/api', ...) 是"精确匹配"。
延伸思考: 如果同时注册了 app.use('/api', routerA) 和 app.use('/api', routerB),请求 /api/users 会怎么匹配?执行顺序如何?如何避免路由冲突?
7. Express 的错误处理最佳实践是什么?如何实现全局错误处理?
难度: ⭐⭐⭐
标准答案:
Express 错误处理分层策略:
第一层:同步错误 —— Express 自动捕获
app.get('/sync-error', (req, res) => {
throw new Error('同步错误会被Express捕获');
});第二层:异步错误 —— 必须手动传递给 next()
app.get('/async-error', async (req, res, next) => {
try {
await riskyOperation();
} catch (err) {
next(err); // 必须!不调用 next(err) 会导致请求挂起
}
});第三层:全局错误中间件(4 参数函数,必须最后注册)
app.use((err, req, res, next) => {
console.error(err.stack);
const status = err.status || 500;
const message = process.env.NODE_ENV === 'production'
? 'Internal Server Error'
: err.message;
res.status(status).json({ error: message });
});第四层:未捕获异常和未处理的 Promise 拒绝
process.on('uncaughtException', (err) => {
logger.fatal('Uncaught Exception', err);
process.exit(1); // 必须退出!进程可能处于不稳定状态
});
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled Rejection', reason);
// 可选:优雅关闭后重启
});Express 5.0 改进:自动捕获 async 函数中抛出的错误,不再需要手动 try/catch + next(err)。
延伸思考: 为什么 uncaughtException 后必须退出进程?在什么场景下可以不退出?如何实现优雅关闭(graceful shutdown)?
8. Node.js + Express 生产环境性能优化有哪些关键手段?
难度: ⭐⭐⭐
标准答案:
1. 进程层面
- Cluster 模式:利用多核 CPU,
cluster.fork()创建多个工作进程 - PM2:生产级进程管理器,支持集群模式、自动重启、负载均衡
- 建议工作进程数 = CPU 核心数(或 CPU 核心数 - 1,留一个给主进程)
2. 请求层面
- 反向代理:Nginx 处理 SSL 终止、静态文件、Gzip 压缩,Node.js 专注业务
- Keep-Alive:启用 HTTP Keep-Alive 减少连接建立开销
- 压缩中间件:
compression()中间件启用 Gzip/Brotli
3. 缓存层面
- 内存缓存:
node-cache、lru-cache存热点数据 - Redis 缓存:分布式缓存 + Session 存储
- HTTP 缓存:设置
Cache-Control、ETag头
4. 代码层面
- 避免同步操作(
fs.readFileSync等)阻塞事件循环 - 使用 Stream 处理大文件(避免
Buffer.concat大量数据) - 监控事件循环延迟:
require('perf_hooks').monitorEventLoopDelay()
5. 监控层面
- APM 工具:New Relic、Datadog、Prometheus + Grafana
- 健康检查端点:
/healthz返回进程状态 - 内存泄漏检测:
heapdump+ Chrome DevTools 分析
延伸思考: Cluster 模式下多个工作进程共享同一个端口,底层是如何实现的?为什么 PM2 推荐使用 Cluster 模式而不是 Docker 水平扩展?
9. Node.js + Express 的生产部署策略有哪些?如何实现零停机部署?
难度: ⭐⭐
标准答案:
部署架构选择:
| 方案 | 适用场景 | 特点 |
|---|---|---|
| PM2 + Nginx | 单机部署 | 简单、低成本、中小规模 |
| Docker + Docker Compose | 中等规模 | 环境一致性、易扩展 |
| Kubernetes | 大规模分布式 | 自动伸缩、滚动更新、服务发现 |
| Serverless (AWS Lambda) | 低频/突发流量 | 按需付费、免运维 |
零停机部署方案:
PM2 滚动重启
bashpm2 reload app --update-env # 逐个重启工作进程Docker 滚动更新
yaml# docker-compose.yml deploy: update_config: order: start-first # 先启动新容器再停旧容器 parallelism: 1 delay: 10sKubernetes Rolling Update
yamlspec: strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 # 最多多启动1个Pod maxUnavailable: 0 # 不允许不可用优雅关闭关键代码
javascriptconst server = app.listen(3000); process.on('SIGTERM', () => { logger.info('SIGTERM received, shutting down gracefully'); server.close(() => { // 停止接收新连接 db.disconnect(); // 关闭数据库连接 process.exit(0); }); setTimeout(() => process.exit(1), 30000); // 强制退出超时 });Nginx 配合:健康检查失败时自动摘除节点,部署完成后自动恢复
延伸思考: 在 Kubernetes 滚动更新时,为什么 readinessProbe 比 livenessProbe 更重要?如何防止新版本 Pod 在完全就绪前就接收流量?
10. 如何在 Express 项目中集成 TypeScript?有哪些关键配置和最佳实践?
难度: ⭐⭐
标准答案:
1. 项目初始化
npm init -y
npm install express
npm install -D typescript @types/express @types/node tsx
npx tsc --init2. tsconfig.json 关键配置
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}3. 类型安全的请求/响应
import { Request, Response, NextFunction } from 'express';
// 自定义请求类型
interface AuthRequest extends Request {
user?: { id: string; role: string };
}
// 类型安全中间件
const authMiddleware = (
req: AuthRequest,
res: Response,
next: NextFunction
) => {
req.user = { id: '1', role: 'admin' };
next();
};
// 路由处理函数
app.get('/profile', authMiddleware, (req: AuthRequest, res: Response) => {
res.json({ userId: req.user?.id });
});4. 开发与构建脚本
{
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}5. 最佳实践
- 使用
zod或joi进行运行时请求验证(TypeScript 只提供编译时类型安全) - 避免使用
any,用unknown替代并逐步收窄 - 使用
Controller模式组织路由逻辑,避免app.get()散落各处 - 错误类继承
Error并添加status字段,配合全局错误中间件
延伸思考: 为什么仅有 TypeScript 的编译时类型检查不够?在 API 边界处,运行时验证为什么同样不可或缺?zod 的 infer 功能如何同时解决这两个问题?
📋 总结:以上 10 题覆盖了 Node.js + Express 的核心知识面,从底层原理(事件循环、异步 I/O、模块机制)到工程实践(中间件、路由、错误处理、性能、部署、TypeScript),建议结合实际项目经验深入理解每个知识点。