Node.js + Express 面试题
生成时间:2026-06-06 21:06 | 共 10 题
1. Node.js 事件循环的六个阶段分别是什么?每个阶段的职责是什么?
难度: ⭐⭐⭐
标准答案:
Node.js 事件循环分为六个阶段,按顺序循环执行:
- timers — 执行
setTimeout和setInterval的回调 - pending callbacks — 执行系统级回调(如 TCP 错误回调),延迟到下一次循环的 I/O 回调
- idle, prepare — 仅内部使用,开发者无需关注
- poll — 检索新的 I/O 事件,执行 I/O 相关回调;若无回调且无 timer,则阻塞等待
- check — 执行
setImmediate回调 - close callbacks — 执行
socket.on('close', ...)等关闭事件回调
每个阶段都有一个 FIFO 队列,当前阶段队列清空或达到最大回调数后进入下一阶段。在两个阶段之间还会检查 process.nextTick 和微任务(Promise),优先执行。
延伸思考: process.nextTick 和 setImmediate 的执行时机差异是什么?为什么 process.nextTick 不在事件循环的任何阶段中?在什么场景下 nextTick 会导致 I/O 饥饿?
2. Node.js 的异步 I/O 是如何实现的?libuv 在其中扮演什么角色?
难度: ⭐⭐⭐
标准答案:
Node.js 的异步 I/O 采用多线程 + 事件循环的架构:
- JavaScript 层:调用
fs.readFile()等 API,将请求参数和回调封装为请求对象,推入线程池队列 - libuv 层:维护一个默认 4 线程的线程池(可通过
UV_THREADPOOL_SIZE调整为最多 128),负责执行文件 I/O、DNS 解析等阻塞操作;网络 I/O 则由 libuv 直接委托给操作系统的 epoll/kqueue/IOCP 完成,不占用线程池 - 通知机制:线程池完成操作后,通过事件通知(Windows 的 IOCP 或 Unix 的 eventfd/pipe)唤醒事件循环,在 poll 阶段执行对应回调
关键点:网络 I/O 是非阻塞的,直接由操作系统内核驱动;文件 I/O 是阻塞的,只能通过线程池模拟异步。
延伸思考: 为什么 Node.js 不像 Go 那样用绿色线程来实现并发?UV_THREADPOOL_SIZE 设过大会有什么副作用?如果所有线程池线程都被长时间文件操作占用,网络请求会受影响吗?
3. CommonJS 和 ES Modules 在 Node.js 中有什么核心差异?如何在一个项目中混用?
难度: ⭐⭐
标准答案:
核心差异:
| 特性 | CommonJS (CJS) | ES Modules (ESM) |
|---|---|---|
| 加载时机 | 运行时同步加载(require) | 编译时静态分析(import) |
| 值类型 | 值的拷贝(浅拷贝) | 值的引用(实时绑定) |
| this 指向 | this === module.exports | this === undefined |
| 顶层 await | 不支持 | 支持 |
| 循环依赖 | 返回已执行部分的快照 | 返回引用但可能未初始化 |
| Tree-shaking | 不支持(动态加载无法静态分析) | 支持 |
混用方式:
- 在
package.json中设置"type": "module"后,.js默认为 ESM;.cjs为 CJS - ESM 中导入 CJS:
import { foo } from './utils.cjs'(Node 自动桥接) - CJS 中导入 ESM:只能用动态
import(),不能require()ESM 文件
延伸思考: 为什么 CJS 的循环依赖容易产生"半初始化"问题?ESM 的 live binding 是如何缓解这个问题的?在大型项目中全面迁移到 ESM 的主要阻碍是什么?
4. Node.js Stream 有哪四种类型?背压(Backpressure)机制是如何工作的?
难度: ⭐⭐⭐
标准答案:
四种类型:
- Readable — 可读流(如
fs.createReadStream) - Writable — 可写流(如
fs.createWriteStream) - Duplex — 双工流,读写独立(如
net.Socket) - Transform — 变换流,读写关联(如
zlib.createGzip)
背压机制:
当 Writable 的写入速度慢于 Readable 的读取速度时,stream.write() 返回 false,Readable 应调用 stream.pause() 暂停读取;当 Writable 排空内部缓冲区后触发 drain 事件,Readable 调用 stream.resume() 恢复读取。
使用 stream.pipe() 或 stream.pipeline() 会自动处理背压。pipeline() 更优,因为它还会正确处理错误传播和流清理。
pipeline(readable, transform, writable, (err) => { ... })延伸思考: 为什么 pipe() 不推荐在生产环境使用?highWaterMark 参数如何影响背压的触发时机?如果忽略背压直接 write(),会有什么后果?
5. Express 中间件的执行原理是什么?如何实现一个可配置的中间件?
难度: ⭐⭐
标准答案:
Express 中间件本质是一个函数 (req, res, next) => {},内部维护一个栈结构(数组),按注册顺序依次执行:
- 请求进入时,从栈顶取出第一个中间件执行
- 中间件内部调用
next()将控制权交给下一个中间件 - 如果不调用
next(),请求-响应周期终止 next('route')跳过当前路由的剩余中间件next(err)跳过所有普通中间件,进入错误处理中间件(4 参数函数)
可配置中间件的实现模式(闭包 + 工厂函数):
function rateLimit({ windowMs = 60000, max = 100 } = {}) {
const hits = new Map();
return (req, res, next) => {
const key = req.ip;
const now = Date.now();
const record = hits.get(key) || { count: 0, start: now };
if (now - record.start > windowMs) {
record.count = 0;
record.start = now;
}
if (++record.count > max) {
return res.status(429).json({ error: 'Too many requests' });
}
hits.set(key, record);
next();
};
}
app.use(rateLimit({ windowMs: 60000, max: 50 }));延伸思考: Express 中间件模型和 Koa 的洋葱模型有什么本质区别?为什么 Express 的错误处理中间件必须放在最后?如何实现中间件的条件跳过?
6. Express 路由机制的核心实现是什么?express.Router() 和直接挂载路由有什么区别?
难度: ⭐⭐
标准答案:
Express 路由核心是 Layer + Route 双层结构:
- Layer:每个中间件/路由注册时创建一个 Layer 对象,包含路径匹配规则(正则)和处理函数
- Route:一个路由(如
app.get('/users', ...))对应一个 Route 对象,内部也是一个 Layer 栈,存放该路由的所有方法处理器
请求匹配过程:遍历 app._router.stack(Layer 数组),对每个 Layer 进行路径匹配,匹配成功则执行其处理函数。
express.Router() 的区别:
- 创建一个独立的小型应用(mini-app),拥有自己的 Layer 栈、中间件和路由
- 实现路由模块化,不同模块的路由互不干扰
- 支持挂载前缀:
app.use('/api', router)→ router 内所有路由自动加/api前缀 - 延迟匹配:只有在请求路径匹配 Router 挂载前缀时,才会进入 Router 内部匹配
const userRouter = express.Router();
userRouter.get('/', listUsers);
userRouter.get('/:id', getUser);
app.use('/users', userRouter);延伸思考: 路由匹配是正则还是前缀树?Express 5 的路由匹配有什么改进?如何设计一个支持参数约束和正则约束的路由?
7. Express 中如何正确处理异步错误?同步错误和异步错误的处理方式有何不同?
难度: ⭐⭐⭐
标准答案:
同步错误:Express 自动捕获,传递给错误处理中间件。
app.get('/sync-error', (req, res) => {
throw new Error('sync'); // ✅ 自动被错误中间件捕获
});异步错误:Express 4 默认不捕获,需要手动处理:
// ❌ 错误写法:异步错误不会被捕获,进程直接崩溃
app.get('/bad', async (req, res) => {
const data = await fetchData(); // 如果抛错,Express 捕获不到
});
// ✅ 方式1:try-catch + next(err)
app.get('/safe1', async (req, res, next) => {
try {
const data = await fetchData();
res.json(data);
} catch (err) {
next(err);
}
});
// ✅ 方式2:Express 5 自动捕获 async 错误
// Express 5 原生支持 async function 返回的 rejected Promise
// ✅ 方式3:高阶函数包装
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/safe2', asyncHandler(async (req, res) => {
const data = await fetchData();
res.json(data);
}));终极防护:process.on('unhandledRejection', ...) + process.on('uncaughtException', ...) 兜底。
延伸思考: Express 5 为什么能自动捕获 async 错误?它的实现原理是什么?uncaughtException 后继续运行进程有什么风险?应该怎样优雅退出?
8. Node.js + Express 生产环境性能优化有哪些关键手段?
难度: ⭐⭐⭐
标准答案:
应用层优化:
- 集群模式:
cluster模块或 PM2 cluster 模式,充分利用多核 CPU - 连接池:数据库、Redis 使用连接池,避免每次请求创建连接
- 流式响应:大文件/大数据用 Stream 传输,避免内存溢出
- 缓存策略:HTTP 缓存头(ETag/Cache-Control)、Redis 缓存热点数据
- 压缩:
compression中间件启用 gzip/brotli
运行时优化:
- V8 优化:
--max-old-space-size调整堆内存、--optimize-for-size内存敏感场景 - 诊断工具:
--inspect+ Chrome DevTools 定位 CPU 瓶颈和内存泄漏 - UV_THREADPOOL_SIZE:I/O 密集场景调大线程池
基础设施优化:
- 反向代理:Nginx 处理静态资源、SSL 终止、负载均衡
- CDN:静态资源上 CDN,减轻源站压力
- 容器化:Docker + Kubernetes 水平扩展
监控与调优:
- APM 工具(New Relic、Datadog)持续监控
clinic.js生成分析报告(Doctor、Bubbleprof、Flame)- 压测工具(autocannon、wrk)验证优化效果
延伸思考: 为什么 Node.js 单线程模式在 I/O 密集场景表现好,但 CPU 密集场景反而成为瓶颈?Worker Threads 和 cluster 模式各适合什么场景?
9. Node.js + Express 应用有哪些主流部署策略?各自的优劣是什么?
难度: ⭐⭐
标准答案:
| 部署方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| PM2 | 零配置集群、自动重启、日志管理、监控面板 | 单机部署,无内置负载均衡(依赖 cluster) | 中小规模、快速上线 |
| Docker + Compose | 环境一致、可复制、易扩展 | 需要学习 Docker、网络配置较复杂 | 团队协作、CI/CD |
| Kubernetes | 自动扩缩容、滚动更新、服务发现、自愈 | 运维复杂度高、资源开销大 | 大规模微服务 |
| Serverless | 按需付费、零运维、自动扩缩 | 冷启动延迟、运行时间限制、调试困难 | 低频/事件驱动 |
| Systemd + Nginx | 轻量、稳定、资源占用少 | 手动管理扩展和重启 | 单服务器、传统运维 |
关键部署实践:
- 环境变量管理(
dotenv+ 密钥管理服务) - 健康检查端点(
/healthz)供负载均衡器探测 - 优雅关闭(
SIGTERM→ 停止接收新请求 → 排空现有请求 → 退出) - 日志聚合(结构化 JSON 日志 + ELK/Loki)
- 反向代理:Nginx 在前处理 HTTPS、静态资源、限流
延伸思考: Serverless 的冷启动对 Express 应用影响有多大?如何用预热策略缓解?在 Kubernetes 中如何实现零停机滚动更新?
10. 如何在 Express 项目中集成 TypeScript?有哪些常见的坑?
难度: ⭐⭐
标准答案:
集成步骤:
- 安装依赖:
npm i -D typescript @types/node @types/express ts-node - 初始化配置:
npx tsc --init - 关键 tsconfig.json 配置:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"skipLibCheck": true,
"sourceMap": true
},
"include": ["src/**/*"]
}- 开发环境:
ts-node-dev --respawn src/index.ts(热重载) - 生产构建:
tsc && node dist/index.js
常见坑:
Express 类型扩展:自定义
Request属性需要声明合并typescriptdeclare global { namespace Express { interface Request { userId?: string; } } }中间件类型:使用
express.RequestHandler或RequestHandler类型标注,避免req/res丢失类型async 中间件:TypeScript 不会提醒你
next()缺失,需要自定义AsyncRequestHandler类型路径参数:
req.params.id默认是string | undefined,需要断言或校验第三方中间件无类型:安装
@types/xxx,或创建declarations.d.ts用declare module 'xxx'
延伸思考: ts-node 的运行时性能开销有多大?生产环境应该用 tsc 编译还是 ts-node 直接运行?如何在 TypeScript 中实现依赖注入以提升 Express 项目的可测试性?
EOF