#JavaScriptDecorators装饰器实战:用@重构类与方法(TC39Stage3)去年我在给一个跑了三年的老项目做TypeScript迁移的时候,信心满满地往代码里怼了一堆@log、@readonly、@…
JavaScript Decorators 装饰器实战:用 @ 重构类与方法(TC39 Stage 3)
JavaScript Decorators 装饰器实战:用 @ 重构类与方法(TC39 Stage 3)
去年我在给一个跑了三年的老项目做 TypeScript 迁移的时候,信心满满地往代码里怼了一堆 @log、@readonly、@cache,本地跑得好好的,CI 也绿了,我还跟同事吹了一句"装饰器这玩意儿我早玩透了"。结果没过两周,有个同事甩给我一条 TC39 的链接,轻飘飘来一句:"装饰器现在进 Stage 3 了,语法整个重写了,你那套过时了。"我点开一看,当场破防——我写得那些 (target, propertyKey, descriptor) 全成了官方嘴里的"legacy 装饰器",真正的 Stage 3 提案里,装饰器函数收的参数是 (value, context),跟我之前用的完全不是一回事。
说白了,我以为自己掌握了装饰器,其实只是掌握了被新提案推翻大半的旧写法。这种"我学的明明是标准,结果标准是上一代"的错觉,在 JavaScript 这个一年一个样的语言里简直家常便饭。今天这篇文章,就是把我自己踩的这个坑、以及后来花了一个周末摸清楚的两种写法,原原本本摊开讲给你听,省得你也经历一遍我那种"学了个寂寞"的暴击。
顺便多嘴一句,很多人一听到"元编程"就头大,觉得那是框架作者才玩的高端玩意儿。其实装饰器就是把"元编程"这件事降维到了普通人也能天天用的程度。你不需要去手动改写函数的 prototype,也不需要吃透 Object.defineProperty 的全部细节,只要会写 @ 后面跟个函数,就能给类和方法"动手术"。这也是为什么我从最初的"这啥高级魔法"到后来"真香"——它把原本只有高手才 hold 住的元编程,变成了下班前顺手就能加的一个标签。
先不管新旧写法,咱们得先说清楚装饰器到底是个啥。它本质就是一种语法糖:用 @ 这个符号,把一个函数"贴"到类、方法、属性或者参数上,让这个函数在编译或者类定义阶段去"包装"或者"劫持"被贴的那个目标。它不改动你原来的业务逻辑,只是把日志、权限、缓存这种"横切关注点"(cross-cutting concerns)从业务代码里抽出来,像便利贴一样贴上去。
这个思路和 Python 的 @decorator、Java 的注解是一脉相承的。在 JavaScript 里,它最大的价值就一句话:让类和方法长得更干净。你本来要在一堆业务方法里手写 try/catch、手写计时、手写权限判断,现在通通交给 @ 去管。业务方法里只剩纯粹的逻辑,横切的那层脏活由装饰器兜底。
一、类装饰器:给整个类动手术
类装饰器是五种装饰器里最"粗犷"的一种,它作用于整个类的构造函数。在大家更熟悉的 legacy 写法里,装饰器函数只收一个参数 target,这个 target 就是被装饰的类本身(也就是构造函数)。你可以直接给它挂静态属性,也可以通过 target.prototype 往原型上塞实例方法。
javascript// 类装饰器(legacy 写法,对应 TypeScript experimentalDecorators / Babel legacy 模式)// 装饰器函数接收的第一个参数 target,就是被装饰的类本身(构造函数)function version(v) { // 返回一个真正的装饰器函数,v 是外部传进来的参数 return function (target) { // 直接给类挂一个静态属性 version,所有实例和类本身都能读到 target.version = v; };}// 用 @ 把装饰器贴到类上,等价于 ApiClient = version("1.2.0")(ApiClient) || ApiClient@version("1.2.0")class ApiClient {}console.log(ApiClient.version); // 输出"1.2.0"

上面这小段代码,就是类装饰器最经典的形态。你可能看出来了:@version("1.2.0") 这种"带括号"的写法,其实是两层函数嵌套——外层 version(v) 收你的参数,返回内层那个真正的装饰器。这种套路在装饰器里有个专属名字,叫"装饰器工厂",后面会专门讲。
光挂个 version 当然没意思。类装饰器真正能打的场合是"框架集成":Angular 的 @Component、MobX 的 @observer,本质上都是类装饰器在类定义时偷偷往类上挂元数据或者改写原型。我之前在接一个老 Angular 模块的时候就发现,光看类本身的代码根本不知道它为什么突然多了那么多能力,最后才反应过来是被装饰器在编译期"动过手术"了。这种"看不见的增强"既是装饰器的爽点,也是它最容易被吐槽为"魔法"的地方。
二、方法装饰器:最常用也最容易翻车
类装饰器能玩的花样其实有限,真正天天用的是方法装饰器。在 legacy 写法里,它收三个参数:target(类的原型对象)、name(被装饰的方法名)、descriptor(属性描述符,就是 Object.defineProperty 用到的那个对象)。你拿到 descriptor.value,就能把它换成"包了一层"的新函数,前后各插一刀。
javascript// 方法装饰器(legacy 写法):自动打印方法的调用日志function log(target, name, descriptor) { // descriptor.value 就是被装饰的原始方法函数 const original = descriptor.value; // 用一个新的函数把它包起来,在调用前后各打一行日志 descriptor.value = function (...args) { console.log(`调用 ${name},参数:`, args); const result = original.apply(this, args); // 保持 this 和参数不变 console.log(`↩️ ${name} 返回:`, result); return result; }; return descriptor; // 关键:必须把修改后的 descriptor 返回出去,否则改动不生效}class UserService { @log getUserName(id) { return `user_${id}`; }}new UserService().getUserName(42);// 调用 getUserName,参数: [42]// ↩️ getUserName 返回: user_42
我第一次写方法装饰器的时候,就栽在"忘记 return descriptor"上——函数跑了,但方法根本没被替换,排查了半天才发现是忘了把 descriptor 还回去。这种只有写的人知道、读的人一脸懵的坑,就是装饰器被叫"魔法"的另一个原因。
除了打日志,方法装饰器还特别适合做只读保护。比如下面这个 @readonly,直接把 descriptor.writable 设成 false,方法就再也不能被重写:
javascript// 方法装饰器(legacy):把方法设为只读,防止被意外覆盖function readonly(target, name, descriptor) { // writable 控制属性能否被重新赋值,设成 false 就锁死了 descriptor.writable = false; return descriptor;}class Person { @readonly sayHi() { return"hi"; }}// 下面这行在严格模式下会直接抛 TypeError:Cannot assign to read only property// Person.prototype.sayHi = function () { return"bye"; };
还有个很实用的 mixin 套路:用类装饰器配合 Object.assign,把一组方法批量混入原型,避免写一堆继承:
javascript// 用类装饰器做 mixin:把外部对象的方法批量挂到类的原型上function mixins(...list) { return function (target) { Object.assign(target.prototype, ...list); };}const LoggerMixin = { log(msg) { console.log(`[log] ${msg}`); }};@mixins(LoggerMixin)class OrderService {}const svc = new OrderService();svc.log("订单创建成功"); // 原型上已经有了 log 方法,能直接调用
三、装饰器工厂:让 @ 也能传参
前面 @log、@readonly 都是"无参装饰器",可实际项目里你几乎一定想传参。这就需要装饰器工厂:外层函数收你的参数,返回一个真正的装饰器函数。下面这个 @debounce 就是典型——给搜索框的 onInput 做个 300ms 防抖,参数就是等待时间。
javascript// 装饰器工厂:外层收参数 waitMs,内层才是真正的装饰器function debounce(waitMs) { return function (target, key, descriptor) { const originalMethod = descriptor.value; // 用 lodash 的 debounce 把原方法包成防抖版本 // 注意:这里依赖外部引入的 _(lodash),生产环境别漏装 descriptor.value = _.debounce(originalMethod, waitMs); return descriptor; };}class SearchBox { @debounce(300) // 传参!这就是工厂模式存在的意义 onInput(value) { console.log("search:", value); }}
工厂这层嵌套刚开始看着绕,但你把它想成"先配置、再返回真正干活的函数"就通了。我自己的习惯是:只要装饰器需要任何外部参数(时间、角色名、缓存 key 生成规则),一律走工厂,绝不为了偷懒把常量写死在装饰器函数里——写死了后面改起来想骂人。
三补、装饰器与高阶函数:它到底是不是语法糖
肯定有人会杠:这不就是高阶函数吗?我直接写一个 wrap(fn) 把方法包起来不也一样?没错,legacy 装饰器的本质确实就是高阶函数——上面那个 @version("1.2.0") class ApiClient {},在编译后等价于 ApiClient = version("1.2.0")(ApiClient) || ApiClient,剥掉 @ 糖衣就是一层函数调用。所以"装饰器 = 高阶函数的语法糖"这句话在 legacy 时代是完全成立的。
但 Stage 3 的新提案让这件事没那么简单了。它新增了 context.addInitializer 这种"在类/实例初始化时插一段逻辑"的钩子,还有 accessor 这种能同时接管 get/set/init 三阶段的能力,这些都是单纯的高阶函数套一层很难优雅表达的东西。换句话说,新装饰器已经不止是"包个函数"那么简单,它更接近一种"声明式的类成员变换协议"。理解了这一层,你就不会再把装饰器当成花哨的语法糖,而是当成正经的元编程原语。
四、惊天大坑:你写的可能是 legacy,不是 Stage 3
讲到这儿,如果你只看过老一点的教程,会觉得"装饰器不就这点东西嘛"。但前面说的那个让我破防的坑,现在正式摊开讲:上面这一整套 (target, propertyKey, descriptor) 的写法,是 TypeScript 的 experimentalDecorators 和 Babel 的 legacy 模式实现的,它对应的是早就被推翻的旧提案(社区习惯叫它 legacy 装饰器,或者 Stage 2 时期的那一版)。而你现在去搜"TC39 Stage 3 装饰器",看到的完全是另一套签名的新提案。
换句话说,标题里写的"TC39 Stage 3",和你我过去在 TS 里写的 @,底层根本不是一种东西。下面这个是 Stage 3 提案里方法装饰器的真实写法:
javascript// ✅ TC39 Stage 3 装饰器(2023-11 修订版)的真实签名// 装饰器函数只收两个参数:value(被装饰的值本身)+ context(上下文对象)function logged(value, { kind, name }) { // 用 context.kind 判断当前装饰的是方法 / 类 / 字段等 if (kind ==="method") { // 直接返回一个新的函数,把原方法 value 包起来 return function (...args) { console.log(`starting ${name} with arguments ${args.join(",")}`); const ret = value.call(this, ...args); console.log(`ending ${name}`); return ret; }; }}class C { @logged // 还是同样的 @ 语法,但底层签名已经彻底变了 m(arg) {}}new C().m(1);// starting m with arguments 1// ending m
看到区别了吗?新提案里,方法装饰器拿到的 value 就是方法函数本身,context 是一个带着 kind、name、addInitializer、access 等字段的对象。你不再去扒 descriptor.value 做替换,而是直接 return 一个新函数兜底。这种"拿到值、返回新值"的模型,比 legacy 那种"改 descriptor 再还回去"的模型直观不少,但也意味着:legacy 的装饰器代码,在 Stage 3 的工具链下会直接编译报错,两边不互通。
类装饰器在 Stage 3 里也换成了 (value, context) 的签名,而且更优雅——你可以直接返回一个继承原类的新类:
javascript// Stage 3 的类装饰器:同样收 (value, context)function logged(value, { kind, name }) { if (kind ==="class") { // 返回一个继承原类的新类,在构造时插一脚 return class extends value { constructor(...args) { super(...args); console.log(`constructing an instance of ${name} with arguments ${args.join(",")}`); } }; }}@loggedclass C {}new C(1);// constructing an instance of C with arguments 1
五、五种装饰器类型,以及 Stage 3 砍掉的东西
不管 legacy 还是 Stage 3,装饰器都能贴到五类目标上,这也是这个主题最核心的知识点:类(class)、方法(method)、访问器(getter/setter,Stage 3 里归到 accessor)、字段(field)、参数(parameter)。legacy 写法靠"收几个参数 + 参数类型"来区分,Stage 3 则统一用 context.kind 的这几个值来判断:"class"、"method"、"getter"、"setter"、"field"、"accessor"。
其中字段装饰器和自动访问器(accessor)是 Stage 3 新增、也最实用的两个。字段装饰器能拦截字段的初始化值,自动访问器能拦截 getter/setter 的读写——这俩在响应式框架里简直是神器。看真实写法:
javascript// Stage 3 字段装饰器:value 这里其实是 undefined,context.kind 为"field"function track(value, { kind, name }) { if (kind ==="field") { // 返回一个函数,接收字段的初始值,返回要真正赋下去的值 return function (initialValue) { console.log(`初始化字段 ${name},初值为 ${initialValue}`); return initialValue; }; }}// Stage 3 自动访问器装饰器:用 accessor 关键字定义,能拦截读写function loggedAccessor(value, { kind, name }) { if (kind ==="accessor") { let { get, set } = value; return { get() { console.log(`读取 ${name}`); return get.call(this); }, set(val) { console.log(`写入 ${name} = ${val}`); return set.call(this, val); }, init(initialValue) { console.log(`初始化 ${name} = ${initialValue}`); return initialValue; } }; }}class C { @track x = 1; @loggedAccessor accessor y = 2;}const c = new C();c.y; // 读取 yc.y = 10; // 写入 y = 10
这里必须泼一盆冷水:参数装饰器在最新的 TC39 Stage 3 提案里被砍掉了。context.kind 的取值里压根没有 "parameter"。所以像 Angular 那种把 @Inject('token') 直接贴在构造器参数上的写法,在纯 Stage 3 里是不存在的,得改用字段装饰器或者类装饰器配合 reflect-metadata 去曲线救国。legacy 写法里参数装饰器是有的,它收三个参数 (target, name, index),index 是参数在形参列表里的位置:
javascript// legacy 参数装饰器(注意:Stage 3 新提案已移除参数装饰器!)function required(target, name, index) { console.log(`方法 ${name} 的第 ${index} 个参数被标记为必填`);}class User { login(@required username, password) { // ...业务逻辑 }}
我第一次知道参数装饰器被砍的时候,心里是真的咯噔一下——因为手头一个项目正好重度依赖 Angular 风格的 @Inject。后来想通了:Stage 3 的设计哲学是"装饰器只作用于有名字的类成员",参数没有稳定名字,所以被拿掉了。理解了这个动机,反而觉得砍得有道理。
六、用 @ 重构类与方法:这才是标题说的重点
讲了这么多签名细节,回到标题里的"用 @ 重构类与方法"。装饰器最大的爽点,就是把散落在业务方法里的横切逻辑抽出来,让方法回到"只关心业务"的状态。看个真实点的例子:一个订单服务,每个方法都要计时、要鉴权、要缓存。
javascript// 重构前:计时、鉴权、缓存全和业务逻辑搅在一起,又臭又长,改一个地方怕动三个class OrderService { getOrder(id, user) { const start = performance.now(); if (!user.isAdmin) throw new Error("无权限"); const cacheKey = `order_${id}`; if (cache.has(cacheKey)) return cache.get(cacheKey); const data = db.findOrder(id); cache.set(cacheKey, data); console.log(`耗时 ${(performance.now() - start).toFixed(2)}ms`); return data; }}// 重构后:三个关注点各自成一个装饰器,业务方法里只剩纯粹的逻辑class OrderService { @timing // 自动计时 @auth("admin") // 自动鉴权 @cache((id) => `order_${id}`) // 自动缓存,key 由工厂生成 getOrder(id) { return db.findOrder(id); // 干干净净,一眼能看懂"要什么" }}
同样的活,重构后读起来像在读"这个方法的意图",而不是"这个方法的实现细节"。这就是 @ 注解(annotation)写法的魅力——代码在声明它要什么,而不是罗列它怎么做。顺便说一句,这种"声明式增强"的思路,和 CSS Houdini 实战:用 Paint/Animation Worklet 突破原生 CSS 限制(自定义绘制) 在 CSS 层做的事其实是同一个哲学:把能力以声明的方式贴到目标上,而不是手把手去改底层实现。
七、执行顺序:剥洋葱的坑
多个装饰器叠一起时,执行顺序是个经典坑,我愿称之为"新手劝退第一关"。legacy 写法里有个反直觉的规则:装饰器是从上到下评估,但从下到上执行,像剥洋葱——外层先进入,内层先执行。Stage 3 提案里顺序也有明确定义,类装饰器的 addInitializer 会在类定义之后、按注册顺序依次跑。所以千万别假设"上面的先跑",真要依赖顺序时,老老实实写注释,或者把职责拆细,一个装饰器只干一件事,别写个巨无霸装饰器指望它按顺序接管一切。
如果你用过 Koa 或者 Express 的中间件,会发现这个"洋葱模型"似曾相识——请求从外往里进、再从里往外出,每一层都能在前后插手。装饰器叠在一起时也是这个感觉:最外层的 @ 先"包裹"上来,但最内层的逻辑最先被执行。把装饰器想象成一层层洋葱皮,你写的业务方法就是最里面的芯,每次调用都从外皮一层层剥到芯、再一层层退出来。这个比喻帮我少掉了不少"到底先跑哪个"的调试时间,也让我后来读 Nest 那种满屏 @ 的代码时,不至于被嵌套顺序绕晕。
八、实战场景:哪些活儿适合丢给装饰器
前面源文里列的几个场景我都验证过能落地,这里摊开说:
权限控制:用 @auth(role) 在方法执行前查用户角色,没权限直接抛错或者短路返回,业务方法里完全不用出现 if (!user.xxx) 这种判断。
请求拦截与日志:用 @logRequest 把 fetch 包一层,自动记接口调用时间、入参、响应,比在 Fetch + AbortController + Streams 实战:现代请求的中断与流式读取(进度/取消) 里到处写 try/catch 和 console.log 清爽太多。你甚至可以做一个 @retry(3) 自动重试三次,网络抖一下用户根本无感。
缓存管理:用 @cache 按参数生成 key、塞进 Map,对纯查询型方法特别香,避免重复计算。做性能监控的话,与其在每个方法里手写 performance.now(),我更推荐配合 Performance API 实战:用 Navigation/Resource Timing 做前端性能监控(Web Vitals)做一套系统化的采集,装饰器负责"埋点",性能平台负责"分析",各司其职。
防抖与节流:搜索框、滚动监听这类高频触发,@debounce / @throttle 一键搞定,不用每次都手动包 lodash。
自动绑定 this:React 老写法的 @autobind,省得每回都在构造函数里 this.fn = this.fn.bind(this)。
响应式增强:MobX 的 @observable、Angular 的 @Injectable,本质都是装饰器在类或字段上挂元数据,框架启动时读取这些元数据完成依赖注入或响应式绑定。
如果装饰器里包的是纯计算、又怕堵主线程,重活可以丢给Web Workers 实战:把计算密集型任务丢给后台线程(SharedWorker 与多线程通信),装饰器负责"声明要算",Worker 负责"后台去算",思路一脉相承。
当然也要泼盆冷水:装饰器不是银弹。它最适合的是"横切、复用、声明式"的场景,不适合塞业务逻辑。我见过有人把一整套业务流程都写进装饰器链,结果 debug 的时候得一层层剥 @,比看意大利面条还痛苦。记住一句话:装饰器是用来"增强"的,不是用来"实现"的。
还有一个容易被人忽略的点:装饰器本身只负责"贴"和"变换",它记不住东西。想做依赖注入、想让 @Injectable 知道这个类要注入啥,得配合 reflect-metadata 这类反射库,把元数据存到类的原型上,框架启动时再去读。源文里提到的 Reflect.metadata / Reflect.defineMetadata 就是这个用途——装饰器负责"打标签",反射 API 负责"存标签和读标签",俩是搭档不是替代。别指望光写个装饰器就能凭空实现 DI,那是另一个坑,踩过的人都懂。
九、配置坑:别抄了代码就跑
最后聊最现实的一关——装饰器不是浏览器原生运行时特性(截至本文,原生支持还得看各家引擎的 flag 进度)。你必须配编译工具链,这也是最容易翻车的地方,我称之为"编译期玄学":
Babel 要装 @babel/plugin-proposal-decorators。注意它有两个模式:{"legacy": true} 是旧签名 (target, name, descriptor);{"version": "2023-11"} 才是 Stage 3 提案重点的 (value, context) 新签名。两种模式的装饰器代码完全不互通,选错直接编译报错,别问我怎么知道的(问就是曾经对着红色报错盯到凌晨三点)。
TypeScript 里把 experimentalDecorators 和 emitDecoratorMetadata 都打开。前者开装饰器语法,后者用来发射类型元数据,配合 reflect-metadata 做依赖注入。但务必记住:TS 当前的 experimentalDecorators 实现的是 legacy 签名,想用 Stage 3 新语法要么走 Babel,要么等 TS 后续版本。
装饰器只能贴到类和类成员上,不能贴普通函数或变量,原因是存在函数提升问题(函数声明会被提到作用域顶部,装饰器来不及绑定)。想装饰普通函数,老老实实用高阶函数包一层,别硬刚。
十、小结:我的人话版最佳实践
摸完两种写法,我自己的结论是:今天能直接落地的,还是 legacy 装饰器(TS / Babel legacy 模式),它成熟、资料多、主流框架(Nest、Angular、MobX、TypeScript)全在用;但面向未来,你必须懂 Stage 3 的 (value, context) 签名,因为它才是标准正主,哪天 TS 切过去你才不至于再经历一次我这种"学了个寂寞"的暴击。
几条不加包装的最佳实践:
第一,装饰器只放横切关注点,别把业务逻辑塞进去——否则你只是把意大利面条换了个地方挂,该乱还是乱。
第二,一个装饰器只干一件事,需要叠加就叠多个 @,别写个巨无霸装饰器又计时又鉴权又缓存,出问题时你根本不知道是哪层炸的。
第三,凡是需要外部参数,一律走装饰器工厂,别为了图省事把常量写死在函数里。
第四,团队里上装饰器之前,先对齐 Babel / TS 的配置模式(legacy 还是 2023-11),否则一个人的装饰器另一个人编译不过,这种低级矛盾最伤士气。
第五,真要上 Stage 3 新语法,先确认工具链版本跟得上,别在生产环境当小白鼠,出了兼容问题背锅的是你。
说到底,JavaScript Decorators 这套 @ 注解机制,是 JavaScript 难得的一次在语法层面正经支持"声明式元编程"。用好了,类和方法能从"一堆怎么做"瘦身为"我要什么";用不好,就是另一处只有写的人看得懂的魔法。希望这篇能帮你避掉我踩过的坑,把时间省下来去踩别的新坑。
想继续深挖,官方和文档都在这:
- TC39 装饰器提案(Stage 3 的权威定义):TC39 proposal-decorators
- Babel 装饰器插件文档:Babel @babel/plugin-proposal-decorators
- TypeScript 装饰器手册:TypeScript Decorators
- legacy 描述符背后的 Object.defineProperty:MDN Object.defineProperty
标签:javascript, 前端, typescript描述:从作者踩过的"legacy 装饰器 vs TC39 Stage 3 新签名"真实坑切入,对比讲解 JavaScript Decorators 两种写法的真实 API、类/方法/字段/访问器五种装饰器类型、用 @ 重构类与方法的实战示例,以及 Babel/TypeScript 的配置坑,帮助前端开发者真正掌握 @ 注解机制并少走弯路。

发表评论发布评论后审核成功,即可显示在列表中...