从 TypeScript 的 @Component 到 Angular 的 @Injectable,装饰器已经是前端开发中几乎绕不开的语法。不过很长一段时间里,它只是 TypeScript 的”私货”和 Babel 插件的模拟产物。直到 2025 年,随着 ES2025 标准的推进和 Chrome 126 的正式支持,我们终于可以在原生 JavaScript 里使用装饰器了。它不需要编译,不需要类型注解,写出来就是标准的 ECMAScript。这篇文章会带你从最基础的语法开始,一步步用三个真实场景的装饰器把它的能力全部摸清。
一、没有装饰器之前,我们怎么给类”加料”
假设你有一个 UserService 类,里面有个 updateProfile 方法。你想在每次调用这个方法时自动记录一条日志,同时把调用参数和时间打到控制台。传统的做法是:手动在方法体开头加 console.log,或者写一个高阶函数去包裹原方法。但逻辑一多,方法体重叠了大量的旁路代码,跟核心业务毫无关系。
另一种“优雅”点的方案是:写一个工厂函数,接收原类或原方法,返回增强后的版本。但这样搞几次之后,调用方式就变了,原本是 new UserService(),现在得 createLoggedService(UserService),而且每次想加缓存、加权限检查就得不断往里套娃,代码的结构跟原始意图差得越来越远。
装饰器解决的正是这个问题:把增强逻辑抽离成函数,以声明的方式标注在类的成员上。你看着类的主体,眼里净是业务代码,那些辅助逻辑被 @ 符号带到了类的顶部或方法的上面,一目了然。
二、原生装饰器的基本语法规则
先明确一个要点:当前的 JavaScript 原生装饰器只能用在类成员上——也就是类字段、方法、getter 和 setter。类本身还没有支持类装饰器(那是后续提案的事)。
一个装饰器本质上就是一个普通函数,只不过它接收两个参数:
- target:如果是静态成员,就是类的构造函数;如果是实例成员,就是
Class.prototype。 - context:一个提供元数据的对象,包含成员名称、种类(field/method/getter/setter)、是否静态等信息,还有一个重要的
addInitializer方法,允许你在类实例化或类定义完成时插入一段初始化逻辑。
下面这个最简单的例子,@log 装饰器会在方法被调用时自动打印方法名和参数:
function log(target, context) {
const methodName = String(context.name);
// 获取原始方法
const original = target;
// 返回一个新的函数替换原方法
function replacement(...args) {
console.log(`调用 ${methodName},参数:`, args);
const result = original.call(this, ...args);
console.log(`${methodName} 执行完毕`);
return result;
}
return replacement;
}
class Calculator {
@log
add(a, b) {
return a + b;
}
}
const calc = new Calculator();
calc.add(3, 5);
// 控制台输出:
// 调用 add,参数: [3, 5]
// add 执行完毕
过程很清晰:add 方法被装饰器替换成了 replacement,但原本的计算逻辑依旧在其中调用。同时,因为装饰器返回的是一个新函数,原型链上的方法引用会被自动修正,calc.add === Calculator.prototype.add 依然是 true。
三、实战一:带参数的装饰器——制作一个通用的计时器
日志装饰器虽然好用,但不能定制,比如我们想给不同的方法打上不同的标签,而不是统一用方法名。这时候就需要“装饰器工厂”——一个返回装饰器的高阶函数。
下面的 timer 装饰器工厂接收一个标签字符串,返回一个真正的装饰器函数,在方法执行前后输出耗时:
function timer(label) {
return function(target, context) {
const original = target;
function replacement(...args) {
const start = performance.now();
const result = original.call(this, ...args);
const duration = (performance.now() - start).toFixed(2);
console.log(`${label} 耗时: ${duration}ms`);
return result;
}
return replacement;
};
}
class DataFetcher {
@timer('数据加载')
fetchLargeDataset() {
// 模拟一个耗时操作
const start = Date.now();
while (Date.now() - start < 200) {}
return { items: [] };
}
@timer('计算统计')
computeStats(data) {
const start = Date.now();
while (Date.now() - start < 50) {}
return { mean: 0, std: 0 };
}
}
const fetcher = new DataFetcher();
fetcher.fetchLargeDataset(); // 输出: 数据加载 耗时: 201.xxms
fetcher.computeStats(); // 输出: 计算统计 耗时: 51.xxms
这种工厂模式让你可以在装饰器上传递任何配置信息——标签、阈值、开关等——与 TypeScript 装饰器的常见用法完全一致。
四、实战二:基于 Map 的内存缓存装饰器
很多计算结果是有幂等性的,拿相同的参数调用就应该返回相同的结果,比如根据用户 ID 查询数据库。我们可以写一个 @memoize 装饰器,把方法的结果按参数缓存起来,下次调用时直接返回缓存值,避免重复计算或请求。
function memoize(target, context) {
const original = target;
const cache = new Map();
function replacement(...args) {
// 用 JSON.stringify 生成简单的缓存键(适用于基本类型参数)
const key = JSON.stringify(args);
if (cache.has(key)) {
console.log(`从缓存返回 ${String(context.name)}`);
return cache.get(key);
}
const result = original.call(this, ...args);
cache.set(key, result);
return result;
}
return replacement;
}
class UserRepository {
@memoize
getUserById(id) {
console.log(`查询数据库: id=${id}`);
return { id, name: `用户${id}` };
}
}
const repo = new UserRepository();
const user1 = repo.getUserById(101); // 查询数据库: id=101
const user2 = repo.getUserById(101); // 从缓存返回 getUserById
console.log(user1 === user2); // true
这个装饰器内部创建了一个独立的 Map,缓存跟着方法走,不污染类实例。如果你的方法是处理大量重复请求(比如列表页的搜索建议),这种方式能直接省掉一大截接口调用。
五、实战三:权限控制装饰器——在方法执行前检查用户状态
装饰器最拿手的一个场景就是前置拦截。比如一个功能只对管理员开放,如果用户角色不对,直接拒绝调用,甚至都不需要进入方法体。我们可以写一个 @requireRole 装饰器:
function requireRole(role) {
return function(target, context) {
const original = target;
function replacement(...args) {
// 假设 this 上有一个 currentUser 属性
if (!this.currentUser || this.currentUser.role !== role) {
throw new Error(`权限不足:需要 ${role} 角色`);
}
return original.call(this, ...args);
}
return replacement;
};
}
class AdminPanel {
// 实例属性,模拟当前用户
currentUser = null;
@requireRole('admin')
deletePost(postId) {
console.log(`帖子 ${postId} 已删除`);
}
@requireRole('editor')
editPost(postId, content) {
console.log(`帖子 ${postId} 已更新`);
}
}
const panel = new AdminPanel();
panel.currentUser = { name: '小李', role: 'editor' };
try {
panel.editPost(42, '新内容'); // 帖子 42 已更新
panel.deletePost(42); // 抛出 Error: 权限不足:需要 admin 角色
} catch (e) {
console.error(e.message);
}
这样,所有跟权限相关的逻辑只存在于装饰器中,类的方法内部专心处理业务。将来如果角色体系变动,只需要改装饰器,不需要在每一个方法里去搜 if (user.role !== 'admin')。
六、使用 addInitializer 做初始化后的自动设置
装饰器的 context.addInitializer 是个很容易被忽略但非常实用的功能。它允许你在类实例创建之后、或者类定义完毕之后,自动运行一段代码。比如你想在每个对象创建时自动把某个属性设为只读,或者自动触发一次数据校验。
下面是一个简单的例子:让类实例在创建后自动输出一条初始化日志。
function autoLogInit(target, context) {
context.addInitializer(function() {
console.log(`实例已创建,成员 ${String(context.name)} 已就绪`);
});
}
class Player {
@autoLogInit
health = 100;
constructor(name) {
this.name = name;
}
}
const p1 = new Player('战士');
// 控制台输出: 实例已创建,成员 health 已就绪
如果给多个字段加了这个装饰器,每个都会触发自己的初始器。更复杂的场景里,你可以用它在类定义阶段收集所有带某装饰器的字段,生成一份“元数据映射”,后面用来做序列化、表单验证之类的功能——这就是很多框架里 @Prop 装饰器的基础原理。
七、原生装饰器与 TypeScript 装饰器的差异
如果你之前一直用的是 TypeScript 的实验性装饰器,切到原生装饰器时会有几个明显变化:
- 原生装饰器目前不支持类装饰器(
@classDecorator写在 class 上方),那个能力还在提案阶段。你需要用其他方式模拟。 - 参数装饰器(
@param写在方法参数前面)暂未支持,目前装饰器只能作用在类成员上。 - 原生装饰器的
context对象提供了更清晰的元数据入口,不像 TS 旧版那样依赖Reflect.metadata。这对不依赖 TypeScript 的纯 JS 项目非常友好。 - 原生装饰器是 ES 标准,不需要
experimentalDecorators编译选项,可以直接在浏览器和 Node.js 里跑——前提是运行时版本够新。
八、运行环境与兼容性
截至 2025 年,装饰器在以下环境可直接使用,无需任何编译或 polyfill:
- Chrome 126+、Edge 126+
- Safari 18+(2024 年底加入)
- Firefox 132+(2024 年 11 月已支持)
- Node.js 22+(需要 v8 标志,但 Node 23 起已是默认特性)
如果你还在维护需要兼容旧浏览器的前端项目,可以继续使用 Babel 或 TypeScript 的装饰器编译方案,而服务端项目(Node.js 22 LTS 以上)完全可以放心用原生装饰器。
九、总结
装饰器最大的价值不是让你少写几行代码,而是把”增强逻辑”和”核心业务”清晰地分离开。你可以在不影响原始方法签名的情况下,给任何类方法加上缓存、日志、权限、节流、防抖这类横切关注点,而且这一切都是声明式的、可组合的。
文中的三个装饰器(日志、缓存、权限)可以直接拷贝到你的项目里使用。如果你还没试过原生装饰器,现在打开 Chrome 的控制台,粘贴第一个 @log 的例子,你会立刻感受到这种语法带来的清爽感——那个感觉就像第一次用 async/await 替代了回调地狱。

