Skip to content

Node.js + Express 面试题 ​

生成时间:2026-06-06 21:29 | 共 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 相关回调。如果队列为空:
    • 有 setImmediate 回调则进入 check 阶段
    • 无 setImmediate 则阻塞等待回调加入队列
  5. check:执行 setImmediate 回调
  6. 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 解析
  • 回调执行: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.exportsimport / export
加载方式同步,运行时加载异步,编译时静态分析
输出值的拷贝(可修改)值的引用(只读实时绑定)
this 指向module.exportsundefined
循环依赖返回已执行部分的快照返回引用,但可能未初始化
Tree-shaking不支持支持

安全混用方案:

  1. package.json 设置 "type": "module" 开启 ESM,CJS 文件改用 .cjs 后缀
  2. ESM 中导入 CJS:使用 import pkg from './pkg.cjs'(Node 自动将 module.exports 作为 default 导出)
  3. CJS 中导入 ESM:必须使用动态 import()(异步),不能用 require()
  4. 推荐策略:新项目统一 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) => {},核心原理是洋葱模型的简化版——栈式顺序执行:

  1. app.use() 或 app.METHOD() 将中间件推入 router.stack 数组
  2. 请求到达时,Router 按注册顺序逐个调用 layer.handle_request()
  3. 每个中间件调用 next() 后,将控制权传递给下一个匹配的中间件
  4. 如果不调用 next(),请求处理链终止
  5. next('route') 跳过当前路由剩余中间件
  6. next(err) 跳到错误处理中间件(4 参数函数)

可配置中间件实现模式:

javascript
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(路径参数)

路由匹配流程:

  1. 请求进入 → router.handle(req, res, out)
  2. 遍历 stack,用 layer.match(path) 匹配
  3. 匹配成功 → 提取 params,执行 layer.handle_request(req, res, next)
  4. 不匹配 → next() 继续

关键区别:app.use('/api', router) 的路径是"前缀匹配",而 app.get('/api', ...) 是"精确匹配"。

延伸思考: 如果同时注册了 app.use('/api', routerA) 和 app.use('/api', routerB),请求 /api/users 会怎么匹配?执行顺序如何?如何避免路由冲突?


7. Express 的错误处理最佳实践是什么?如何实现全局错误处理? ​

难度: ⭐⭐⭐

标准答案:

Express 错误处理分层策略:

第一层:同步错误 —— Express 自动捕获

javascript
app.get('/sync-error', (req, res) => {
  throw new Error('同步错误会被Express捕获');
});

第二层:异步错误 —— 必须手动传递给 next()

javascript
app.get('/async-error', async (req, res, next) => {
  try {
    await riskyOperation();
  } catch (err) {
    next(err);  // 必须!不调用 next(err) 会导致请求挂起
  }
});

第三层:全局错误中间件(4 参数函数,必须最后注册)

javascript
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 拒绝

javascript
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)低频/突发流量按需付费、免运维

零停机部署方案:

  1. PM2 滚动重启

    bash
    pm2 reload app --update-env  # 逐个重启工作进程
  2. Docker 滚动更新

    yaml
    # docker-compose.yml
    deploy:
      update_config:
        order: start-first  # 先启动新容器再停旧容器
        parallelism: 1
        delay: 10s
  3. Kubernetes Rolling Update

    yaml
    spec:
      strategy:
        type: RollingUpdate
        rollingUpdate:
          maxSurge: 1        # 最多多启动1个Pod
          maxUnavailable: 0   # 不允许不可用
  4. 优雅关闭关键代码

    javascript
    const 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); // 强制退出超时
    });
  5. Nginx 配合:健康检查失败时自动摘除节点,部署完成后自动恢复

延伸思考: 在 Kubernetes 滚动更新时,为什么 readinessProbe 比 livenessProbe 更重要?如何防止新版本 Pod 在完全就绪前就接收流量?


10. 如何在 Express 项目中集成 TypeScript?有哪些关键配置和最佳实践? ​

难度: ⭐⭐

标准答案:

1. 项目初始化

bash
npm init -y
npm install express
npm install -D typescript @types/express @types/node tsx
npx tsc --init

2. tsconfig.json 关键配置

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. 类型安全的请求/响应

typescript
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. 开发与构建脚本

json
{
  "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),建议结合实际项目经验深入理解每个知识点。