Skip to content

Node.js + Express 面试题 ​

生成时间:2026-06-06 21:06 | 共 10 题


1. Node.js 事件循环的六个阶段分别是什么?每个阶段的职责是什么? ​

难度: ⭐⭐⭐

标准答案:

Node.js 事件循环分为六个阶段,按顺序循环执行:

  1. timers — 执行 setTimeout 和 setInterval 的回调
  2. pending callbacks — 执行系统级回调(如 TCP 错误回调),延迟到下一次循环的 I/O 回调
  3. idle, prepare — 仅内部使用,开发者无需关注
  4. poll — 检索新的 I/O 事件,执行 I/O 相关回调;若无回调且无 timer,则阻塞等待
  5. check — 执行 setImmediate 回调
  6. 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.exportsthis === 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)机制是如何工作的? ​

难度: ⭐⭐⭐

标准答案:

四种类型:

  1. Readable — 可读流(如 fs.createReadStream)
  2. Writable — 可写流(如 fs.createWriteStream)
  3. Duplex — 双工流,读写独立(如 net.Socket)
  4. 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) => {},内部维护一个栈结构(数组),按注册顺序依次执行:

  1. 请求进入时,从栈顶取出第一个中间件执行
  2. 中间件内部调用 next() 将控制权交给下一个中间件
  3. 如果不调用 next(),请求-响应周期终止
  4. next('route') 跳过当前路由的剩余中间件
  5. next(err) 跳过所有普通中间件,进入错误处理中间件(4 参数函数)

可配置中间件的实现模式(闭包 + 工厂函数):

javascript
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 内部匹配
javascript
const userRouter = express.Router();
userRouter.get('/', listUsers);
userRouter.get('/:id', getUser);
app.use('/users', userRouter);

延伸思考: 路由匹配是正则还是前缀树?Express 5 的路由匹配有什么改进?如何设计一个支持参数约束和正则约束的路由?


7. Express 中如何正确处理异步错误?同步错误和异步错误的处理方式有何不同? ​

难度: ⭐⭐⭐

标准答案:

同步错误:Express 自动捕获,传递给错误处理中间件。

javascript
app.get('/sync-error', (req, res) => {
  throw new Error('sync'); // ✅ 自动被错误中间件捕获
});

异步错误:Express 4 默认不捕获,需要手动处理:

javascript
// ❌ 错误写法:异步错误不会被捕获,进程直接崩溃
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?有哪些常见的坑? ​

难度: ⭐⭐

标准答案:

集成步骤:

  1. 安装依赖:npm i -D typescript @types/node @types/express ts-node
  2. 初始化配置:npx tsc --init
  3. 关键 tsconfig.json 配置:
json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "strict": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "skipLibCheck": true,
    "sourceMap": true
  },
  "include": ["src/**/*"]
}
  1. 开发环境:ts-node-dev --respawn src/index.ts(热重载)
  2. 生产构建:tsc && node dist/index.js

常见坑:

  • Express 类型扩展:自定义 Request 属性需要声明合并

    typescript
    declare 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