类装饰器,顾名思义就是专门贴在 class 上面的 “标签”。当我们给一个类贴上装饰器时,TypeScript 会在程序运行时自动将 “这个类的构造函数(constructor)” 作为参数,然后传递给装饰器函数。
TypeScript 类装饰器基础:给类补充属性
假设我们正在开发一个博客系统,里面有 Article(文章)类和 Comment(评论)类。业务规定:这些实体类在实例化时,都必须包含一个 createdAt(创建时间)属性。
如果我们在每个类里面手动写一遍,不仅代码重复,而且后期修改起来非常麻烦。这时候,我们就可以用类装饰器在底层统一 “注入” 新属性。
示例 1:给类注入时间戳属性
// 定义类装饰器
function AddTimestamp(target: Function) {
// target 就是被装饰的类的构造函数
// 我们直接在它的原型(prototype)上挂载属性和方法
target.prototype.createdAt = new Date().toLocaleString();
// 修正:在严格模式下,动态挂载方法的 this 隐式为 any 会导致编译报错
// 必须显式声明 this: any(或具体接口类型)来通过静态检查
target.prototype.printTime = function(this: any) {
console.log("[系统]: 创建时间为", this.createdAt);
};
}
// 使用类装饰器
@AddTimestamp
class Article {
public title: string;
constructor(title: string) {
this.title = title;
}
}
const myArticle = new Article("TypeScript 装饰器");
// 注意:在原生 TypeScript 中直接调用注入的属性会提示类型错误
// 这里我们使用 as any 绕过编译期的类型检查
console.log((myArticle as any).createdAt);
(myArticle as any).printTime();运行结果如下。
[系统]: 创建时间为 2026/5/11 06:56:03分析:
在这个例子中,装饰器的参数 target 指向的就是 Article 类的构造函数本身。我们通过操作 target.prototype,动态地给类塞入了新的属性和方法。
虽然代码能完美运行,但小伙伴们会发现我们在调用时被迫使用了 as any。这是因为 TypeScript 的类型检查是发生在使用装饰器之前的,编译器并不知道 @AddTimestamp 给原型偷偷塞了东西。这也是目前 TypeScript 实验性装饰器的一个历史遗留盲区。
为了解决这个类型提示丢失的问题,并且实现更强大的功能,我们需要使用类装饰器的终极形态:重写类的构造函数。
TypeScript 类装饰器进阶:如何彻底重写一个类?
在 TypeScript 中,如果类装饰器函数返回了一个全新的类,那么这个新的类就会彻底替换掉原来的类!
这是一种十分霸道的 “偷梁换柱” 黑科技。结合上一章学过的 “泛型约束”,我们可以在保留完美类型推导的同时,优雅地重写类的属性。
示例 2:使用匿名类彻底重写原类
// 定义类装饰器
function BaseEntity<T extends { new (...args: any[]): {} }>(constructor: T) {
// 返回一个继承自原类的匿名新类
return class extends constructor {
// 强制注入或覆盖属性
public id: number = Math.floor(Math.random() * 10000);
public createdAt: string = new Date().toLocaleDateString();
// 甚至可以重写原类的方法
public toString() {
return `实体id:${this.id},创建于:${this.createdAt}`;
}
};
}
@BaseEntity
class User {
public username: string;
constructor(name: string) {
this.username = name;
console.log(`User 原本的构造函数执行了,用户:${name}`);
}
}
// 实例化 User
const u = new User("Jack");
console.log(u);
// 调用被重写的 toString()
console.log((u as any).toString());运行结果如下。
User 原本的构造函数执行了,用户:Jack
User { username: 'Jack', id: 6455, createdAt: '2026/5/11' }
实体id:6455,创建于:2026/5/11分析:
<T extends { new (...args: any[]): {} }>上面这一句代码的意思是:T 必须是一个合法的构造函数(类)。
在这个例子中,我们让装饰器返回了一个 class extends constructor(继承了原类的匿名类)。这种做法的好处是:
- 它保留了原类(User)的全部初始化逻辑(比如你会看到 User 原本的 constructor 依然被执行了)。
- 它在原类的基础上,强行覆盖或追加了 id 和 createdAt,甚至重写了 toString() 方法。
这是企业级框架底层最常用的手法,比如在前端数据劫持、或者持久化层的实体类(Entity)封装中,经常会使用这种方式自动注入数据库的默认字段。
TypeScript 类装饰器实战:框架底层是怎么写的?
在了解了类装饰器的基础和进阶用法后,让我们来看看顶级开源框架(如 NestJS)是怎么使用类装饰器搞底层架构的。
后端框架通常会使用 @Controller("/api/user") 这样的写法,把一个普通的类标记为处理特定 URL 的路由控制器。这其实就是 “类装饰器” 结合 “装饰器工厂” 的经典应用。
示例 3:手写一个基础的 Controller 装饰器
// 定义装饰器工厂,接收一个表示路由路径的字符串参数
function Controller(path: string) {
return function(target: Function) {
// 在原型上悄悄保存这个路由路径
target.prototype.$routePath = path;
console.log(`[路由扫描]: 发现控制器 ${target.name},映射路径为 ${path}`);
};
}
// 使用工厂模式,传入 "/api/users"
@Controller("/api/users")
class UserController {
public getUsers() {
console.log("返回用户列表");
}
}
@Controller("/api/orders")
class OrderController {
public getOrders() {
console.log("返回订单列表");
}
}
const userCtrl = new UserController();
console.log("运行时获取到的路由前缀:", (userCtrl as any).$routePath);运行结果如下。
[路由扫描]: 发现控制器 UserController,映射路径为 /api/users
[路由扫描]: 发现控制器 OrderController,映射路径为 /api/orders
运行时获取到的路由前缀:/api/users分析:
在这个例子中,虽然没有修改业务方法的逻辑,但我们利用 @Controller("/xxx") 给这些类打上了 “元数据(Metadata)” 标签。在真实的框架底层,核心引擎只需要遍历所有类,提取出它们原型上的 $routePath,就能自动帮你把整个后端 API 的路由表给注册好!
利用声明合并来消除 as any 报错
细心的小伙伴可能发现了:在上面的例子中,无论我们是给 prototype 塞属性,还是返回了一个继承的匿名类,我们在外部调用那些新注入的属性时,都不得不委屈地写上 as any。
由于 TypeScript 是静态检查,它在编译时只认识 class Article 里显式写出来的属性,根本不知道装饰器在运行时偷偷干了什么。
在严谨的企业级开发中,满屏的 as any 是无法忍受的。为了既能享受装饰器的便利,又保留 100% 的类型提示,我们需要使用 TypeScript 极具特色的一项机制:同名接口合并(Declaration Merging)。
示例 4:消灭 as any 的完美写法
// 1. 定义装饰器:给原型注入 createdAt 属性
function AddTimestamp(target: Function) {
target.prototype.createdAt = new Date().toLocaleString();
}
// 2. 核心黑科技:定义一个与类同名的接口
// TypeScript 会自动将同名接口的属性合并到类的类型声明中
interface Article {
createdAt: string;
}
// 3. 正常使用类和装饰器
@AddTimestamp
class Article {
public title: string;
constructor(title: string) {
this.title = title;
}
}
const myArticle = new Article("TypeScript 装饰器");
// 4. 再也不需要 as any 了,编辑器会有完美的智能提示
console.log("文章创建时间:", myArticle.createdAt);运行结果如下。
文章创建时间:2026/5/11 21:53:35分析:
在 TypeScript 的规则中,如果一个接口(interface)和一个类(class)的名字完全一样,那么它们内部的类型声明会自动合并到一起。
通过 interface Article { createdAt: string; },我们提前 “剧透” 给 TypeScript 编译器:“这个 Article 类身上确实有一个 createdAt 属性,你别瞎报错了!”
配合这种巧妙的欺骗手法,类装饰器在 TypeScript 中的开发体验终于达到了最完美的状态。
