讲解

点咖啡:美式可以加奶、加糖、加燕麦奶,组合起来几十种。如果每种组合一个类(美式加奶加糖燕麦奶类……),类爆炸;实际做法是点单后一层层「加料」——每一层包在杯子外面,价格叠加。装饰器模式就是「加料」的面向对象版:不改变原对象,用一个同类型的包装对象把它包起来,在转发调用的前后加上自己的职责,且包装可以层层叠加。

GoF 的定义:动态地给一个对象添加一些额外的职责。就增加功能来说,装饰器模式相比生成子类更为灵活。结构上的关键约束是装饰器与组件实现同一个接口——这样装饰器对客户端透明(客户端手里的还是 Fetcher),装饰器之间也能互相包裹(缓存包装日志,日志再包装真实对象)。

相比继承,装饰器的组合优势体现在两点:一是职责可以运行时按需叠加,而不是编译期定死;二是每种职责(缓存、重试、日志、限流)独立成类,可以自由排列组合,避免「缓存重试日志类」这种排列组合爆炸。中间件、流(Node.js 的 stream pipe)、React 高阶组件都是它的近亲。

提醒一个命名撞车:TypeScript/JavaScript 语法里的 @decorator(类装饰器语法)是语言特性,用途是元编程(如依赖注入框架收集元数据),和 GoF 装饰器模式不是一个东西,虽然精神相通。本章讲的是后者——一种对象组合结构。

示例

给一个「数据拉取器」动态叠加缓存和统计两层职责,看包装顺序如何决定行为:

import assert from 'node:assert/strict';

interface Fetcher {
  fetch(url: string): string;
}

// 核心组件:真实拉取(用计数模拟昂贵操作)
class RealFetcher implements Fetcher {
  calls = 0;
  fetch(url: string): string {
    this.calls++;
    return '内容(' + url + ')';
  }
}

// 装饰器一:缓存——同样的 URL 不重复请求
class CachedFetcher implements Fetcher {
  private readonly cache = new Map<string, string>();
  constructor(private readonly inner: Fetcher) {}

  fetch(url: string): string {
    const hit = this.cache.get(url);
    if (hit !== undefined) return hit;
    const value = this.inner.fetch(url);
    this.cache.set(url, value);
    return value;
  }
}

// 装饰器二:统计——记录每次经过它的调用
class MeteredFetcher implements Fetcher {
  total = 0;
  constructor(private readonly inner: Fetcher) {}

  fetch(url: string): string {
    this.total++;
    return this.inner.fetch(url);
  }
}

const real = new RealFetcher();
// 包装顺序:统计 包 缓存 包 真实
const fetcher = new MeteredFetcher(new CachedFetcher(real));

assert.equal(fetcher.fetch('/a'), '内容(/a)');
assert.equal(fetcher.fetch('/a'), '内容(/a)'); // 缓存命中,没惊动 real
fetcher.fetch('/b');

assert.equal(real.calls, 2); // 真实请求只发生 2 次
assert.equal(fetcher.total, 3); // 外层统计到全部 3 次调用
console.log('调用 3 次,实际网络请求 ' + real.calls + ' 次(缓存生效)');

断言揭示了两个要点:缓存装饰器把第二次 /a 请求拦了下来(real.calls 停在 2);而统计装饰器包在最外层,所以连被缓存拦掉的调用它也数到了(total 是 3)。包装顺序不是随意的——先缓存后重试和先重试后缓存,行为完全不同,装配时要想清楚每层该在哪个位置。

常见坑

  • 装饰器改变了接口语义:装饰器应该「增强」而不是「改变」——把 fetch 变成有时返回缓存的旧数据是可接受的语义(要文档化),把返回类型换掉就背叛了接口。
  • 客户端依赖具体装饰器类型:手里攥着 CachedFetcher 类型就退化成继承耦合了;变量类型应始终是接口 Fetcher。
  • 忘记转发所有方法:组件接口方法多时,装饰器每加一个都要转发全部方法,漏一个就是隐蔽 bug;这也提醒我们组件接口要窄。
  • 包装顺序想当然:日志该在缓存外还是缓存内?要不要记录缓存命中?答案不同顺序不同,装配处要写明为什么。
  • 和 TS 的 @装饰器语法混淆:@sealed、@Injectable 是编译期元编程语法糖;GoF 装饰器是运行时的对象包装,两者别混为一谈。

小结

装饰器 = 同接口包装 + 转发时增强 + 可层层叠加,用组合替代了继承的排列组合爆炸。下一章是结构型里最「朴实」的一个:把一坨子系统调用收进一个入口的外观模式。