想象你走进一家手机美容店。老板不拆你的手机芯片,不换主板,只是熟练地贴钢化膜、套保护壳、再挂一个小挂件。手机原本的通话、刷视频功能一点没变,但屏幕更耐摔、机身更好看,你还知道这台手机被精心养护过。TypeScript装饰器做的就是这件事——在不动类和方法内部逻辑的前提下,给代码贴上“保护膜”“功能壳”和“信息标签”。 装饰器是TypeScript中一种用@符号标记的特殊机制。它像一个“代码美容师”,在类定义的那一刻,悄悄地对类、方法、属性、参数做增强或记录。NestJS里的@Controller、TypeORM里的@Entity,底层都是装饰器在起作用。这篇教程会带你从零认识类装饰器、方法装饰器、属性装饰器、参数装饰器,搞懂它们的参数、执行顺序、工厂模式,以及如何配合reflect-metadata读写元数据。学完你就能看懂框架源码里的装饰器魔法,也能自己动手写一个。
学会装饰器,你就能看懂 NestJS、TypeORM 等主流框架的核心机制,并独立封装日志、权限、校验等通用能力,让代码复用和开发效率上一个台阶。
目录
可视化演示
展示类/方法/属性/参数四种装饰器的应用、执行顺序与装饰器工厂模式,切换模式查看装饰器核心机制。
本节配有交互动效(进入视口后自动加载,约 3KB)。
四种装饰器类型 青色为类装饰器,绿色为方法,橙色为属性,粉色为参数 @decorator target → 修改行为 TypeScript装饰器用@语法给类、方法、属性、参数添加元数据和修改行为
装饰器入门:认识手机美容店
小明的手机美容店开张了。第一位顾客拿来一部手机,要求“屏幕更耐磨、机身更好看”。小明没有拆开后盖改硬件,而是贴了一张钢化膜、套了一个渐变壳。手机还是那部手机,打电话、刷视频的体验却升级了。这个思路放到TypeScript里,就是装饰器:不修改类内部实现,在外部“贴一层”新能力。
装饰器是TypeScript提供的一种特殊声明语法,用@符号放在类、方法、属性或参数前面。它本质上是一个普通函数,只是被“注册”到了目标上。当类定义被求值时,装饰器函数会被自动调用一次。使用前需要在tsconfig.json中开启"experimentalDecorators": true,这也是很多初学者卡住的第一步。
装饰器的典型用途包括日志记录、权限校验、依赖注入、路由注册。比如NestJS里写@Controller('users'),这个@Controller就是一个装饰器,它不改变UserController内部的业务逻辑,只是在类定义时把路由前缀“贴”了上去。
小明给手机贴完膜,顾客满意地走了。装饰器的哲学正是如此——不追求“拆机改造”,而是用一层层“外包”来增强代码,这让代码的原始逻辑保持干净,也让增强能力可以被复用和组合。
- 装饰器是函数每个装饰器本质上是一个接收目标对象的普通函数,就像贴膜师拿到手机本体。
- @语法是糖@符号只是把目标传给装饰器并接收结果的简写,背后还是普通函数调用。
- 可替换可保留装饰器返回新对象就替换原目标,返回null则原目标保留不变。
类装饰器:整机包膜服务
今天店里来了一位“整机包膜”的顾客。他要的不是保护某个零件,而是给整个手机套上防摔壳。在TypeScript里,类装饰器就是这个“整机服务”——它作用在整个类上,像给类套了一层外壳。
类装饰器定义在类声明之前,接收唯一一个参数:类的构造函数。它接收的是“类的本体”,也就是那个可以被new的构造函数本身。你可以用它冻结类、扩展原型、添加静态属性,甚至返回一个全新的类来替换原来的类。
当装饰器返回一个新类时,这个新类会完全替换原类,新的对象实例都基于新类创建。通过class extends target的写法,可以给类追加字段或重写方法。这是实现“混入”(Mixin)功能的基础手段。
整机包膜让手机多了一层保护,顾客不必关心壳里是怎么走线的。类装饰器也一样:业务类不用管自己有没有被“包膜”,装饰器在类定义时静默完成一切。
- 唯一参数是构造函数类装饰器只接收类构造函数一个参数,就像整机包膜只需要手机本体。
- 可返回新类返回的新类会替换原类,所有后续实例都由新类创建。
- 惯用继承扩展用
class extends target继承原类再追加能力,是替换类时的常见写法。
function sealed(target: Function) { Object.freeze(target); Object.freeze(target.prototype);} @sealedclass User {}
方法装饰器:给摄像头贴镜片
手机美容店里常有一项服务:给摄像头贴蓝宝石镜片。顾客的手机主要功能是拍照,但镜头容易刮花。小明在镜头上加了保护,拍照功能不变,照片却不再模糊。方法装饰器对类里的方法做的正是这件事。
方法装饰器定义在方法声明之前,接收三个参数:第一个是类的原型对象(prototype),第二个是方法名(字符串),第三个是方法的属性描述符(PropertyDescriptor)。描述符里藏着value(函数本身)、writable、enumerable等控制选项,改写descriptor.value就能在方法调用前后插入逻辑。
这段代码展示了方法拦截的核心:先保存原始函数,再替换为一个“包装函数”,包装函数在调用前打印日志、调用后返回结果。apply(this, ...)保证this指向正确。这就是日志装饰器的底层原理,也是AOP(面向切面编程)在TypeScript中的实现方式。
给摄像头贴了镜片,拍照主体没变,照片却更清晰了。方法装饰器也是如此:原方法的逻辑完整保留,新的“镜片”在不打扰业务的前提下为方法增加了日志、校验或缓存能力。
- 第一参是原型实例方法存在原型上,装饰器拿到的是构造函数的prototype对象。
- 第二参是方法名被装饰方法的名字,是字符串类型,方便后续打印和索引。
- 第三参是描述符属性描述符包含value、writable、enumerable、configurable四个字段。
- 改写value实现拦截把descriptor.value换成包装函数,就完成了对原方法的“包膜”。
function log(target: any, key: string, descriptor: PropertyDescriptor) { const original = descriptor.value; descriptor.value = function (...args: any[]) { console.log('[日志] 调用', key, args); return original.apply(this, args); };}
属性装饰器:屏幕钢化膜
手机店里最受欢迎的单品是钢化膜。它贴在屏幕表面,不影响触控,却能在摔落时保护屏幕。属性装饰器就像钢化膜,贴在类的属性上,轻轻覆盖,不改变属性本身的读写逻辑。
属性装饰器定义在属性声明之前,接收两个参数:第一个是类的原型对象(静态属性则为构造函数),第二个是属性名。注意,它拿不到第三个descriptor参数,因此在默认的装饰器实现里,属性装饰器无法直接改写属性的value、writable等特性。它的返回值也会被忽略。
既然拿不到描述符,那属性装饰器有什么用?答案是“做标记”。它可以配合reflect-metadata把属性的附加信息(如字段长度、是否必填)记录下来,供框架后续读取。TypeORM的@Entity和@Column、class-validator的@IsEmail,本质都是先靠属性装饰器收集字段信息,再消费这些信息。
钢化膜贴在屏幕上,看不见摸不着改变,但摔手机时保护效果立竿见影。属性装饰器做标记时也是静悄悄的,等到框架需要读取这些标记时,它们才真正“显形”发力。
- 只有两个参数参数一是原型或构造函数,参数二是属性名字符串。
- 拿不到描述符属性装饰器不像方法装饰器那样能拿到descriptor,因此不能改读写特性。
- 返回void返回值被忽略,所以它只能做“贴标签”这类副作用操作。
function format(target: any, key: string) { console.log('属性装饰器:', key);}
参数装饰器:充电口防尘塞
手机美容店内还有一件不起眼的小配件——充电口防尘塞。它塞在手机的充电口里,不充电时防止灰尘进入,需要充电时拔掉即可。参数装饰器的作用对象,正是方法的“插孔”:参数位置。
参数装饰器定义在方法参数之前,接收三个参数:第一个是类的原型对象,第二个是方法名(如果是构造函数参数则忽略该参数值),第三个是参数在参数列表中的索引数字。第一个参数索引为0,第二个为1,依此类推。
参数装饰器无法获取参数的值,也无法修改参数内容。它只能在参数上“盖章”,记录这个位置有特殊语义。最常见的使用是配合reflect-metadata记录“参数的类型”或“参数是否必填”,再由方法装饰器读取这些元数据去执行真正的校验逻辑。NestJS的@Param('id')、@Body()就是参数装饰器的经典应用。
防尘塞不参与充电过程,却让充电口保持干净。参数装饰器不参与方法实际运算,却把“这位置有讲究”的信息留给框架,让依赖注入和数据校验变得自动化。
- 前两参与方法装饰器一致第一参是原型对象,第二参是所在方法的名字。
- 第三参是索引参数索引从0开始,第0个参数对应paramIndex=0。
- 不能改参数值装饰器拿不到实参,所以无法拦截或修改传入的值。
function required(target: any, key: string, index: number) { console.log('第', index, '个参数被标记');}
装饰器工厂:定制套餐工坊
顾客多了,小明发现“统一贴膜”满足不了所有人:有人要磨砂膜,有人要防蓝光膜,有人要带图案的。于是店里推出“定制套餐”——顾客选好款式材质,工坊按配方生产对应的膜。装饰器工厂就是这套“定制工坊”。
装饰器工厂是一个返回装饰器函数的函数。它接收配置参数,在参数里定制装饰器的行为,然后返回真正被@使用的装饰器函数。调用工厂时需要在@后加括号传参,比如@log('用户模块')。
这在手写框架时特别有用:同样的“打日志”装饰器,通过工厂传入不同的前缀、级别、输出目标,就能复用在各个业务模块。NestJS里的@Controller('users')、@Get(':id')、@SetMetadata('role', 'admin'),全是装饰器工厂——先传路由或配置,再返回真正干活的装饰器。
定制套餐让每个顾客拿到自己心仪的膜。装饰器工厂让每个模块拥有专属配置的装饰器——同样的Log装饰器,给订单模块加“订单”前缀,给用户模块加“用户”前缀,代码复用率高且互不干扰。
- 工厂是外层函数工厂本身不是装饰器,它是生产装饰器的函数,输入配置参数。
- 返回的才是装饰器调用工厂后得到的函数才是真正挂在@后的装饰器函数。
- 用法是函数调用
@factory(opt)先执行工厂求得装饰器,再把装饰器应用到目标上。
function log(prefix: string) { return function (target: any, key: string, descriptor: PropertyDescriptor) { const fn = descriptor.value; descriptor.value = function (...args: any[]) { console.log(prefix + key, args); return fn.apply(this, args); }; };}
执行顺序:先贴内层再贴外层
一位顾客同时买了钢化膜、防摔壳和镜头贴。小明操作的顺序是:先贴镜头贴,再贴屏幕膜,最后套外壳。这个顺序很讲究——先做的“内层”,后做的“外层”,外层的壳包裹着内层的膜。装饰器的执行顺序同样是这个“洋葱模型”。
当多个装饰器同时挂在一个目标上时,规则有两条:第一,装饰器表达式(工厂调用)按从上到下的顺序求值;第二,求值得到的装饰器函数按从下到上的顺序调用。也就是说,@dec1 @dec2中dec2先执行,dec1后执行,dec1包裹在dec2外面。
不同类型装饰器应用在同一类上时,顺序也有约定:实例成员的参数装饰器先执行,然后是同成员的属性/方法装饰器(从右到左对每个成员处理),之后是构造器参数装饰器,最后才是类装饰器。属性/参数装饰器最“内层”,类装饰器最“外层”。
小明把最贴身的膜先贴好,再把壳套在外面,保护层层层叠加。装饰器的洋葱模型也一样:内层装饰器先感知原始行为,外层装饰器在它外围做进一步处理,顺序反了,效果就完全不同。
- 表达式从上到下求值
@dec1 @dec2中dec1的工厂/引用先求值,dec2后求值。 - 调用从下到上求值得到的函数按从右到左(从下到上)的顺序被调用,dec2先执行。
- 外层包裹内层后执行的dec1在外层,先执行的dec2在内层,形成洋葱。
@dec1@dec2class User {} // dec2先执行,dec1后执行,最终效果 dec1(dec2(User))
reflect-metadata:手机档案标签
手机美容店生意好了,小明给每台手机贴一张小标签,写上“贴膜日期”“保修期”“客户偏好”。标签不改变手机功能,却让店里能快速查到每台手机的服务历史。reflect-metadata就是给代码对象贴“档案标签”的工具包。
reflect-metadata是一个npm库,为JavaScript/TypeScript提供了元数据读写能力。核心API包括Reflect.defineMetadata(key, value, target)写入、Reflect.getMetadata(key, target)读取、Reflect.hasMetadata判断。装饰器是“贴纸”,元数据是“贴纸上的字”——装饰器执行时通过defineMetadata把信息写到类或方法上,其他代码再通过getMetadata读出来。
这个库是NestJS、TypeORM等框架的基石。TypeORM的@Entity('user')会把表名写入元数据;NestJS的@Controller('users')把路由前缀写入元数据;依赖注入容器则通过元数据知道“这个构造参数要注入哪个服务”。装饰器负责收集信息,reflect-metadata负责存储和读取信息,二者配合完成元编程。
标签贴在手机上,手机自己不知道,但店员一查就知道该给这手机用什么保养方案。reflect-metadata把装饰器标记的信息变成可查询的数据,让框架能在运行时“读懂”代码结构,这就是NestJS能自动配路由、TypeORM能自动建表的原因。
- key是元数据键名键通常是字符串或Symbol,用来区分不同类型的元数据。
- target是承载对象元数据挂在类构造函数、原型或方法对应的对象上。
- 返回元数据值getMetadata读到键对应的值,找不到时返回undefined。
import 'reflect-metadata'; Reflect.defineMetadata('role', 'admin', User);const role = Reflect.getMetadata('role', User); // 'admin'
AI 应用场景
NestJS控制器路由注册
阶段:Web框架配置与路由注册
解决的问题:传统Express写路由需要手动app.get('/users/:id', handler),路由分散且容易漏写。NestJS用类装饰器@Controller和方法装饰器@Get,在类定义时自动完成路由映射,路由前缀与方法一一对应,代码集中且声明式。
具体案例:开发用户管理接口时,只需写@Controller('users')和@Get(':id'),NestJS启动时读取装饰器写入的元数据,自动把GET /users/:id绑定到该方法上。新增接口只需加一个方法,不必再找路由文件。
TypeORM实体映射
阶段:数据持久层建模
解决的问题:传统ORM需要手写大量XML映射文件,字段多时极易出错。TypeORM用@Entity、@Column属性装饰器在实体类上直接声明表名、列名、类型,一处定义处处复用。
具体案例:定义一个User实体类,用@Entity('user')标在类上、@Column({name: 'user_name', length: 50})标在属性上,启动时框架读取元数据自动建表或校验表结构,字段变更只需改一处代码。
依赖注入容器
阶段:IoC容器与模块装配
解决的问题:大型项目中手动new依赖、传构造函数参数容易产生耦合。NestJS利用参数装饰器@Inject和构造器参数元数据,让容器自动找到并注入对应服务。
具体案例:在Service构造器参数前写@Inject('REDIS'),容器读到参数装饰器记录的“这个位置需要REDIS”,启动时将已注册的Redis客户端实例自动注入。换实现时只需改注册处的绑定,Service代码不用动。
Python 演示
import functoolsimport time def log_execution(func): """Print method call log like a TS method decorator.""" @functools.wraps(func) def wrapper(*args, **kwargs): print("[log] call {} args={} kwargs={}".format(func.__name__, args[1:], kwargs)) start = time.time() result = func(*args, **kwargs) cost = (time.time() - start) * 1000 print("[log] {} returned {} (cost {:.2f}ms)".format(func.__name__, result, cost)) return result return wrapper def validate_positive(func): """Validate amount is positive like a TS param decorator.""" @functools.wraps(func) def wrapper(self, amount): if amount <= 0: raise ValueError("amount must be positive") return func(self, amount) return wrapper class BankAccount: """A bank account class with decorators as phone stickers.""" def __init__(self, owner, balance=0): self.owner = owner self.balance = balance @log_execution def deposit(self, amount): self.balance += amount return self.balance @log_execution @validate_positive def withdraw(self, amount): if amount > self.balance: raise ValueError("insufficient balance") self.balance -= amount return self.balance def main(): account = BankAccount("xiaoming", 1000) account.deposit(500) account.withdraw(200) print("final balance:", account.balance) if __name__ == "__main__": main()
def log_execution(func):装饰器本质是接收函数的函数,对应TS装饰器的“贴膜师”角色def wrapper(*args, **kwargs):返回的包装函数,代替原方法执行,对应TS方法装饰器改写descriptor.valueresult = func(*args, **kwargs)调用原始方法,保留原逻辑,像贴膜不影响手机功能@log_executionPython语法糖,等价于deposit = log_execution(deposit),和TS的@写法同源if amount <= 0:参数校验逻辑,体现TS中“参数装饰器做标记、真正校验靠方法装饰器”的思想
常见误区
误区我一开始以为写了@装饰器,目标的方法逻辑或属性值一定会被改变,比如方法被换掉、属性被重写。如果装饰器没有改变任何东西,就觉得它没用。
正解装饰器完全可以只做“标记”。像给手机贴一张小标签,手机该什么样还是什么样。配合reflect-metadata把信息写进元数据,类定义完成后另一个函数再读取这些信息去干活,这是框架中最常见的用法。装饰器改行为只是其中一个分支,不是全部。
为何常见:教程里最爱展示日志装饰器、拦截装饰器这类“改了行为”的例子,看得多了就以为装饰器非改不可。
误区以为`@outer @inner`就是先执行outer,再执行inner,像剥洋葱一样从外层往里剥。
正解TypeScript的规则是:表达式从上到下求值,函数从下到上调用。@outer @inner中inner先执行,outer后执行。最终outer拿到的是inner处理后的结果,所以看起来是outer包在inner外面。类比贴膜:先贴内层膜,再贴外层壳,壳最后套上却是最外面那层。
为何常见:看到“洋葱模型”这个词就想起“先入后出”,但装饰器的求值和调用分两个阶段,只记住一个阶段就容易弄反。
误区方法装饰器能拿到descriptor改写value,我猜属性装饰器应该也能拿到descriptor,去改属性的可写性、默认值之类的。
正解属性装饰器只有两个参数——原型对象和属性名,没有descriptor,返回值也被忽略(TypeScript 5.0之前的默认装饰器实现里)。它根本摸不到属性描述符,所以不能拦截属性赋值。如果想控制属性的读写行为,应该用访问器装饰器(装饰getter/setter)或方法装饰器。
为何常见:方法装饰器的三个参数太深入人心,初学者容易把方法和属性混为一谈,忽略了属性装饰器是“轻量版”。
误区我以为装饰器会像方法本身一样,每次`new`一个对象、每次调用一次方法,装饰器都重新跑一遍。
正解装饰器只在类被定义时执行且只执行一次。它在类定义求值阶段完成对被装饰目标的包装或标记,之后每次调用方法执行的是“被包装后的函数”,而不是装饰器本身。装饰器相当于贴膜的那一次动作,贴完就结束了,之后每次用手机都是贴着膜用。
为何常见:方法装饰器包装的wrapper在方法调用时运行,让人误以为装饰器本身也在每次调用时运行。
误区我最初在参数装饰器里写if判断,想拦截非法参数,结果发现根本不生效,参数还是原样传进去了。
正解参数装饰器拿不到参数值,也没有descriptor,无法拦截或修改参数。它只能在元数据里记下“这个位置的参数有特殊语义”。真正的参数校验必须通过方法装饰器包装方法体,在wrapper里读取参数并校验,这也是NestJS参数校验管道底层的做法。
为何常见:校验是刚需,直觉上觉得“装饰在参数上就应该能拦住参数”,但装饰器本质上只是注册时的一次函数调用而已。
面试题
Q1. TypeScript中装饰器的本质是什么?
- A.一种特殊的类型注解
- B.一个普通的函数← 正确答案
- C.一种内置的数据结构
- D.一个命令行工具
答案:B。装饰器本质是函数,通过@语法挂到类、方法、属性、参数上,在类定义时被调用。类型注解只是给编译器看的标签,装饰器是真正会执行代码的函数。C和D与装饰器的函数本质无关。
Q2. 在项目中使用装饰器,tsconfig.json需要开启哪个选项?
- A."strict": true
- B."noImplicitAny": true
- C."experimentalDecorators": true← 正确答案
- D."esModuleInterop": true
答案:C。装饰器属于实验性特性,必须在tsconfig.json中设置"experimentalDecorators": true才能启用。strict和noImplicitAny是类型检查选项,esModuleInterop是模块互操作选项,与装饰器是否启用无关。
Q3. 方法装饰器接收的第一个参数是什么?
- A.类的构造函数
- B.方法的属性描述符
- C.类的原型对象← 正确答案
- D.方法的名字字符串
答案:C。实例方法挂在原型上,所以方法装饰器的第一个参数是类的原型对象(静态方法则是构造函数)。构造函数是类装饰器的参数;描述符和名字分别是第二、三个参数。
Q4. 关于类装饰器的返回值,下列说法正确的是?
- A.可以返回一个新类来替换原类← 正确答案
- B.必须返回undefined否则报错
- C.只能返回void不能返回类
- D.返回值会被编译器忽略
答案:A。类装饰器的返回值会被作为替换后的构造函数。返回新类,原来的类就被替换;不返回则保留原类。返回void和返回新类都是合法写法,不存在“必须返回undefined”的限制。
Q5. 以下哪种装饰器可以拿到属性描述符?
- A.属性装饰器
- B.参数装饰器
- C.方法装饰器← 正确答案
- D.类装饰器
答案:C。方法装饰器接收(target, key, descriptor)三个参数,descriptor是属性描述符,可以改写。属性装饰器和参数装饰器都拿不到descriptor,类装饰器只接收构造函数。这是四种装饰器最核心的差异之一。
Q6. 代码@factory(config)中,这种写法依赖什么机制?
- A.装饰器继承
- B.装饰器工厂← 正确答案
- C.装饰器重载
- D.装饰器混入
答案:B。@factory(config)是装饰器工厂的调用形式:先调用工厂函数,传入配置参数,得到真正的装饰器函数,再把这个装饰器应用到目标上。工厂的意义是让装饰器可以通过参数定制行为,而不是写死逻辑。
Q7. 对于代码@A @B class X {},其中A和B是装饰器函数,下列说法正确的是?
- A.先调A再调B,表达式从下到上求值
- B.先调B再调A,表达式从下到上求值
- C.先调B再调A,表达式从上到下求值← 正确答案
- D.先调A再调B,表达式从上到下求值
答案:C。TypeScript规定多个装饰器应用时,表达式从上到下求值(A的表达式先求值),函数调用从下到上(B先被调用,A后调用),最终效果是A包裹B,即A(B(X))。这就像先贴内层膜B再贴外层壳A。
Q8. 以下哪些位置可以合法使用装饰器?
- A.类声明上← 正确答案
- B.方法定义上← 正确答案
- C.函数体内的局部变量上
- D.参数位置上← 正确答案
答案:ABD。装饰器可以用于类声明、方法、属性、参数以及构造器参数,用途由目标类型决定。函数体内部的局部变量不能使用装饰器,因为装饰器只能附着在类结构成员上,不能脱离类上下文。
Q9. 方法装饰器接收的参数包括哪些?
- A.类的原型对象← 正确答案
- B.方法的名字← 正确答案
- C.方法的属性描述符← 正确答案
- D.方法的参数个数
答案:ABC。方法装饰器的三个参数依次是原型对象(静态方法为构造函数)、方法名字符串、属性描述符。参数个数不在其中——参数个数信息需要通过参数装饰器的索引或Function.length获取,不直接传给方法装饰器。
Q10. reflect-metadata库的主要用途是什么?
- A.给装饰器提供运行时类型检查
- B.在对象上存储和读取元数据← 正确答案
- C.替代装饰器实现AOP编程
- D.编译TypeScript到JavaScript
答案:B。reflect-metadata提供Reflect.defineMetadata、Reflect.getMetadata等API,用于在对象上读写键值对元数据。装饰器负责“贴标签”,reflect-metadata负责“存标签、读标签”,二者配合支撑NestJS、TypeORM等框架的元编程能力。