前端开发··2 阅读·预计 13 分钟

TypeScript 装饰器在 Node.js 中的深度应用:从参数校验到依赖注入的运行时类型安全

TypeScript 的类型安全有个致命短板:编译后全部擦除。function add(a: number, b: number) 到了运行时只是 function add(a, b),传入 'hello' 也不会报错。对于 Node.js 服务端来说,这是实打实的隐患。装饰器 + 元数据,恰好能填补这个鸿沟。

一、问题现场:类型擦除的代价

// 编译后类型签名全没了
app.post('/user', (req, res) => {
  const { name, age } = req.body; // any
  // name 是什么类型?age 是什么类型?全部靠猜
  createUser(name, age);
});

function createUser(name: string, age: number) {
  // 编译后:function createUser(name, age) —— 运行时完全不设防
}

一个 REST API 每秒要接成千上万请求,把类型校验全部压在手动写 if-else 上,既蠢又不可靠。

二、装饰器 + reflect-metadata:运行时保留类型信息

reflect-metadata 是装饰器模式的核心基础设施。开启 emitDecoratorMetadata 后,TypeScript 编译器会在装饰器调用点注入 design:typedesign:paramtypesdesign:returntype 三条元数据:

import 'reflect-metadata';

function validate(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const originalMethod = descriptor.value;
  const paramTypes = Reflect.getMetadata('design:paramtypes', target, propertyKey);

  descriptor.value = function (...args: any[]) {
    for (let i = 0; i < args.length; i++) {
      const expectedType = paramTypes[i];
      if (expectedType === String && typeof args[i] !== 'string') {
        throw new TypeError(`参数 ${i} 应为 string,实际为 ${typeof args[i]}`);
      }
      if (expectedType === Number && typeof args[i] !== 'number') {
        throw new TypeError(`参数 ${i} 应为 number,实际为 ${typeof args[i]}`);
      }
    }
    return originalMethod.apply(this, args);
  };
}

class UserService {
  @validate
  createUser(name: string, age: number) {
    // 参数类型已在运行时校验
    return { name, age };
  }
}

const svc = new UserService();
svc.createUser('凌霄', 14);          // ✅ 正常
svc.createUser(123 as any, '14');    // ❌ TypeError: 参数 0 应为 string

Reflect.getMetadata('design:paramtypes', ...) 在运行时返回 [String, Number]——不是 any,是实实在在的类型引用。

三、从装饰器到 IoC 容器:一个极简依赖注入实现

有了类型元数据,依赖注入就不再需要 Angular 那种庞大的框架:

// 用装饰器标记可注入的类
const INJECTABLE_KEY = Symbol('injectable');

function Injectable() {
  return (target: any) => {
    Reflect.defineMetadata(INJECTABLE_KEY, true, target);
  };
}

// IoC 容器
class Container {
  private registry = new Map<any, any>();

  register<T>(token: any, instance: T) {
    this.registry.set(token, instance);
  }

  resolve<T>(target: new (...args: any[]) => T): T {
    const paramTypes: any[] = Reflect.getMetadata('design:paramtypes', target) ?? [];
    const injections = paramTypes.map(pt => {
      const instance = this.registry.get(pt);
      if (!instance) throw new Error(`未注册的依赖:${pt.name}`);
      return instance;
    });
    return new target(...injections);
  }
}

// 使用
@Injectable()
class Database {
  query(sql: string) { /* ... */ }
}

@Injectable()
class UserRepository {
  constructor(private db: Database) {} // ← 构造函数自动注入
  findById(id: number) { return this.db.query('SELECT ...'); }
}

const container = new Container();
container.register(Database, new Database());
container.register(UserRepository, container.resolve(UserRepository));
// UserRepository 的构造函数参数从元数据中读取,自动注入 Database 实例

❌ 常见反模式:手动 new 依赖链

// 坏:硬编码依赖,无法测试,无法替换实现
class OrderService {
  private db = new Database('prod-url');
  private notifier = new EmailService();
  // ...
}

✅ IoC 解耦

@Injectable()
class OrderService {
  constructor(
    private db: Database,
    private notifier: INotifier,  // 接口注入,测试时换 FakeNotifier
  ) {}
}

四、路由装饰器:Express/Koa 的声明式改造

装饰器最直观的生产力提升在路由层:

// 定义路由元数据 key
const ROUTES_KEY = Symbol('routes');
const MIDDLEWARE_KEY = Symbol('middleware');

function Controller(path: string) {
  return (target: any) => {
    Reflect.defineMetadata('prefix', path, target);
  };
}

function Get(path: string = '') {
  return (target: any, propertyKey: string) => {
    const routes = Reflect.getMetadata(ROUTES_KEY, target.constructor) ?? [];
    routes.push({ method: 'get', path, handler: propertyKey });
    Reflect.defineMetadata(ROUTES_KEY, routes, target.constructor);
  };
}

function Post(path: string = '') {
  return (target: any, propertyKey: string) => {
    const routes = Reflect.getMetadata(ROUTES_KEY, target.constructor) ?? [];
    routes.push({ method: 'post', path, handler: propertyKey });
    Reflect.defineMetadata(ROUTES_KEY, routes, target.constructor);
  };
}

// 注册函数:扫描 Controller 并绑定到 Express
function registerControllers(app: any, controllers: any[]) {
  for (const Ctrl of controllers) {
    const instance = new Ctrl();
    const prefix = Reflect.getMetadata('prefix', Ctrl) ?? '';
    const routes = Reflect.getMetadata(ROUTES_KEY, Ctrl) ?? [];

    for (const route of routes) {
      const fullPath = `${prefix}${route.path}`;
      app[route.method](fullPath, (req: any, res: any) => {
        return instance[route.handler](req, res);
      });
    }
  }
}

// 实际写 Controller
@Controller('/api/users')
class UserController {
  @Get('/')
  list(req: Request, res: Response) {
    res.json([{ id: 1, name: '凌霄' }]);
  }

  @Post('/')
  create(req: Request, res: Response) {
    res.status(201).json({ id: 2, ...req.body });
  }
}

// 一行注册
registerControllers(app, [UserController]);

对比传统的:

// ❌ 传统方式:路由散落各处,跨文件时毫无结构化提示
app.get('/api/users', (req, res) => { /* ... */ });
app.post('/api/users', (req, res) => { /* ... */ });

五、性能考量

装饰器在模块加载时执行一次,运行时只有 Reflect.getMetadata() 的 Map 查找开销——O(1),纳秒级。不会成为瓶颈。

// 启动时执行一次
Reflect.defineMetadata(ROUTES_KEY, routes, Ctrl);

// 每个请求只做一次 Reflect.getMetadata —— 跟读一个普通对象属性差不多快
const routes = Reflect.getMetadata(ROUTES_KEY, Ctrl) ?? [];

唯一的代价是 emitDecoratorMetadata 会增加少量编译产物(每个装饰器调用点多几条 __decorate 调用),打包体积大约增加 5-10%。对于 Node.js 服务端来说完全可以忽略。

总结

场景不用装饰器用装饰器
运行时类型校验手写 if-else,重复且易漏design:paramtypes 自动读取,零重复
依赖注入new 硬编码,无法测试构造函数声明依赖,容器自动注入
路由注册散落的 app.get/postController 类内聚,声明式路由

装饰器不是银弹,但它是 TypeScript 从「编译时安全」延伸到「运行时安全」的最佳桥梁。对 Node.js 服务端来说,这 3 个模式足以覆盖 80% 的类型安全盲区。

0 评论

评论区

登录 后参与评论