设计模式之装饰模式详解(附 Java / TypeScript 示例)

装饰模式:在不改原类的情况下叠加能力

装饰模式(Decorator Pattern)是一种结构型设计模式。它的核心目的,是在不修改原始对象代码的前提下,动态地给对象增加职责或行为。

一句话概括:

当你想给对象一层一层叠加能力,但又不想通过继承制造大量子类时,可以使用装饰模式。

问题从哪里来

假设我们要做一个咖啡点单系统,基础咖啡有:

  • 美式咖啡
  • 拿铁咖啡

可选加料有:

  • 牛奶
  • 摩卡
  • 奶油

如果直接用继承,很容易写出这样的类:

  • MilkAmericano
  • SugarAmericano
  • MilkSugarAmericano
  • MochaLatte
  • CreamMochaLatte

问题是:加料可以自由组合。每增加一种加料,组合数量都会快速膨胀。

装饰模式的做法是:

  • 先抽象出统一组件接口,例如 Coffee
  • 基础对象实现这个接口,例如 AmericanoLatte
  • 装饰器也实现同一个接口,并持有一个 Coffee
  • 每个装饰器只负责增加一小段行为,例如加牛奶、加糖、加摩卡。

这样能力可以像套娃一样叠加:

text 复制代码
MilkDecorator -> SugarDecorator -> Americano

结构

装饰模式通常包含四个角色:

  • Component:组件接口,定义对象的统一行为。
  • ConcreteComponent:具体组件,也就是原始对象。
  • Decorator:抽象装饰器,持有一个组件对象。
  • ConcreteDecorator:具体装饰器,给组件增加额外行为。

放到咖啡例子里:

  • Coffee 是组件接口。
  • AmericanoLatte 是具体组件。
  • CoffeeDecorator 是抽象装饰器。
  • MilkDecoratorSugarDecorator 是具体装饰器。

Java 示例

先定义咖啡接口:

java 复制代码
public interface Coffee {
    String getDescription();

    double cost();
}

实现基础咖啡:

java 复制代码
public class Americano implements Coffee {
    @Override
    public String getDescription() {
        return "美式咖啡";
    }

    @Override
    public double cost() {
        return 12.0;
    }
}

public class Latte implements Coffee {
    @Override
    public String getDescription() {
        return "拿铁咖啡";
    }

    @Override
    public double cost() {
        return 18.0;
    }
}

定义抽象装饰器。它同样实现 Coffee,并把默认行为委托给被包装的对象。

java 复制代码
public abstract class CoffeeDecorator implements Coffee {
    protected final Coffee coffee;

    protected CoffeeDecorator(Coffee coffee) {
        this.coffee = coffee;
    }

    @Override
    public String getDescription() {
        return coffee.getDescription();
    }

    @Override
    public double cost() {
        return coffee.cost();
    }
}

实现具体装饰器:

java 复制代码
public class MilkDecorator extends CoffeeDecorator {
    public MilkDecorator(Coffee coffee) {
        super(coffee);
    }

    @Override
    public String getDescription() {
        return coffee.getDescription() + " + 牛奶";
    }

    @Override
    public double cost() {
        return coffee.cost() + 3.0;
    }
}

public class SugarDecorator extends CoffeeDecorator {
    public SugarDecorator(Coffee coffee) {
        super(coffee);
    }

    @Override
    public String getDescription() {
        return coffee.getDescription() + " + 糖";
    }

    @Override
    public double cost() {
        return coffee.cost() + 1.0;
    }
}

使用方式:

java 复制代码
public class Demo {
    public static void main(String[] args) {
        Coffee coffee = new Americano();
        coffee = new MilkDecorator(coffee);
        coffee = new SugarDecorator(coffee);

        System.out.println(coffee.getDescription());
        System.out.println(coffee.cost());
    }
}

输出结果类似:

text 复制代码
美式咖啡 + 牛奶 + 糖
16.0

这里的关键是:客户端拿到的始终是 Coffee,不需要关心它到底是基础咖啡,还是被装饰过多次的咖啡。

Java 真实示例

Java 标准库里的 IO 流是装饰模式的经典例子。最底层的 FileInputStream 只负责从文件中读取字节,本身不提供缓冲能力。如果直接频繁读取小块数据,每次都可能触发底层 IO,性能会比较差。

BufferedInputStream 就是在 InputStream 外面加了一层缓冲装饰:

java 复制代码
try (InputStream input = new BufferedInputStream(
    new FileInputStream("data.txt")
)) {
    int value = input.read();
}

对应到装饰模式:

  • InputStream 是组件接口。
  • FileInputStream 是具体组件,负责真实文件读取。
  • FilterInputStream 是抽象装饰器,内部持有另一个 InputStream
  • BufferedInputStream 是具体装饰器,在读取能力外面叠加缓冲能力。

调用方拿到的仍然是 InputStream,所以可以继续把它传给任何只依赖 InputStream 的代码。缓冲只是被包装进去的一层额外能力,而不是改掉原来的文件读取类。

TypeScript 示例

TypeScript 中可以用接口和类直接表达装饰模式。

先定义组件接口:

ts 复制代码
interface Coffee {
  getDescription(): string;
  cost(): number;
}

实现基础咖啡:

ts 复制代码
class Americano implements Coffee {
  getDescription(): string {
    return "美式咖啡";
  }

  cost(): number {
    return 12;
  }
}

class Latte implements Coffee {
  getDescription(): string {
    return "拿铁咖啡";
  }

  cost(): number {
    return 18;
  }
}

定义抽象装饰器:

ts 复制代码
abstract class CoffeeDecorator implements Coffee {
  protected constructor(protected readonly coffee: Coffee) {}

  getDescription(): string {
    return this.coffee.getDescription();
  }

  cost(): number {
    return this.coffee.cost();
  }
}

实现具体装饰器:

ts 复制代码
class MilkDecorator extends CoffeeDecorator {
  getDescription(): string {
    return `${this.coffee.getDescription()} + 牛奶`;
  }

  cost(): number {
    return this.coffee.cost() + 3;
  }
}

class SugarDecorator extends CoffeeDecorator {
  getDescription(): string {
    return `${this.coffee.getDescription()} + 糖`;
  }

  cost(): number {
    return this.coffee.cost() + 1;
  }
}

使用:

ts 复制代码
let coffee: Coffee = new Americano();
coffee = new MilkDecorator(coffee);
coffee = new SugarDecorator(coffee);

console.log(coffee.getDescription());
console.log(coffee.cost());

如果项目偏函数式,也可以用高阶函数来实现类似装饰效果:

ts 复制代码
type CoffeeFn = () => {
  description: string;
  cost: number;
};

const americano: CoffeeFn = () => ({
  description: "美式咖啡",
  cost: 12,
});

const withMilk = (coffee: CoffeeFn): CoffeeFn => () => {
  const base = coffee();
  return {
    description: `${base.description} + 牛奶`,
    cost: base.cost + 3,
  };
};

const withSugar = (coffee: CoffeeFn): CoffeeFn => () => {
  const base = coffee();
  return {
    description: `${base.description} + 糖`,
    cost: base.cost + 1,
  };
};

const order = withSugar(withMilk(americano));

console.log(order());

这不是 GoF 书里最传统的类结构,但仍然保留了装饰模式的核心思想:不修改原对象,通过包装叠加行为。

TypeScript 真实示例

TypeScript 项目里很常见的装饰场景,是给同一个请求函数叠加鉴权、日志、重试或缓存能力。比如业务代码统一依赖一个 HttpRequest 函数类型:

ts 复制代码
type HttpRequest = (input: RequestInfo, init?: RequestInit) => Promise<Response>;

const withAuth = (request: HttpRequest, token: string): HttpRequest => {
  return (input, init = {}) =>
    request(input, {
      ...init,
      headers: {
        ...init.headers,
        Authorization: `Bearer ${token}`,
      },
    });
};

const withLogging = (request: HttpRequest): HttpRequest => {
  return async (input, init) => {
    console.log("request", input);
    return request(input, init);
  };
};

const request = withLogging(withAuth(fetch, "access-token"));

这里 fetch 是原始组件,withAuthwithLogging 是装饰器。它们不改变调用方看到的函数签名,只是在原有请求能力外面继续叠加行为。

什么时候适合用

适合使用装饰模式的场景:

  • 想在运行时给对象动态增加能力。
  • 额外能力可以自由组合。
  • 不希望通过继承产生大量组合类。
  • 希望每个增强能力保持独立,例如缓存、日志、鉴权、限流、压缩。
  • 原始类不方便修改,或者修改会影响已有逻辑。

不适合的场景:

  • 增强逻辑很少,直接改原类更清晰。
  • 装饰链太长,导致调试困难。
  • 每层装饰器都依赖具体实现细节,无法只通过统一接口工作。
  • 对象生命周期和状态非常复杂,包装之后容易出现状态不一致。

和代理模式的区别

装饰模式和代理模式结构很像:它们都持有一个目标对象,并实现相同接口。

区别在目的:

  • 装饰模式关注增强能力,让对象在原有行为基础上增加职责。
  • 代理模式关注控制访问,例如延迟加载、权限校验、远程调用、缓存访问。

简单说:

  • 装饰器是为了"加功能"。
  • 代理是为了"管访问"。

和继承的区别

继承是在编译期固定能力组合,而装饰模式是在运行时组合能力。

如果使用继承:

text 复制代码
MilkSugarAmericano extends Americano

这个组合在类定义时就固定了。

如果使用装饰模式:

java 复制代码
Coffee coffee = new SugarDecorator(new MilkDecorator(new Americano()));

组合可以根据用户选择、配置文件、运行环境动态决定。

常见误区

误区一:装饰器只是简单包装

不是。普通包装可能只是转发调用,而装饰模式要求包装对象和被包装对象遵循同一个抽象接口,客户端可以用同一种方式使用它们。

误区二:装饰器越多越灵活

不一定。装饰器太多会让调用链变长,排查问题时需要一层层追踪。每个装饰器最好只做一件清晰的小事。

误区三:装饰模式可以替代所有继承

不能。装饰模式适合叠加对象行为,不适合表达稳定的"是什么"关系。如果模型本身就是天然的分类层级,继承或接口实现仍然可能更合适。

总结

装饰模式解决的是"能力组合爆炸"的问题。它通过统一接口和对象包装,让增强能力可以一层层叠加,而不用为每一种组合创建一个新类。

在 Java 中,装饰模式通常体现为"接口 + 抽象装饰器 + 具体装饰器";在 TypeScript 中,它既可以用类表达,也可以用高阶函数表达。判断是否需要装饰模式时,关键看能力是否需要自由组合,以及这些能力是否可以独立地包在原对象外面。