模板方法模式 📖
定义
模板方法模式(Template Method Pattern) 保留整个流程的骨架,只替换掉其中的某些器官/步骤。
在一个方法中定义一个算法的骨架,而将一些步骤的实现延迟到子类中。模板方法使得子类可以在不改变算法结构的情况下,重新定义算法中的某些特定步骤。
星巴克泡茶与泡咖啡:不管泡什么,大体流程都是:1. 把水煮沸 → 2. 用沸水冲泡 → 3. 把饮料倒入杯子 → 4. 加调料。
这个“四步走”的流程绝对不能乱,这就是模板(骨架)。但是第2步(泡茶叶还是泡咖啡豆)和第4步(加柠檬还是加牛奶),则交给具体的饮料种类(子类)去决定。
痛点:在多个相似的业务中,存在大量高度重复的“样板代码(Boilerplate)”和固定的执行顺序,只有微小的细节不同。如果复制粘贴,代码将极难维护。
解决:将固定的流程写死在父类/基础函数的“模板方法”中,把可变的部分作为抽象方法,留给子类/回调去实现。
与设计原则及架构思想的关系
- 好莱坞原则 (Hollywood Principle):
- “不要给我们打电话,我们会给你打电话。(Don’t call us, we’ll call you.)”
- 这是模板方法模式的核心思想,也被称为控制反转。在平时写代码时,是我们主动调用系统的 API;但在模板模式中,是父类(框架)掌控全局执行流程,在执行到特定节点时,父类主动去调用子类提供的方法。
- DRY 原则 (Don’t Repeat Yourself - 不要重复自己):
- 最大限度地提取了公共代码,把所有通用的逻辑(如煮开水、倒杯子)收拢在基类中。
- 开闭原则 (Open-Closed Principle):
- 算法的骨架是封闭的(不可随意修改流程),但具体的细节步骤是开放的(可以通过新增子类随意扩展)。
实现
1. 传统 OOP 的经典实现(类与继承)
我们用“泡饮料”的例子来看看标准写法。
// 1. 抽象父类 (定义骨架)
class Beverage {
// 这是【模板方法】,规定了死流程。
// 在 Java 中通常会加上 final 关键字防止子类重写,JS中靠团队约定。
prepareRecipe() {
this.boilWater();
this.brew(); // 抽象步骤,留给子类实现
this.pourInCup();
if (this.customerWantsCondiments()) { // 钩子方法(Hook)
this.addCondiments(); // 抽象步骤,留给子类实现
}
}
// 公共方法:直接在父类实现,大家共用
boilWater() { console.log("1. 把水煮沸"); }
pourInCup() { console.log("3. 把饮料倒进杯子"); }
// 抽象方法:JS没有 abstract 关键字,所以抛出错误逼迫子类实现
brew() { throw new Error("子类必须实现 brew 方法"); }
addCondiments() { throw new Error("子类必须实现 addCondiments 方法"); }
// 钩子方法(Hook):父类提供默认实现,子类可以选择性重写来微调流程
customerWantsCondiments() { return true; }
}
// 2. 具体子类:咖啡
class Coffee extends Beverage {
brew() { console.log("2. 用沸水冲泡咖啡豆"); }
addCondiments() { console.log("4. 加糖和牛奶"); }
}
// 3. 具体子类:茶
class Tea extends Beverage {
brew() { console.log("2. 用沸水浸泡茶叶"); }
addCondiments() { console.log("4. 加柠檬"); }
// 重写了钩子方法:这杯茶顾客不加调料!
customerWantsCondiments() { return false; }
}
// ================= 使用 =================
console.log('--- 冲泡咖啡 ---');
const coffee = new Coffee();
coffee.prepareRecipe(); // 调用模板方法
console.log('\n--- 冲泡一杯不加柠檬的茶 ---');
const tea = new Tea();
tea.prepareRecipe();
2. 现代 JS 的高阶函数实现(极其常用)
在现代前端(特别是 React Hooks 生态中),我们已经不怎么写 class 和继承了。利用“高阶函数(HOF)”或“回调函数”,我们可以优雅地实现模板方法模式。
以“发请求的统一流程”为例,固定骨架是:开启 Loading → 发请求 → 关闭 Loading → 处理错误。
// 模板函数:定义了请求的骨架
async function requestTemplate(requestAction, onSuccess) {
try {
console.log("开启全局 Loading 动画..."); // 固定步骤
// 这里的 requestAction 就是留给调用者传入的“具体步骤”
const data = await requestAction();
onSuccess(data); // 留给调用者处理数据
} catch (error) {
console.log("全局错误处理: 弹出 Toast", error.message); // 固定步骤
} finally {
console.log("关闭全局 Loading 动画...\n"); // 固定步骤
}
}
// 使用:业务侧只需要填入具体的“请求动作”和“成功逻辑”
requestTemplate(
() => fetch('https://jsonplaceholder.typicode.com/todos/1').then(res => res.json()), // 自定义步骤1
(data) => console.log('渲染页面数据:', data) // 自定义步骤2
);
前端应用
前端可能是把模板方法模式玩得最溜的领域,只是很多时候它换了个名字。
1. UI 框架的生命周期 (Lifecycle Hooks)
无论是 Vue 还是 React 的老类组件,它们底层的渲染管线(Render Pipeline)就是一个超级巨大的模板方法。
- 骨架:框架内部规定了组件从创建、解析模板、挂载 DOM、更新、直到销毁的严格顺序。
- 钩子 (Hooks):
created/mounted/componentDidMount等生命周期函数,正是框架开放给你的抽象方法或钩子方法。 - 控制反转:你写的
mounted函数你从来没有主动调用过,都是 Vue/React 在底层模板的特定时机主动调用你的。这就是“不要给我们打电话,我们会给你打电话”。
2. Vue 的 <slot> 与 React 的 props.children
这是UI 维度的模板方法模式。
假设你要封装一个标准的 <Dialog> 组件:
- 骨架:遮罩层、白底弹窗、右上角的 X 按钮、底部的确认/取消按钮。这些 HTML 结构是固定的。
- 可变部分:弹窗中间具体显示什么文字?显示表单还是图片?
通过预留 <slot> (插槽),父组件定义了 UI 骨架,而具体的局部 UI 内容被延迟到调用该组件的时候由外面塞进来。
3. 前端工程化中的 Webpack / Vite 插件机制
以 Webpack 为例,Webpack 的核心对象 Compiler 内部定义了整个构建过程的生命周期(初始化 → 编译 → 输出 → 完成)。
如果你要写一个 Webpack Plugin,你实际上是在顺着 Webpack 定义好的流程模板,在特定的钩子(如 compiler.hooks.emit.tap)中注入你的自定义逻辑。
4. 后台管理系统的 CRUD 页面封装
在做中后台管理系统时,90% 的页面逻辑都是:搜索表单 → 调 API 获取表格数据 → 渲染表格 → 分页器。
高级前端工程师通常会封装一个 BaseTableList 类(或 Vue 中的 Mixin / React 中的自定义 Hook useTable)。
这个基础模块中定义了完整的 fetchData 流程(带防抖、带 Loading 状态、带分页重置)。具体的子页面只需要配置:调用哪个 API 地址?表格有几列?这就是经典的业务级模板方法。
总结
- 当你发现你的代码里有多个流程**“长得非常像,只是某些局部有区别”**时。
- 当你想要封装一个强大的基础组件或工具,你想控制全局流程,但又想把某些自定义的权力下放给使用者时。
请马上使用模板方法模式(通过抽象类继承,或者通过高阶函数传回调)。它能让你的代码抽象层级极大提高,真正写出具有“框架感”的高级代码。