Node.js 项目最终还是需要框架

个人比较喜欢规范化的开发。从自己封装装饰器+IoC 的轻量框架,到业务复杂化后被迫寻求开源方案——回头再看,Node.js 的项目最后还是要回归大型框架。本文记录这段历程和反思。

一、自研框架的起点

我一直对代码的规范化有执念。用 Express 写接口时,路由声明和业务逻辑混在一起,TypeScript 的装饰器语法让我看到了另一种可能:

// 我一直期望的代码风格
@Controller('/users')
class UserController {
  @Inject()
  private userService: UserService;

  @Get('/:id')
  async getUser(@Param('id') id: string) {
    return this.userService.findById(id);
  }

  @Post('/')
  async createUser(@Body() dto: CreateUserDto) {
    return this.userService.create(dto);
  }
}

受 Java Spring 和 Angular 的启发,我开始动手实现自己的框架。核心三件套:

装饰器路由

const ROUTE_METADATA = Symbol('route');

export function Controller(path: string): ClassDecorator {
  return (target) => {
    Reflect.defineMetadata(ROUTE_METADATA, { path }, target);
  };
}

export function Get(path: string): MethodDecorator {
  return (target, key, descriptor) => {
    const routes = Reflect.getMetadata(ROUTE_METADATA, target.constructor) || [];
    routes.push({ method: 'get', path, handler: key });
    Reflect.defineMetadata(ROUTE_METADATA, routes, target.constructor);
  };
}

依赖注入容器

class Container {
  private providers = new Map<string, any>();

  register<T>(token: string, provider: new () => T): void {
    this.providers.set(token, new provider());
  }

  resolve<T>(token: string): T {
    return this.providers.get(token);
  }
}

路由注册

function bootstrap(controllerClasses: any[]) {
  const app = express();
  for (const ControllerClass of controllerClasses) {
    const prefix = Reflect.getMetadata(ROUTE_METADATA, ControllerClass).path;
    const routes = Reflect.getMetadata(ROUTE_METADATA, ControllerClass.prototype) || [];
    for (const route of routes) {
      app[route.method](`${prefix}${route.path}`, async (req, res) => {
        const instance = container.resolve(ControllerClass.name);
        const result = await instance[route.handler](req);
        res.json(result);
      });
    }
  }
  app.listen(3000);
}

小项目里,这套东西工作得很好。新增接口就是写一个 Controller 类,加几个装饰器,框架自动完成路由注册和依赖注入。

二、业务增长的瓶颈

随着项目规模扩大,需求从「完成功能」变成了「保障质量」:

参数校验。参考 class-validator 的装饰器校验思路,写了自定义校验模块。但很快发现需要国际化错误消息、嵌套校验、数组校验——代码量开始失控。

中间件体系。需要灵活的守卫机制(认证/授权),自定义了 @UseGuards() 装饰器。

ORM 集成。Service 层需要注入 Repository,又实现了一套 @InjectRepository() 装饰器。

单元测试。每个 Controller 都需要 mock Container 和 Service,测试代码比业务代码还长。

问题逐渐显现:框架从 500 行膨胀到 5000+ 行;没有社区验证的边界 case 在生产环境暴露;文档永远跟不上;安全漏洞靠自己排查。

三、Node.js 主流框架对比

Express

最底层的 HTTP 框架。优点是简单、自由。缺点是过于自由——团队超过 3 个人时,每个人都有自己的一套文件结构和错误处理方式。

Koa

在 Express 基础上优化了异步控制和中间件洋葱模型。但依然不提供开箱即用的结构——路由、请求体解析、参数校验都要自己选库组合。

Nest.js

目光转向 Nest.js 时,我几乎笑出来——它长得和我自研的框架一模一样:

@Controller('users')
export class UsersController {
  constructor(private readonly userService: UserService) {}

  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.userService.findOne(id);
  }
}

但差别在于:

  • Pipe:内建参数校验和类型转换,@Body(new ValidationPipe()) dto 一行搞定
  • Guard:认证/授权中间件,声明式使用,可全局或路由级
  • Interceptor:统一的响应格式、日志记录、缓存
  • Filter:全应用统一的异常处理
  • Module 系统:按功能拆分模块,清晰的依赖边界
  • Swagger 集成:@nestjs/swagger 自动生成 API 文档

我花了一年时间在自研框架上实现的功能,Nest.js 已经做了六年。

Fastify

对延迟敏感的场景,Fastify 是高性能选择。内置 JSON Schema 校验,但生态规模不如 Nest.js。

维度 Express Koa Nest.js Fastify
学习曲线 低 低 中高 中
项目结构 自由 自由 强约束(Module) 插件约定
依赖注入 无 无 内建 可选
TypeScript 手动配置 手动配置 一等公民 良好
适合规模 小 中小 中大型 中小

四、为什么最终回归框架

社区生态碾压个人能力。class-validator 有六个维护者处理了 Unicode 边界 case 和循环引用检测——一个人绝不可能做到同等级别的覆盖度。

团队协作需要共同语言。新人加入团队,面对自研框架每个决策都要来问作者。换用 Nest.js 后,花一周看文档就能上手。

造轮子的时机判断。该造:学习原理,或现有方案无法满足性能需求。不该造:只是觉得现有方案「写法不够优雅」,而它已有成熟的社区和数百万用户。

五、自研框架的长期价值

虽最终回归开源框架,但这一年的自研经历并不浪费:

理解原理。亲手实现过路由匹配、中间件洋葱模型和 DI 容器后,再去看 Nest.js 源码,看到的不是「魔法」,而是可以逐行理解的工程实现。

成为更好的框架使用者。很多人用框架是「黑盒调用」——复制文档代码,遇到问题不知从何排查。亲手造过框架的人会去看源码、issue、PR 讨论,因为你知道每个抽象层下面是什么。

适用场景。自研框架在今天仍有合理场景:教学目的、极简微服务(少于 5 个端点)、对性能有极致追求且现有框架无法满足时。但在团队协作的生产项目中,拥抱开源是更理性的选择。


总结

从 Express 到自研 IoC 框架,再到 Nest.js——这是很多 Node.js 开发者的必经之路。写框架和用框架学到的是两回事,前者教你「为什么这么设计」,后者教你「怎么大规模协作」。

如果你也在自研框架和开源方案之间纠结,我的建议是:造过一个,你就会知道什么时候不该造。