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:type、design:paramtypes、design: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/post | Controller 类内聚,声明式路由 |
装饰器不是银弹,但它是 TypeScript 从「编译时安全」延伸到「运行时安全」的最佳桥梁。对 Node.js 服务端来说,这 3 个模式足以覆盖 80% 的类型安全盲区。
评论区
登录 后参与评论