Decorator:不用继承,也能叠 Buff

0 阅读6分钟

你有没有碰到过这种场景:一个请求发出去之前,要加 token、要加日志、要加缓存、要加重试……每加一个能力,就想多写一个子类或者一个 if 分支?

加到第四个的时候,你开始怀疑人生。

这不是你的问题。是继承这条路本身会走进死胡同。  今天聊的 Decorator 模式,就是专门解决这种"能力叠加"困境的。

一、问题:子类组合爆炸

假设你在做一个通知系统。初始需求很简单——发邮件就行。后来产品说:加个短信通知;再后来:加 Slack;再后来:加企业微信。

如果用继承,3 种额外通知方式的所有组合是多少个子类?

通知方式组合
短信1
Slack1
企微1
短信 + Slack1
短信 + 企微1
Slack + 企微1
短信 + Slack + 企微1
合计7 个子类

3 种能力,7 个子类。4 种能力,15 个子类。N 种能力,2ᴺ - 1 个子类。

这就是经典的子类组合爆炸

子类组合爆炸:3 种通知方式需要 7 个组合子类

子类组合爆炸:3 种通知方式需要 7 个组合子类

继承的本质问题是:它把"组合"这件事固定在了编译期。  你必须在写代码的时候就穷举所有排列组合,而现实中需求是运行时才确定的。

二、解法:一层包一层,运行时叠加

Decorator 的核心思路极其朴素:不继承,包一层。

// 基础接口
interface Notifier {
  send(msg: string): void;
}

// 基础实现
class EmailNotifier implements Notifier {
  send(msg: string) {
    // 发邮件
  }
}

// 装饰器基类
class NotifierDecorator implements Notifier {
  constructor(private wrappee: Notifier) {}
  send(msg: string) {
    this.wrappee.send(msg);  // 委托给被包装对象
  }
}

// 具体装饰器:短信
class SMSDecorator extends NotifierDecorator {
  send(msg: string) {
    super.send(msg);   // 先执行原有逻辑
    // 再发一条短信
  }
}

// 具体装饰器:Slack
class SlackDecorator extends NotifierDecorator {
  send(msg: string) {
    super.send(msg);
    // 再推一条 Slack
  }
}

运行时组装:

let notifier: Notifier = new EmailNotifier();

if (user.wantsSMS)   notifier = new SMSDecorator(notifier);
if (user.wantsSlack) notifier = new SlackDecorator(notifier);

notifier.send("服务器着火了!");
// → 邮件 + 短信 + Slack,三条全发

7 个子类变成了 3 个装饰器类 + 1 个基础类。需要新增企微通知?加一个 WeChatDecorator,已有代码一行不改。

各种通知方式变为独立装饰器,运行时任意组合

各种通知方式变为独立装饰器,运行时任意组合

从指数级降到线性级——这就是 Decorator 真正解决的问题。

三、为什么接口必须一致?

你可能注意到了:装饰器和被装饰对象实现同一个接口。这不是凑巧,而是整个模式能运转的核心约束。

三个理由:

约束为什么
透明性客户端不知道、也不需要知道对象被包了几层。它只看到 Notifier 接口
可递归装饰器包装饰器包装饰器,任意嵌套深度,类型始终兼容
可替换任何期望 Notifier 的地方都能传入装饰后的对象,零修改

这里有个很好的类比:表观遗传

DNA 序列(核心对象)不变,但通过甲基化、乙酰化等化学修饰,同一段基因可以表达出完全不同的蛋白质。修饰不改变基因本身,但改变了它的"行为输出"。而且这些修饰是可逆的——就像你可以在运行时摘掉某一层装饰器。

基因不用突变,修饰就能决定表达。对象不用继承,包装就能改变行为。

Decorator 结构图:组件接口 → 具体组件 + 基础装饰器 → 具体装饰器

Decorator 结构图:组件接口 → 具体组件 + 基础装饰器 → 具体装饰器

四、前端里的 Decorator 无处不在

你可能觉得设计模式是后端的事。但前端代码里,Decorator 的影子比你以为的多得多:

前端场景Decorator 体现被包装的"核心对象"
React HOCwithAuth(withTheme(App))原始组件
Express/Koa middlewareapp.use(cors()); app.use(logger())请求处理函数
Axios interceptorrequest.use(addToken); response.use(handleError)原始请求/响应
Redux middlewareapplyMiddleware(thunk, logger)dispatch 函数
埋点增强withTracking(Button)基础按钮组件

它们的共同特征:

不修改原对象——原始组件/函数完全不知道自己被包了

保持相同接口——包装后的东西和原来"长得一样",调用方无感知

可自由组合——加几层、加什么层,运行时决定

// Axios 拦截器 = 请求的装饰器链
axios.interceptors.request.use((config) => {
  config.headers.Authorization = `Bearer ${token}`;  // 第一层:加 token
  return config;
});

axios.interceptors.request.use((config) => {
  console.log(`[${Date.now()}] ${config.method} ${config.url}`);  // 第二层:日志
  return config;
});

每个 interceptor.use() 就是"再包一层"。你一直在用 Decorator,只是以前可能不知道它叫这个名字。

五、Decorator vs. 其他容易混淆的模式

对比DecoratorAdapterProxy
接口相同或扩展完全不同完全相同
目的叠加新行为兼容不同接口控制访问
递归组合✅ 支持❌ 不支持❌ 通常不嵌套
谁控制组装客户端客户端Proxy 自身

一句话区分:Decorator 加能力,Adapter 转接口,Proxy 管权限。

有一个更形象的说法:Decorator 改变对象的外皮(skin),Strategy 改变对象的内核(guts)。

六、什么时候该用 Decorator

不是所有"加功能"的场景都适合 Decorator。判断清单:

条件说明
✅ 能力是独立的加密、压缩、日志、缓存之间互不依赖
✅ 需要运行时动态组合用户偏好、配置项决定叠哪些能力
✅ 需要保持接口不变调用方不应该因为"加了日志"就改代码
✅ 能力数量可能持续增长以后还会加新层,不想改老代码
❌ 能力之间有强顺序依赖如果 A 必须在 B 前执行且逻辑耦合,pipeline 更合适
❌ 需要访问核心对象内部状态Decorator 只能通过接口操作,拿不到私有字段

这里的经济学类比很贴切:亚当·斯密的分工理论。把一个大流程拆成多个专业化环节(加密层、压缩层、日志层),每个环节独立可替换、独立可测试、独立可复用。Decorator 就是代码世界的专业分工——组合的边际成本趋近于零。

七、动手判断:你的代码需要 Decorator 吗?

下次写代码时,如果你发现自己在做这些事,停下来想想 Decorator:

你在一个类里堆了 5 个布尔开关——enableLogenableCacheenableRetry……每个 if 让方法越来越胖

你在用继承组合能力——LoggingCachingRetryingService extends CachingRetryingService extends RetryingService

你在重复写同样的"前置/后置"逻辑——每个 API 方法都有一段加 token 的代码

这三种情况的共同特征是:能力是正交的,但你把它们耦合在了一起。

Decorator 的解法就像毛坯房装修——房屋结构(核心组件)不动,防水层、隔音层、装饰层、智能家居层各司其职,独立施工,随时可拆可换。

继承是推倒重建,Decorator 是模块化装修。


如果你只想带走一句话,我建议记这个:

能力叠加不靠继承靠包装。类的数量从 2ᴺ 降到 N,靠的就是把"组合"从编译期搬到运行时。

Decorator 不是什么高深的模式。它的精髓就一个字——。包一层加一个能力,包几层运行时决定。接口不变,行为叠加,调用方无感知。

下次你写 app.use(middleware) 或者 withAuth(Component) 的时候,可以微微一笑——你已经是 Decorator 的老用户了。

参考原文:

• Refactoring.Guru — Decorator

qrcode_for_gh_6a9e7f3719d6_344.jpg