Skip to content

5.3 模板方法模式 (Template Method Pattern)

模板方法模式是一种在框架和库中被广泛应用的、基于继承的行为型设计模式。它的核心思想非常直观:定义一套标准流程,但允许子类定制流程中的某些具体步骤。

核心定义:在一个方法中定义一个算法的骨架(或称模板),而将一些步骤延迟到子类中去实现。模板方法使得子类可以在不改变一个算法的结构的情况下,重新定义该算法的某些特定步骤。

一、生活中的场景:标准化的饮料制作流程

想象一下你在一家饮品店工作。无论是制作一杯“茶”还是一杯“咖啡”,都遵循一个非常相似的标准作业流程 (SOP)

  1. 把水烧开 (boilWater)
  2. 用沸水冲泡原料 (brew)
  3. 把饮料倒进杯子 (pourInCup)
  4. 在饮料中加入调料 (addCondiments)

这个 1->2->3->4 的流程是固定不变的,它就是我们的“模板”

但是,其中的第 2 步和第 4 步是可变的

  • 做茶时,冲泡的是“茶叶”(brewTea),加入的调料是“柠檬”(addLemon)。
  • 做咖啡时,冲泡的是“咖啡粉”(brewCoffee),加入的调料是“牛奶和糖”(addMilkAndSugar)。

模板方法模式,就是要把这个固定的流程抽象成一个“父类”,把可变的步骤留给“子类”去实现。

二、没有模板方法模式的代码:重复的流程

javascript
class Tea {
  make() {
    console.log("1. 把水烧开");
    console.log("2. 用沸水浸泡茶叶"); // <--- 变化点
    console.log("3. 把茶水倒进杯子");
    console.log("4. 加柠檬"); // <--- 变化点
  }
}

class Coffee {
  make() {
    console.log("1. 把水烧开");
    console.log("2. 用沸水冲泡咖啡粉"); // <--- 变化点
    console.log("3. 把咖啡倒进杯子");
    console.log("4. 加糖和牛奶"); // <--- 变化点
  }
}

const myTea = new Tea();
myTea.make();

const myCoffee = new Coffee();
myCoffee.make();

这段代码充满了重复烧水倒杯子的逻辑在两个类中一模一样。如果未来制作流程要增加一个“冷却”步骤,你需要同时修改 TeaCoffee 两个类,这非常不利于维护。

三、使用模板方法模式重构

第 1 步:创建抽象父类,定义模板

我们创建一个 Beverage (饮料) 父类,在里面定义“算法骨架”。

javascript
class Beverage {
  // 这是模板方法,它定义了算法的骨架,并且是 final 的(在JS中通过约定),不应被子类重写。
  init() {
    this.boilWater();
    this.brew(); // <-- 这是一个“坑”,留给子类来填
    this.pourInCup();
    this.addCondiments(); // <-- 这也是一个“坑”
  }

  // --- 通用的、已实现的方法 ---
  boilWater() {
    console.log("1. 把水烧开");
  }

  pourInCup() {
    console.log("3. 把饮料倒进杯子");
  }

  // --- 抽象方法,需要子类去实现 ---
  // 在 JS 中,我们可以通过抛出错误来模拟抽象方法
  brew() {
    throw new Error("子类必须实现 brew 方法");
  }

  addCondiments() {
    throw new Error("子类必须实现 addCondiments 方法");
  }
}

第 2 步:创建具体子类,实现变化点

javascript
class Tea extends Beverage {
  // 实现父类中留下的“坑”
  brew() {
    console.log("2. 用沸水浸泡茶叶");
  }

  addCondiments() {
    console.log("4. 加柠檬");
  }
}

class Coffee extends Beverage {
  // 实现父类中留下的“坑”
  brew() {
    console.log("2. 用沸水冲泡咖啡粉");
  }

  addCondiments() {
    console.log("4. 加糖和牛奶");
  }
}

第 3 步:客户端调用

客户端现在面向的是统一的 init 方法。

javascript
const myTea = new Tea();
console.log("--- 制作一杯茶 ---");
myTea.init();

const myCoffee = new Coffee();
console.log("--- 制作一杯咖啡 ---");
myCoffee.init();

通过重构,我们将不变的流程 (init) 提升到了父类,将可变的实现 (brew, addCondiments) 委托给了子类。代码复用性大大提高,结构也更加清晰。

引入“钩子” (Hook)

现在,如果顾客说“我的咖啡不需要加任何调料”,怎么办?我们总不能再创建一个 CoffeeWithoutCondiments 类,然后在 addCondiments 里什么都不做吧?

这就是“钩子”方法登场的时机。钩子是一个在父类中实现的、但通常是空或者返回默认值的方法,子类可以选择性地重写它,来“钩入”算法流程,改变其行为。

javascript
class BeverageWithHook {
  init() {
    this.boilWater();
    this.brew();
    this.pourInCup();
    // 在模板中加入判断逻辑
    if (this.customerWantsCondiments()) {
      // <-- 调用钩子
      this.addCondiments();
    }
  }

  // ... boilWater, pourInCup, brew, addCondiments ...

  // 这就是一个钩子!它提供一个默认行为,但允许子类覆盖它。
  customerWantsCondiments() {
    return true; // 默认需要调料
  }
}

class CoffeeWithHook extends BeverageWithHook {
  brew() {
    /* ... */
  }
  addCondiments() {
    /* ... */
  }

  // 子类通过重写钩子,来控制模板方法的流程
  customerWantsCondiments() {
    // 假设这里有一个弹窗询问用户的逻辑
    return window.confirm("您需要加糖和牛奶吗?");
  }
}

const myHookCoffee = new CoffeeWithHook();
myHookCoffee.init(); // 此时会弹窗询问,根据用户的选择决定是否执行 addCondiments

四、好莱坞原则

模板方法模式完美地体现了“好莱坞原则” (Hollywood Principle)

**“Don't call us, we'll call you.” (别来找我们,我们会来调用你。) **

在这个例子中,父类的 init 方法就是“好莱坞”。它掌握着主动权,决定了在什么时候、以什么顺序去调用子类实现的那些方法。子类只需要提供实现,然后等待被父类“调用”即可。

五、优缺点与适用场景

优点

  1. 代码复用:将通用代码放在父类,提高了代码复用性。
  2. 封装不变部分,扩展可变部分:将固定的算法流程封装起来,将可变的实现细节交给子类,非常符合开放/封闭原则。
  3. 行为控制:父类可以通过模板方法控制子类的行为,确保算法流程的正确性。

缺点

  1. 强依赖继承:由于模式基于继承,导致子类与父类的耦合度较高。
  2. 可读性问题:如果父类的模板方法非常复杂,调用了大量的抽象方法,代码的执行流程可能会在父类和子类之间跳来跳去,增加阅读难度。

适用场景

  • 框架的生命周期:几乎所有框架的生命周期函数都是模板方法模式的体现。例如 Vue 组件的 created, mounted, updated,React 的 componentDidMount, render 等。框架定义了组件渲染的“骨架”,而我们在这些“钩子”里填充具体的业务逻辑。
  • UI 组件库:创建一个 BaseComponent,定义好渲染、事件绑定、销毁的通用流程,然后让具体的 Button, Input 等组件去继承它,并实现自己独特的渲染逻辑。
  • 当多个类中存在完全相同或高度相似的算法,只有个别步骤不同时,非常适合用模板方法模式进行重构。