Skip to content

Node.js + Express 面试题 ​

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


1. 请详细描述 Node.js 事件循环的六个阶段,以及各阶段的执行顺序 ​

难度: ⭐⭐⭐

标准答案:

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

  1. timers:执行 setTimeout 和 setInterval 到期的回调。
  2. pending callbacks:执行上一轮循环推迟到下一轮的 I/O 回调(如 TCP 错误回调)。
  3. idle, prepare:仅内部使用,开发者通常不接触。
  4. poll:检索新的 I/O 事件,执行 I/O 相关回调。如果队列为空:
    • 有 setImmediate 回调等待 → 进入 check 阶段
    • 无 setImmediate → 阻塞等待回调加入队列
  5. check:执行 setImmediate 回调。
  6. 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 直接调用,不经过线程池。

工作流程:

  1. JavaScript 调用 Node.js C++ 绑定层
  2. C++ 层调用 libuv 接口发起 I/O
  3. libuv 根据操作类型选择操作系统异步接口或线程池
  4. I/O 完成后,libuv 将回调加入事件循环的对应阶段
  5. 事件循环在 poll 阶段执行回调,结果返回 JavaScript 层

延伸思考: 如果所有文件 I/O 都走线程池,当并发文件操作超过线程池大小时会发生什么?如何优化?


3. CommonJS 与 ES Modules 在 Node.js 中的核心差异是什么?如何在一个项目中混用? ​

难度: ⭐⭐

标准答案:

特性CommonJS (CJS)ES Modules (ESM)
语法require() / module.exportsimport / export
加载方式同步(运行时加载)异步(静态解析 + 异步加载)
加载时机代码执行到 require() 时加载编译时静态分析依赖
值引用值拷贝(基本类型)/ 引用拷贝绑定(live binding,导出方修改可同步)
Tree-shaking不支持支持(静态分析可确定未使用导出)
顶层 thismodule.exportsundefined
__dirname自动可用需从 import.meta.url 派生

混用方式:

  • .mjs 文件默认 ESM,.cjs 文件默认 CJS
  • package.json 中 "type": "module" 使 .js 默认 ESM
  • CJS 中可用 import() 动态导入 ESM 模块
  • ESM 中不能用 require(),但可通过 createRequire 桥接:
    js
    import { 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() 更安全,自动处理错误和资源清理
js
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 参数函数)

实现可配置中间件:

js
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)编译为正则表达式,用于匹配请求路径并提取参数。

路由匹配流程:

  1. 请求到来,遍历中间件栈
  2. 遇到路由(Route 实例),用编译好的正则匹配 req.path
  3. 匹配成功 → 提取参数(req.params),执行路由的中间件栈
  4. 匹配失败 → next() 继续下一个

express.Router() 原理:

  • Router 本质是一个 迷你 Express 实例,拥有独立的中间件栈和路由表
  • 它是一个中间件函数,同时也是一个挂载点
  • 挂载时使用 app.use('/api', router),/api 成为该 Router 的 路由前缀
  • Router 内部路径相对于前缀匹配,实现路由模块化
js
// 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 错误

最佳实践:

js
// 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 编排

零停机部署方案:

  1. PM2 热重载:

    bash
    pm2 reload app  # 逐个重启 worker,保持服务可用
  2. 蓝绿部署:两套环境交替上线,Nginx 切换 upstream

  3. 滚动更新(K8s):

    yaml
    spec:
      strategy:
        type: RollingUpdate
        rollingUpdate:
          maxUnavailable: 0
          maxSurge: 1

关键配置:

  • Nginx:proxy_next_upstream 容错、keepalive 长连接池
  • 健康检查端点:/healthz 供负载均衡器探活
  • 优雅关闭:监听 SIGTERM,停止接收新请求,等待现有请求完成
    js
    process.on('SIGTERM', () => {
      server.close(() => process.exit(0));
      setTimeout(() => process.exit(1), 10_000); // 超时强制退出
    });

延伸思考: 在 K8s 滚动更新中,如果 Pod 还在处理请求就被终止,如何保证请求不丢失?preStop 钩子的作用是什么?


10. 如何在 Express 项目中集成 TypeScript?有哪些常见陷阱和最佳实践? ​

难度: ⭐⭐

标准答案:

基础集成步骤:

  1. 初始化 TypeScript:

    bash
    npm i -D typescript @types/node @types/express ts-node
    npx tsc --init
  2. tsconfig.json 关键配置:

    json
    {
      "compilerOptions": {
        "target": "ES2022",
        "module": "NodeNext",
        "moduleResolution": "NodeNext",
        "outDir": "./dist",
        "rootDir": "./src",
        "strict": true,
        "esModuleInterop": true,
        "skipLibCheck": true,
        "resolveJsonModule": true
      },
      "include": ["src"]
    }
  3. 类型安全的路由写法:

    ts
    import { 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);
    });
  4. 开发环境:ts-node-dev 或 tsx 热重载

  5. 生产构建: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,先编译再运行

延伸思考: 为什么"编译时类型安全"不等于"运行时安全"?在实际项目中,如何平衡类型严格度和开发效率?