Appearance
5.3 模板方法模式 (Template Method Pattern)
模板方法模式是一种在框架和库中被广泛应用的、基于继承的行为型设计模式。它的核心思想非常直观:定义一套标准流程,但允许子类定制流程中的某些具体步骤。
核心定义:在一个方法中定义一个算法的骨架(或称模板),而将一些步骤延迟到子类中去实现。模板方法使得子类可以在不改变一个算法的结构的情况下,重新定义该算法的某些特定步骤。
一、生活中的场景:标准化的饮料制作流程
想象一下你在一家饮品店工作。无论是制作一杯“茶”还是一杯“咖啡”,都遵循一个非常相似的标准作业流程 (SOP):
- 把水烧开 (boilWater)
- 用沸水冲泡原料 (brew)
- 把饮料倒进杯子 (pourInCup)
- 在饮料中加入调料 (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();这段代码充满了重复!烧水和倒杯子的逻辑在两个类中一模一样。如果未来制作流程要增加一个“冷却”步骤,你需要同时修改 Tea 和 Coffee 两个类,这非常不利于维护。
三、使用模板方法模式重构
第 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 方法就是“好莱坞”。它掌握着主动权,决定了在什么时候、以什么顺序去调用子类实现的那些方法。子类只需要提供实现,然后等待被父类“调用”即可。
五、优缺点与适用场景
优点
- 代码复用:将通用代码放在父类,提高了代码复用性。
- 封装不变部分,扩展可变部分:将固定的算法流程封装起来,将可变的实现细节交给子类,非常符合开放/封闭原则。
- 行为控制:父类可以通过模板方法控制子类的行为,确保算法流程的正确性。
缺点
- 强依赖继承:由于模式基于继承,导致子类与父类的耦合度较高。
- 可读性问题:如果父类的模板方法非常复杂,调用了大量的抽象方法,代码的执行流程可能会在父类和子类之间跳来跳去,增加阅读难度。
适用场景
- 框架的生命周期:几乎所有框架的生命周期函数都是模板方法模式的体现。例如 Vue 组件的
created,mounted,updated,React 的componentDidMount,render等。框架定义了组件渲染的“骨架”,而我们在这些“钩子”里填充具体的业务逻辑。 - UI 组件库:创建一个
BaseComponent,定义好渲染、事件绑定、销毁的通用流程,然后让具体的Button,Input等组件去继承它,并实现自己独特的渲染逻辑。 - 当多个类中存在完全相同或高度相似的算法,只有个别步骤不同时,非常适合用模板方法模式进行重构。