一、从一个咖啡店的故事说起
想象一下,你走进一家咖啡店。你点了一杯最基础的“美式咖啡”。然后,你可能会说:“加一份浓缩”、“加一份奶油”、“再加点焦糖糖浆”。最终,你得到了一杯风味独特的“焦糖奶油特浓美式”。
这个过程,和我们编程中一个非常棒的设计思想不谋而合——装饰器模式。我们不改变“美式咖啡”这个核心对象,而是一层一层地给它“穿上”新的功能(浓缩、奶油、焦糖)。在JavaScript的世界里,尤其是ES7引入了装饰器语法之后,这种“动态扩展对象功能”的技巧变得前所未有的优雅和强大。它让我们能够在不修改原有代码结构的情况下,灵活地给类、方法或属性添加新的行为。
二、装饰器模式:不拆墙的精装修
在深入代码之前,我们先理解一下装饰器模式的核心思想。它属于结构型设计模式,主要解决的是“继承”可能带来的问题。如果我想给一个类增加十个不同的功能,用继承的话,我可能需要创建十个子类,这会导致类爆炸,代码也难以维护。
装饰器模式提供了一种更灵活的替代方案:它通过将对象放入一个特殊的“包装器”中来为原对象增加新的功能。这些包装器(也就是装饰器)和被装饰的对象有相同的接口,所以对使用者来说是透明的。你可以用一个装饰器,也可以用多个装饰器层层包裹。
在JavaScript中,装饰器主要应用在以下几个地方:
- 类装饰器:用于整个类,可以修改或替换类的定义。
- 类方法装饰器:用于类的方法,可以修改方法的行为(比如添加日志、权限校验)。
- 类属性装饰器:用于类的属性。
- 参数装饰器:用于方法的参数。
下面,我们将用一个统一的技术栈来演示这些装饰器是如何工作的。
技术栈:TypeScript / ES Next (需要配置相应编译器支持,如 Babel + @babel/plugin-proposal-decorators 或 TypeScript 开启 experimentalDecorators)
我们选择TypeScript来演示,因为它对装饰器语法有更好的支持和类型提示,但核心思想同样适用于配置了相应Babel插件的现代JavaScript项目。
三、动手实战:编写你的第一个装饰器
让我们从一个最简单的例子开始,感受一下装饰器的魔力。
示例1:一个基础的方法装饰器
假设我们有一个User类,其中有一个save方法,我们希望在这个方法执行前后自动打印日志。
// 首先,定义一个简单的日志装饰器工厂函数
function Log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
// 保存原始方法的引用
const originalMethod = descriptor.value;
// 重写该方法
descriptor.value = function (...args: any[]) {
console.log(`[LOG] 调用方法: ${propertyKey}, 参数:`, args);
// 执行原始方法,并保存结果
const result = originalMethod.apply(this, args);
console.log(`[LOG] 方法 ${propertyKey} 执行完毕,结果:`, result);
// 返回原始方法的结果
return result;
};
return descriptor;
}
class User {
name: string;
constructor(name: string) {
this.name = name;
}
// 使用 @Log 装饰器装饰 save 方法
@Log
save() {
console.log(`用户 ${this.name} 的数据已保存。`);
return `用户ID: ${Math.random().toString(36).substr(2, 9)}`;
}
}
// 使用示例
const user = new User('小明');
user.save();
// 控制台输出:
// [LOG] 调用方法: save, 参数: []
// 用户 小明 的数据已保存。
// [LOG] 方法 save 执行完毕,结果: 用户ID: xyz789abc
看,我们完全没有改动User类内部的save方法,就给它附加了完整的日志功能!@Log就像一个标签,贴上去就生效了。这里的target是类的原型(User.prototype),propertyKey是方法名(‘save’),descriptor是该方法的属性描述符,我们可以通过修改它来改变方法的行为。
示例2:带参数的装饰器工厂
很多时候,我们希望装饰器能接受一些配置参数,让它更灵活。这时我们需要一个“装饰器工厂”——一个返回真正装饰器函数的函数。
// 装饰器工厂:接受一个标签前缀作为参数
function LogWithPrefix(prefix: string) {
// 返回真正的装饰器函数
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
console.log(`[${prefix}] 调用方法: ${propertyKey}`);
const result = originalMethod.apply(this, args);
console.log(`[${prefix}] 方法 ${propertyKey} 执行完毕`);
return result;
};
return descriptor;
};
}
// 权限校验装饰器工厂
function RequireRole(role: string) {
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
// 假设我们从某个地方(如this.context)获取当前用户角色
const currentUserRole = (this as any).context?.role || 'guest';
if (currentUserRole !== role) {
throw new Error(`权限不足!需要角色: ${role}, 当前角色: ${currentUserRole}`);
}
return originalMethod.apply(this, args);
};
return descriptor;
};
}
class AdminService {
context = { role: 'admin' }; // 模拟上下文
@LogWithPrefix('ADMIN_API')
@RequireRole('admin') // 装饰器可以叠加,执行顺序是“从下到上”或“从近到远”(RequireRole先执行)
deleteUser(userId: string) {
console.log(`正在删除用户 ${userId}...`);
return { success: true, userId };
}
}
const service = new AdminService();
service.deleteUser('123');
// 控制台输出:
// [ADMIN_API] 调用方法: deleteUser
// 正在删除用户 123...
// [ADMIN_API] 方法 deleteUser 执行完毕
// 如果 context.role = 'user',则会先抛出权限错误,LogWithPrefix的日志也不会打印。
这个例子展示了两个强大特性:
- 装饰器工厂:
@LogWithPrefix(‘ADMIN_API’)让我们可以定制日志前缀。 - 装饰器叠加:我们可以同时使用多个装饰器。它们会像洋葱一样层层包裹原始方法。注意执行顺序:离方法最近的装饰器(
@RequireRole)先执行其包装逻辑,然后是外一层的@LogWithPrefix。但就“进入”动作而言,是外层先触发。
示例3:类装饰器与属性装饰器
装饰器不仅能装饰方法,还能装饰整个类和属性。
// 类装饰器:为类添加元数据或修改构造函数
function Injectable(constructor: Function) {
console.log(`类 ${constructor.name} 被标记为可注入。`);
// 这里可以做一些依赖注入容器的注册逻辑
}
// 属性装饰器:常用于元数据反射、依赖注入标记
function FormatDate(format: string) {
return function (target: any, propertyKey: string) {
// target: 对于实例属性是类的原型,对于静态属性是类的构造函数
// propertyKey: 属性名
let value: string;
const getter = function () {
return value;
};
const setter = function (newVal: string) {
console.log(`正在格式化日期,格式为:${format}`);
// 这里简化处理,实际应用中可能使用 moment.js 或 date-fns 进行格式化
value = `[${format}] ${newVal}`;
};
// 使用 defineProperty 重新定义该属性,添加getter/setter
Object.defineProperty(target, propertyKey, {
get: getter,
set: setter,
enumerable: true,
configurable: true,
});
};
}
// 使用装饰器
@Injectable
class Article {
@FormatDate('YYYY-MM-DD')
publishDate: string;
constructor(date: string) {
this.publishDate = date; // 赋值时会触发setter,进行格式化
}
getInfo() {
return `文章发布于:${this.publishDate}`;
}
}
// 使用示例
const article = new Article('2023-10-27');
console.log(article.getInfo()); // 输出:文章发布于:[YYYY-MM-DD] 2023-10-27
console.log(article.publishDate); // 输出:[YYYY-MM-DD] 2023-10-27
// 控制台还会输出:类 Article 被标记为可注入。
// 以及:正在格式化日期,格式为:YYYY-MM-DD
四、装饰器的舞台:应用场景与优劣分析
装饰器模式并非银弹,但在合适的场景下,它能极大地提升代码质量。
应用场景
- 日志记录与性能监控:如上面的
@Log,可以无侵入地给关键方法添加日志、计算执行时间(@MeasureTime)。 - 权限验证与安全检查:如
@RequireRole(‘admin’)、@CheckPermission,在方法执行前统一拦截验证。 - 数据验证与格式化:如
@Validate、@FormatDate,在属性赋值或方法调用时自动校验数据格式。 - 依赖注入:在Angular、NestJS等框架中大量使用,通过
@Injectable()、@Inject()等装饰器来管理类之间的依赖关系。 - 路由定义:在Express/Koa的某些框架中,用
@Get(‘/api/users’)装饰器来定义路由和处理函数。 - 缓存:实现一个
@Cache(ttl=60)装饰器,自动缓存方法返回值,提升性能。 - 防抖与节流:给事件处理函数添加
@Debounce(300)或@Throttle(1000),优化性能。 - 自动重试:给网络请求方法添加
@Retry(times=3),在失败时自动重试。
技术优缺点
优点:
- 符合开闭原则:无需修改原有代码即可扩展功能,对扩展开放,对修改封闭。
- 高复用性:一个装饰器可以在多个不同的类或方法上使用。
- 灵活的组合:可以通过叠加多个装饰器来组合复杂的功能,避免了多层继承的僵化。
- 职责清晰:每个装饰器只关注一个特定的横切关注点(如日志、权限),代码结构清晰。
缺点:
- 增加复杂度:大量使用装饰器会让代码的流程变得不那么直观,调试难度增加,因为行为被隐藏在了装饰器内部。
- 初始化与顺序问题:装饰器在类/方法定义时立即执行并应用,而不是在实例化时。多个装饰器的应用和执行的顺序需要开发者明确知晓。
- 对新手不友好:语法相对特殊,理解其背后的元编程概念需要一定成本。
- ES标准尚未完全稳定:虽然已是Stage 3提案,广泛使用,但标准细节可能仍有微调。
注意事项
- 执行顺序:多个装饰器应用在同一目标时,其求值(工厂函数调用)顺序是“从上到下”,而应用(装饰器函数调用)顺序是“从下到上”。对于方法和访问器,这类似于洋葱模型。
this的指向:在装饰器内部重写方法时,务必使用originalMethod.apply(this, args)来确保this上下文正确指向当前实例。- 不可滥用:不要为了用装饰器而用装饰器。简单的功能直接写在方法内部可能更清晰。装饰器最适合用于那些“横切关注点”。
- 类型安全:在TypeScript中,装饰器可能会破坏原始的类型签名,需要仔细处理返回的描述符(
PropertyDescriptor)以确保类型安全。
五、总结
JavaScript装饰器模式为我们提供了一种强大而优雅的“动态扩展对象功能”的能力。它就像一套功能丰富的“标签贴纸”,允许我们在不触碰原有“墙体”(源代码)的情况下,对类、方法、属性进行“精装修”。通过将日志、权限、验证等公共逻辑抽离成独立的装饰器,我们的核心业务代码可以保持干净和纯粹,显著提升了代码的可读性、可维护性和可复用性。
尽管它需要一定的学习成本,并且在调试和复杂度控制上存在挑战,但在处理横切关注点、构建可插拔的架构方面,装饰器无疑是现代JavaScript/TypeScript开发者工具箱中一件不可或缺的利器。下次当你发现自己在多个地方重复编写相似的样板代码时,不妨考虑一下:“这里是否可以用一个装饰器来优雅地解决?”
评论