Node.js 做服务端开发绕不开的一个问题就是框架选型。我最早写 Node 后端的时候用的是原生 http 模块几十行代码才能处理一个简单的路由和请求体解析后来接触到 Express感觉像打开了新世界的大门。再后来 Koa2 出来了Nest.js 也慢慢火了起来团队里每次开新项目总有人问“这次用哪个框架”。这个问题看起来简单但真要回答清楚得从架构理念、开发体验、性能表现、生态配套、团队协作等多个维度去拆。我在实际项目里用 Express 做过中小型 API 网关用 Koa2 搭过 BFF 层也用 Nest.js 写过完整的企业级微服务踩过的坑不算少。这篇文章就把我对这三个框架的理解、实操经验和选型逻辑完整地梳理一遍不管你是刚接触 Node.js 服务端的新手还是正在做技术选型的团队负责人应该都能从中找到有用的参考。1. 三个框架的架构理念与核心差异1.1 Express中间件线性模型的经典代表Express 的核心设计哲学可以用一句话概括一切皆中间件。一个请求进来依次经过注册的中间件函数每个中间件可以选择处理请求、修改请求对象、调用下一个中间件或者直接返回响应。这种线性模型非常直观你几乎不需要理解什么额外的概念就能上手。我刚开始用 Express 的时候最直观的感受就是“自由”。路由怎么写、中间件怎么组织、错误怎么处理全凭自己安排。比如下面这段代码就是一个最典型的 Express 应用骨架const express require(express); const app express(); app.use(express.json()); app.get(/users/:id, async (req, res, next) { try { const user await getUserById(req.params.id); if (!user) { return res.status(404).json({ error: User not found }); } res.json(user); } catch (err) { next(err); } }); app.use((err, req, res, next) { console.error(err.stack); res.status(500).json({ error: Internal Server Error }); }); app.listen(3000);这段代码里app.use(express.json())是中间件路由处理函数是中间件错误处理也是中间件。整个请求处理流程就是一条中间件链谁先注册谁先执行。这种模型的优势在于学习成本极低文档丰富社区庞大几乎你遇到的任何问题都能在网上找到答案。但线性模型也有它的问题。当项目规模变大中间件数量膨胀到几十个甚至上百个的时候执行顺序就变得非常难以追踪。我曾经维护过一个 Express 项目光app.use就写了四十多行排查一个请求为什么没有走到预期的中间件花了整整一个下午。另外Express 对异步错误的处理一直是个痛点在 Express 4.x 里async 函数抛出的错误不会被自动捕获必须手动 try-catch 然后调用 next(err)稍不注意就会导致请求挂起。1.2 Koa2洋葱模型与 async/await 的优雅结合Koa2 是 Express 原班人马打造的新一代框架它的核心改进有两个一是采用了洋葱模型的中间件执行机制二是原生支持 async/await。洋葱模型的意思是中间件的执行顺序像洋葱一样层层深入然后再层层返回。一个请求先经过最外层中间件的前半部分再进入下一层到达最内层处理完业务逻辑后再依次经过各层中间件的后半部分。这个模型最典型的应用场景就是日志和耗时统计。在 Express 里如果你想记录一个请求的总耗时需要在中间件里记录开始时间然后监听 response 的 finish 事件。但在 Koa2 里你可以直接在中间件里写const Koa require(koa); const app new Koa(); app.use(async (ctx, next) { const start Date.now(); await next(); const ms Date.now() - start; console.log(${ctx.method} ${ctx.url} - ${ms}ms); }); app.use(async (ctx) { ctx.body { message: Hello Koa }; }); app.listen(3000);await next()之后的代码会在所有后续中间件执行完毕后才执行这就是洋葱模型的核心。这种设计让 Koa2 的中间件逻辑非常清晰特别适合做请求前后的统一处理比如响应头注入、性能监控、事务管理等。Koa2 的另一个特点是极简。它本身不包含路由、静态文件服务、请求体解析等功能这些都需要通过第三方中间件来实现。这种设计的好处是框架本身非常轻量你可以完全按需组装坏处是新手可能会觉得“怎么什么都要自己装”。我个人的经验是Koa2 适合对 HTTP 协议有一定理解、喜欢自己掌控技术栈的开发者不太适合完全零基础的新手。1.3 Nest.js企业级架构与 TypeScript 的深度融合Nest.js 和前两个框架的定位完全不同。它不是一个轻量级的 HTTP 工具库而是一个完整的应用框架灵感来源于 Angular。Nest.js 的核心概念包括模块Module、控制器Controller、服务Service、依赖注入DI、装饰器Decorator等这些概念对于写过 Angular 或者 Spring Boot 的开发者来说会非常熟悉。Nest.js 默认使用 TypeScript并且强推依赖注入和分层架构。一个典型的 Nest.js 控制器长这样import { Controller, Get, Param, NotFoundException } from nestjs/common; import { UsersService } from ./users.service; Controller(users) export class UsersController { constructor(private readonly usersService: UsersService) {} Get(:id) async findOne(Param(id) id: string) { const user await this.usersService.findById(id); if (!user) { throw new NotFoundException(User not found); } return user; } }这段代码里Controller和Get是装饰器UsersService通过构造函数注入。Nest.js 的依赖注入容器会自动管理服务的生命周期和依赖关系你不需要手动 new 一个 Service 出来。这种设计在大型项目中优势非常明显代码结构统一、职责边界清晰、可测试性强、模块之间解耦彻底。但 Nest.js 的学习曲线也是三个框架里最陡的。你需要理解模块系统、依赖注入、装饰器元数据、管道、守卫、拦截器、过滤器等一系列概念。我见过不少开发者第一次接触 Nest.js 时被这些概念劝退觉得“写个接口怎么这么复杂”。但一旦跨过这个门槛在团队协作和长期维护上带来的收益是巨大的。1.4 三者核心差异对照维度ExpressKoa2Nest.js中间件模型线性模型洋葱模型模块化拦截器异步支持回调/Promise需手动处理错误原生 async/await原生 async/await语言JavaScriptJavaScriptTypeScript默认架构约束无无强约束模块/DI/分层内置功能路由、静态文件等几乎无路由、DI、验证、Swagger 等学习曲线低中高适合场景中小型项目、快速原型中间层、BFF、轻量 API企业级应用、微服务、大型团队这张表是我自己在选型时经常参考的一个总结。但光看这张表还不够每个框架在实际使用中都有很多细节需要展开说。2. 核心细节解析与实操要点2.1 Express 的中间件顺序陷阱与错误处理Express 最容易被忽视的问题就是中间件顺序。因为中间件是按注册顺序执行的所以顺序错了功能就会出问题。我踩过的一个典型坑是把错误处理中间件注册在了路由之前结果路由里抛出的错误根本不会走到错误处理中间件。错误处理中间件必须注册在所有路由之后而且必须是四个参数(err, req, res, next)少一个参数 Express 就会把它当成普通中间件。另一个常见问题是express.json()和express.urlencoded()的注册位置。如果你在解析请求体的中间件之前就注册了路由那么路由处理函数里拿到的req.body就是 undefined。这个坑我在刚学 Express 的时候踩过不止一次。关于异步错误处理Express 5.x 已经支持自动捕获 async 函数抛出的错误但如果你还在用 4.x就必须手动处理。我的做法是封装一个asyncHandler高阶函数const asyncHandler (fn) (req, res, next) { Promise.resolve(fn(req, res, next)).catch(next); }; app.get(/users/:id, asyncHandler(async (req, res) { const user await getUserById(req.params.id); res.json(user); }));这样每个异步路由都用asyncHandler包一层就不用到处写 try-catch 了。这个技巧在实际项目中非常实用建议每个用 Express 4.x 的团队都把它作为标准写法。注意Express 的中间件是顺序敏感的错误处理中间件必须放在最后请求体解析中间件必须放在路由之前。这两个顺序问题是最常见的 Express 踩坑点。2.2 Koa2 的 ctx 对象与中间件组合Koa2 的ctx对象是对原生req和res的封装它把请求和响应的常用操作都挂载到了一个对象上。比如ctx.method、ctx.url、ctx.request.body、ctx.body、ctx.status等。这种设计比 Express 的req和res分离要更符合直觉写起来也更简洁。Koa2 的中间件组合是通过koa-compose实现的这也是洋葱模型的技术基础。每个中间件都是一个 async 函数接收(ctx, next)两个参数。next()返回一个 Promiseawait next()会暂停当前中间件的执行把控制权交给下一个中间件等下一个中间件执行完后再回来继续执行。这个机制在实现一些横切关注点时特别有用。比如实现一个简单的请求耗时统计和响应日志app.use(async (ctx, next) { const start Date.now(); try { await next(); } catch (err) { ctx.status err.status || 500; ctx.body { error: err.message }; ctx.app.emit(error, err, ctx); } finally { const ms Date.now() - start; console.log(${ctx.method} ${ctx.url} ${ctx.status} - ${ms}ms); } });这段代码里finally块中的日志会在所有后续中间件执行完毕后执行不管是否发生错误。这种写法在 Express 里需要监听 response 事件才能实现Koa2 里就自然多了。Koa2 的路由通常用koa-router或者koa/router。需要注意的是Koa2 本身不解析请求体需要用koa-bodyparser中间件。静态文件服务用koa-static跨域用koa/cors。这些中间件都需要自己安装和注册虽然多了一步但也让你对项目依赖一目了然。2.3 Nest.js 的模块系统与依赖注入Nest.js 的模块系统是整个框架的骨架。每个应用至少有一个根模块AppModule其他功能模块通过imports注册到根模块中。模块内部包含控制器和服务控制器负责处理 HTTP 请求服务负责业务逻辑。这种分层设计让代码的职责非常清晰。依赖注入是 Nest.js 最核心的机制之一。你只需要在构造函数里声明依赖Nest.js 的 IoC 容器就会自动帮你实例化并注入。比如Injectable() export class UsersService { constructor( InjectRepository(User) private usersRepository: RepositoryUser, ) {} async findById(id: string): PromiseUser | null { return this.usersRepository.findOne({ where: { id } }); } }这里InjectRepository(User)是 TypeORM 提供的装饰器Nest.js 会自动把对应的 Repository 实例注入进来。这种设计的好处是Service 不关心 Repository 是怎么创建的只关心它的接口非常利于单元测试和模块替换。Nest.js 还提供了管道Pipe、守卫Guard、拦截器Interceptor、过滤器Filter等横切关注点机制。管道用于请求参数的验证和转换守卫用于权限控制拦截器用于请求前后的统一处理过滤器用于异常处理。这些机制的组合使用可以让代码的横切逻辑非常干净地抽离出来。2.4 三个框架的 TypeScript 支持对比Express 和 Koa2 都可以用 TypeScript 写但它们的类型定义主要依赖types/express和types/koa这些社区维护的类型包。类型覆盖度还算不错但中间件的类型推断有时候不够精确需要手动标注类型的地方比较多。Nest.js 则是原生 TypeScript 框架类型系统是框架设计的一部分。装饰器、依赖注入、模块系统都深度依赖 TypeScript 的元数据反射能力。用 Nest.js 写代码类型提示和自动补全的体验是最好的几乎不需要手动标注类型IDE 就能给出准确的提示。不过 Nest.js 对 TypeScript 的强依赖也意味着如果你团队里有人不熟悉 TypeScript上手成本会比较高。我建议如果决定用 Nest.js团队最好先统一学习一下 TypeScript 的基础知识特别是装饰器和泛型否则会在开发过程中遇到很多困惑。3. 实操过程与核心环节实现3.1 从零搭建一个 Express API 服务先来看 Express 的完整搭建过程。假设我们要做一个用户管理的 API包含列表查询、详情查询、创建用户三个接口。第一步是初始化项目并安装依赖mkdir express-demo cd express-demo npm init -y npm install express npm install -D nodemon第二步是创建入口文件app.js把中间件、路由、错误处理都组织好const express require(express); const app express(); // 请求体解析 app.use(express.json()); app.use(express.urlencoded({ extended: true })); // 请求日志 app.use((req, res, next) { console.log(${new Date().toISOString()} ${req.method} ${req.url}); next(); }); // 模拟数据 const users [ { id: 1, name: Alice, email: aliceexample.com }, { id: 2, name: Bob, email: bobexample.com }, ]; // 路由 app.get(/api/users, (req, res) { res.json({ data: users }); }); app.get(/api/users/:id, (req, res) { const user users.find(u u.id req.params.id); if (!user) { return res.status(404).json({ error: User not found }); } res.json({ data: user }); }); app.post(/api/users, (req, res) { const { name, email } req.body; if (!name || !email) { return res.status(400).json({ error: Name and email are required }); } const newUser { id: String(users.length 1), name, email }; users.push(newUser); res.status(201).json({ data: newUser }); }); // 404 处理 app.use((req, res) { res.status(404).json({ error: Not Found }); }); // 错误处理 app.use((err, req, res, next) { console.error(err.stack); res.status(500).json({ error: Internal Server Error }); }); const PORT process.env.PORT || 3000; app.listen(PORT, () { console.log(Server running on port ${PORT}); });这个结构在 Express 项目里非常典型。路由直接写在入口文件里对于小型项目来说够用但如果接口多了建议按功能拆分成多个路由文件用express.Router()来组织。第三步是在package.json里配置启动脚本{ scripts: { start: node app.js, dev: nodemon app.js } }nodemon会在文件变化时自动重启服务开发阶段非常方便。实测下来Express 项目从零到能跑起来熟练的话十分钟就够了这也是它最大的优势之一。3.2 Koa2 项目的分层组织与中间件选型Koa2 的搭建过程稍微多几步因为需要自己选装中间件。同样的用户管理 API用 Koa2 来实现mkdir koa-demo cd koa-demo npm init -y npm install koa koa/router koa-bodyparser koa/cors npm install -D nodemon入口文件app.js的结构const Koa require(koa); const Router require(koa/router); const bodyParser require(koa-bodyparser); const cors require(koa/cors); const app new Koa(); const router new Router({ prefix: /api }); // 全局错误处理 app.use(async (ctx, next) { try { await next(); } catch (err) { ctx.status err.status || 500; ctx.body { error: err.message }; ctx.app.emit(error, err, ctx); } }); // 请求日志 app.use(async (ctx, next) { const start Date.now(); await next(); const ms Date.now() - start; console.log(${ctx.method} ${ctx.url} ${ctx.status} - ${ms}ms); }); app.use(cors()); app.use(bodyParser()); // 模拟数据 const users [ { id: 1, name: Alice, email: aliceexample.com }, { id: 2, name: Bob, email: bobexample.com }, ]; router.get(/users, (ctx) { ctx.body { data: users }; }); router.get(/users/:id, (ctx) { const user users.find(u u.id ctx.params.id); if (!user) { ctx.status 404; ctx.body { error: User not found }; return; } ctx.body { data: user }; }); router.post(/users, (ctx) { const { name, email } ctx.request.body; if (!name || !email) { ctx.status 400; ctx.body { error: Name and email are required }; return; } const newUser { id: String(users.length 1), name, email }; users.push(newUser); ctx.status 201; ctx.body { data: newUser }; }); app.use(router.routes()); app.use(router.allowedMethods()); app.on(error, (err) { console.error(Server error:, err); }); const PORT process.env.PORT || 3000; app.listen(PORT, () { console.log(Server running on port ${PORT}); });对比 Express 的版本Koa2 的代码有几个明显不同错误处理用 try-catch 包在中间件里而不是单独的错误处理中间件路由用koa/router的实例来管理响应通过ctx.body和ctx.status设置。整体代码量差不多但 Koa2 的中间件执行顺序更符合直觉特别是日志中间件里await next()前后的代码分界非常清晰。Koa2 项目在实际使用中我建议把路由、控制器、服务分层组织。路由只负责定义 URL 和 HTTP 方法控制器负责处理请求和响应服务负责业务逻辑。这样即使项目变大代码也不会乱。3.3 Nest.js 企业级项目的模块化实现Nest.js 的搭建过程是最复杂的但它的 CLI 工具可以帮我们省很多事。先安装 CLInpm install -g nestjs/cli nest new nest-demoCLI 会交互式地问你用什么包管理器选 npm 或 yarn 都行。创建完成后项目结构已经帮你组织好了src/ app.controller.ts app.service.ts app.module.ts main.ts接下来生成用户模块nest generate module users nest generate controller users nest generate service users这三个命令会分别创建users.module.ts、users.controller.ts、users.service.ts并且自动把 UsersModule 注册到 AppModule 的 imports 里。这种代码生成能力在大型项目中非常实用能保证团队所有人的代码结构一致。用户服务的实现// users.service.ts import { Injectable, NotFoundException } from nestjs/common; export interface User { id: string; name: string; email: string; } Injectable() export class UsersService { private users: User[] [ { id: 1, name: Alice, email: aliceexample.com }, { id: 2, name: Bob, email: bobexample.com }, ]; findAll(): User[] { return this.users; } findById(id: string): User { const user this.users.find(u u.id id); if (!user) { throw new NotFoundException(User not found); } return user; } create(name: string, email: string): User { const newUser: User { id: String(this.users.length 1), name, email, }; this.users.push(newUser); return newUser; } }控制器的实现// users.controller.ts import { Controller, Get, Post, Param, Body, HttpCode } from nestjs/common; import { UsersService } from ./users.service; class CreateUserDto { name: string; email: string; } Controller(api/users) export class UsersController { constructor(private readonly usersService: UsersService) {} Get() findAll() { return { data: this.usersService.findAll() }; } Get(:id) findOne(Param(id) id: string) { return { data: this.usersService.findById(id) }; } Post() HttpCode(201) create(Body() createUserDto: CreateUserDto) { const { name, email } createUserDto; return { data: this.usersService.create(name, email) }; } }模块定义// users.module.ts import { Module } from nestjs/common; import { UsersController } from ./users.controller; import { UsersService } from ./users.service; Module({ controllers: [UsersController], providers: [UsersService], exports: [UsersService], }) export class UsersModule {}最后在main.ts里启动应用import { NestFactory } from nestjs/core; import { AppModule } from ./app.module; async function bootstrap() { const app await NestFactory.create(AppModule); await app.listen(3000); console.log(Server running on port 3000); } bootstrap();Nest.js 的代码量明显比前两个框架多但结构非常清晰。控制器只负责路由和参数提取服务只负责业务逻辑模块负责组装。这种分层在项目变大后优势会越来越明显。另外Nest.js 内置了参数验证管道配合class-validator可以自动验证请求参数不需要手动写 if-else 判断。3.4 性能压测对比与参数调优三个框架的性能差异是很多人关心的问题。我用autocannon做过一轮简单的压测测试环境是本地开发机Node.js 18 LTS每个框架都跑一个返回 JSON 的简单接口并发连接数 100持续 10 秒。结果如下框架每秒请求数RPS平均延迟ms吞吐量MB/sExpress约 120008.22.1Koa2约 135007.12.4Nest.js约 110009.51.9这个数据只是参考实际性能受业务逻辑、数据库查询、中间件数量影响很大。从结果看Koa2 因为中间件机制更轻量性能略好于 ExpressNest.js 因为多了依赖注入和装饰器元数据的开销性能稍低但差距在可接受范围内。真正影响性能的往往不是框架本身而是数据库查询、序列化、日志、中间件数量这些因素。我在实际项目中做过优化把 Express 项目里的一个同步日志中间件改成异步写入后RPS 提升了将近 20%。所以选型时不用太纠结框架的基准性能更应该关注开发效率和维护成本。提示如果确实对性能有极致要求可以考虑在框架前面加一层反向代理做负载均衡或者用 Node.js 的 cluster 模块充分利用多核 CPU。框架层面的性能差异在真实业务场景中通常不是瓶颈。4. 常见问题与排查技巧实录4.1 Express 常见问题速查问题现象可能原因解决方法req.body 为 undefined请求体解析中间件未注册或注册顺序错误确保express.json()在路由之前注册错误处理中间件不生效注册位置在路由之前或参数不是四个移到所有路由之后确保参数为(err, req, res, next)异步路由错误导致请求挂起Express 4.x 不自动捕获 async 错误用asyncHandler包装异步路由跨域请求失败未配置 CORS 中间件安装cors并在路由之前注册路由匹配不到路由顺序问题或路径拼写错误检查路由注册顺序用express.Router()拆分Express 的这些问题我基本都踩过一遍。最让人头疼的是异步错误导致请求挂起因为服务不会崩溃只是那个请求一直没有响应排查起来很费时间。后来我养成了习惯所有异步路由一律用asyncHandler包装再也没遇到过这个问题。4.2 Koa2 常见问题速查问题现象可能原因解决方法ctx.request.body 为 undefined未注册koa-bodyparser安装并在路由之前注册路由 404未注册router.routes()或 prefix 配置错误检查app.use(router.routes())和 prefix中间件不执行忘记调用await next()确保每个中间件都调用了next()错误未被捕获没有全局错误处理中间件在最外层中间件用 try-catch 捕获静态文件 404未注册koa-static或路径配置错误检查静态文件中间件的注册路径Koa2 最常见的问题就是忘记await next()。因为 Koa2 的中间件必须显式调用next()才会继续往下执行如果忘了写请求就会停在那里。这个坑我在刚用 Koa2 的时候踩过排查了半天才发现是少写了一个await next()。4.3 Nest.js 常见问题速查问题现象可能原因解决方法依赖注入失败Service 未在 Module 的 providers 中注册检查 Module 的 providers 和 exports路由 404Controller 未在 Module 的 controllers 中注册检查 Module 的 controllers 配置参数验证不生效未使用 ValidationPipe 或 DTO 未加装饰器在 main.ts 中全局注册 ValidationPipe循环依赖报错两个模块互相 imports使用forwardRef()解决循环依赖TypeORM 连接失败数据库配置错误或实体未注册检查 TypeOrmModule 配置和实体注册Nest.js 的依赖注入问题是最常见的特别是新手容易忘记在 Module 里注册 Service。我建议每次新建 Service 后先检查 Module 的 providers 数组里有没有它这个习惯能省很多排查时间。4.4 框架选型的实操建议说了这么多技术细节最后落到选型上我的建议是这样的如果是个人项目、快速原型、或者团队规模小且追求开发速度Express 依然是最稳妥的选择。它的生态最成熟遇到问题最容易找到解决方案招人也最容易。如果项目需要处理大量请求前后的统一逻辑比如 BFF 层、API 网关、需要精细控制中间件执行顺序的场景Koa2 的洋葱模型会让你写得更舒服。但前提是团队对 HTTP 协议和异步编程有一定理解。如果是企业级应用、微服务架构、团队规模较大且需要长期维护Nest.js 的架构约束和 TypeScript 支持会带来巨大的长期收益。虽然前期学习成本高但项目越大Nest.js 的优势越明显。我个人的经验是不要为了用新框架而用新框架。选型的核心依据是团队的技术储备、项目的生命周期、以及维护成本。一个用 Express 写得清清楚楚的项目远比一个用 Nest.js 写得乱七八糟的项目要好维护得多。另外补充一个实际工作中的小技巧如果团队已经在用某个框架除非有非常明确的理由否则不要轻易换框架。换框架的迁移成本往往被低估而收益往往没有想象中那么大。我见过一个团队从 Express 迁移到 Nest.js花了三个月才把核心业务迁移完期间新功能开发几乎停滞。这个代价在选型时一定要考虑进去。