从类继承到自定义 Hook:模板方法模式的前端演进

模板方法模式可能是你最熟悉却最不自知的设计模式。它不像策略模式那样需要刻意设计,也不像观察者模式那样需要维护订阅列表。它简单到几乎透明------定义一个流程骨架,让子类填充细节------但它的思想贯穿了几乎所有现代前端框架。

然而,现在 React 开发中大家都用 Hooks 了,Vue 也全面转向 Composition API。相对于传统的继承方式,Hooks 体现的是"组合代替继承"的思想------将通用逻辑抽离到函数级别,模块粒度适中,控制起来比继承更加灵活。但这是否意味着模板方法模式过时了?答案是否定的------它只是换了一副面孔,从"继承"变成了"组合"。

本文从 Vue/React 生命周期钩子、通用请求封装、Vue 3 composable 三个真实场景出发,给出模板方法模式的完整实现,对比它与策略模式的本质区别,并讨论模板方法模式在现代前端中从继承到组合的演进。

一、从一个"流程固定"的需求说起

假设你在写一个数据请求模块,每次调用接口都需要经过相同的步骤:添加 Token → 显示 Loading → 发送请求 → 解析响应 → 隐藏 Loading → 错误处理。

最直接的写法是每次都把这几步写一遍,结果就是每个接口函数里都充满了重复的 Loading 控制和 try-catch。

模板方法模式要解决的问题正是:把固定的流程在父类中定义好,把可变的步骤留给子类实现。 子类不需要关心 Loading 什么时候显示、错误什么时候捕获------这些都由父类统一管理。

二、模板方法模式的核心思想

在父类中定义一个算法的骨架(流程),将某些步骤延迟到子类中实现。子类可以重写特定步骤,但不能改变算法的整体结构。

关键点:

  • 固定流程:算法的执行顺序在父类中确定,子类无法改变。
  • 钩子方法:父类提供可选的钩子方法,子类可以选择性地覆盖。
  • 复用与扩展:公共逻辑在父类中复用,差异逻辑在子类中扩展。

如果面试官追问"和策略模式有什么区别",可以回答:模板方法关注"流程的骨架"------步骤顺序固定,部分步骤可定制;策略模式关注"算法的替换"------整个算法可以完全替换。 模板方法是"填空",策略是"换人"。

三、场景1:Vue/React 生命周期钩子------框架内置的模板方法

在前端框架中,模板方法模式最经典的体现就是组件的生命周期。框架定义了组件从创建到销毁的完整流程,开发者在特定的钩子函数中填写自己的逻辑。

以 Vue 3 为例,Vue 内部定义了一个固定的生命周期流程:

javascript 复制代码
// Vue 内部定义的"模板"(简化示意)
class ComponentInstance {
  // 模板方法:固定的生命周期流程
  mount() {
    this.beforeCreate();  // 钩子1
    this.created();       // 钩子2
    this.beforeMount();   // 钩子3
    this.render();        // 核心渲染(框架实现)
    this.mounted();       // 钩子4:你在 mounted 里写的逻辑
    this.beforeUpdate();  // 钩子5
    this.updated();       // 钩子6
  }

  // 默认空钩子,开发者覆盖它们
  beforeCreate() {}
  created() {}
  beforeMount() {}
  mounted() {}    // 你在这里写初始化逻辑
  beforeUpdate() {}
  updated() {}
}

使用时,你只需要在 mounted 里填写自己的初始化代码:

vue 复制代码
<script setup>
import { onMounted } from 'vue';

onMounted(() => {
  // 你的初始化代码:请求数据、绑定事件等
  fetchData();
});
</script>

React 类组件同理------componentDidMountcomponentDidUpdatecomponentWillUnmount 都是模板方法中的钩子:

jsx 复制代码
class MyComponent extends React.Component {
  componentDidMount() {
    // 你的初始化逻辑
  }
  componentDidUpdate(prevProps) {
    // 你的更新逻辑
  }
  componentWillUnmount() {
    // 你的清理逻辑
  }
  render() {
    // 必须实现的步骤
  }
}

Vue 的 mounted、React 的 componentDidMountuseEffect,本质上都是模板方法模式中的钩子。框架定义了组件的完整生命周期流程,开发者只负责在特定的钩子里填写自己的逻辑。开发者不能改变生命周期的执行顺序------mounted 一定在 created 之后执行------但可以决定在每个钩子时做什么。

四、场景2:通用请求封装------固定的请求流程

在项目中,所有接口调用都需要经过相似的流程:添加 Token → 发送请求 → 解析响应 → 错误处理 → 隐藏 Loading。模板方法模式可以将这个固定流程封装到基类中:

javascript 复制代码
// 基类:定义请求流程的骨架
class BaseAPI {
  // 模板方法:固定的请求流程
  async request(url, options = {}) {
    this.beforeRequest(options);          // 步骤1:添加请求头、Token 等
    try {
      const response = await fetch(url, options);
      const data = await this.parseResponse(response); // 步骤2:解析响应
      this.onSuccess(data);               // 步骤3:成功处理
      return data;
    } catch (error) {
      this.onError(error);                // 步骤4:错误处理
      throw error;
    } finally {
      this.onFinally();                   // 步骤5:收尾工作(隐藏 loading 等)
    }
  }

  // 钩子方法:子类可覆盖
  beforeRequest(options) {
    options.headers = options.headers || {};
    options.headers['Content-Type'] = 'application/json';
  }

  async parseResponse(response) {
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json();
  }

  onSuccess(data) {}      // 子类覆盖:成功后的业务处理
  onError(error) {}       // 子类覆盖:统一的错误提示
  onFinally() {}          // 子类覆盖:清理工作
}

// 子类:实现具体的接口调用
class UserAPI extends BaseAPI {
  async getUser(id) {
    return this.request(`/api/users/${id}`);
  }

  async updateUser(id, data) {
    return this.request(`/api/users/${id}`, {
      method: 'PUT',
      body: JSON.stringify(data)
    });
  }

  // 覆盖钩子:添加统一的成功日志
  onSuccess(data) {
    console.log('请求成功:', data);
  }

  // 覆盖钩子:统一错误提示
  onError(error) {
    message.error(error.message || '请求失败');
  }
}

模板方法模式让所有 API 请求的流程在基类中固定下来,子类只需要关注具体的接口和个性化钩子。如果某天需要统一添加 Token 刷新逻辑,只需要改基类的 beforeRequest 方法,所有子类自动生效。

五、场景3:Vue 3 自定义 Hook 的模板流程

Vue 3 的 composable 函数虽然没有继承关系,但同样体现了模板方法模式的思想。封装一个通用的数据加载 Hook,固定的状态管理逻辑在 Hook 内部定义,具体的请求逻辑由调用方传入:

typescript 复制代码
// 通用数据加载模板
export function useDataLoader<T>(fetcher: () => Promise<T>) {
  const data = ref<T | null>(null);
  const loading = ref(false);
  const error = ref<Error | null>(null);

  // 模板方法:固定的加载流程
  const execute = async () => {
    loading.value = true;
    error.value = null;
    try {
      data.value = await fetcher(); // 具体的数据获取逻辑由调用方提供
    } catch (e) {
      error.value = e as Error;
    } finally {
      loading.value = false;
    }
  };

  return { data, loading, error, execute };
}

// 使用:调用方只需提供 fetcher 函数
const { data: users, loading, execute } = useDataLoader(
  () => fetch('/api/users').then(r => r.json())
);

onMounted(() => execute());

useDataLoader 定义了加载数据的固定流程------设置 loading、请求数据、处理结果、finally 收尾。调用方只需要传入一个具体的 fetcher 函数。这和模板方法模式的思想完全一致:流程固定,具体步骤可定制。只是 Vue 3 用组合替代了继承。

六、从继承到组合------模板方法模式在现代前端的演进

为什么类继承式模板方法淡出了?

传统模板方法模式依赖继承。但在 Hooks 时代,这种写法暴露了三个问题:

  1. 灵活性差 :子类只能覆盖父类预留的钩子,不能改变流程的执行顺序。如果某个接口不需要 onFinally,或者想在 beforeRequest 之前做点别的,就很别扭。

  2. 组合困难:一个类只能继承一个父类。如果你想同时复用"日志记录"、"缓存处理"、"错误重试"这些能力,用继承几乎做不到,而组合可以随意拼接。

  3. 与现代框架理念不符 :React 的 Hooks 和 Vue 的 Composables 都强调组合优于继承------把可复用的逻辑抽成独立函数,哪里需要就组合哪里,而不是通过继承链来层层叠加。

模板方法的思想并没有消失------它变成了"组合式模板"

模板方法模式的精髓不是"继承",而是"固定流程 + 可替换的步骤"。 这个思想在 Hooks 时代以更灵活的方式延续着。

我们用一个组合式的 useRequest Hook 来替代前面的 BaseAPI 类:

typescript 复制代码
// hooks/useRequest.ts ------ 组合式模板方法
interface UseRequestOptions<T> {
  fetcher: () => Promise<T>;
  onBefore?: () => void;
  onSuccess?: (data: T) => void;
  onError?: (error: Error) => void;
  onFinally?: () => void;
}

export function useRequest<T>(options: UseRequestOptions<T>) {
  const { fetcher, onBefore, onSuccess, onError, onFinally } = options;
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const execute = async () => {
    onBefore?.();        // 钩子1:前置处理
    setLoading(true);
    setError(null);
    try {
      const result = await fetcher(); // 核心步骤:由调用方提供
      setData(result);
      onSuccess?.(result);  // 钩子2:成功回调
    } catch (err) {
      const e = err as Error;
      setError(e);
      onError?.(e);        // 钩子3:错误回调
    } finally {
      setLoading(false);
      onFinally?.();        // 钩子4:收尾回调
    }
  };

  return { data, loading, error, execute };
}

使用方式------调用方用组合的方式传入钩子函数:

tsx 复制代码
function UserList() {
  const { data: users, loading, execute } = useRequest({
    fetcher: () => fetch('/api/users').then(r => r.json()),
    onSuccess: (data) => message.success(`加载了 ${data.length} 条数据`),
    onError: (err) => message.error(err.message),
  });

  useEffect(() => { execute(); }, []);

  if (loading) return <Spin />;
  return <ul>{users?.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

模板方法思想在这里的体现

  • useRequestexecute 函数定义了固定的执行流程:前置钩子 → Loading → 请求 → 成功/失败回调 → Finally 收尾。
  • 这个流程是固定的,调用方不能改变 try/catch/finally 的执行顺序。
  • 但每一步的具体行为 由调用方通过传入的函数(fetcheronSuccessonError 等)来定制。

这和类继承式模板方法的本质完全一致------流程固定,步骤可替换 。区别只在于:传统模板方法用 extends(继承) 实现,组合式模板用 参数传入(组合) 实现。

模板方法思想在现代框架中的其他体现

你每天都在用模板方法模式的思想,只是不需要自己写类来继承。

React 的生命周期 :React 组件的渲染流程是固定的------挂载、更新、卸载------但你可以在 useEffectuseLayoutEffect 等钩子中插入自定义逻辑。React 定义了"什么时候执行"(模板方法),你决定了"执行什么"(钩子内容)。

Webpack 的 Plugin 和 Loader:Webpack 的构建流程是固定的(读取文件 → 解析 → 转换 → 输出),但你可以在特定阶段通过 Plugin 和 Loader 插入自己的处理逻辑。

路由守卫 :Vue Router 的 beforeEach、React Router 的路由配置中的鉴权逻辑,本质都是"框架定义了路由跳转的固定流程,开发者在特定节点插入校验逻辑"。

什么时候还是用传统模板方法(类继承)?

组合式模板虽然灵活,但继承式模板方法在某些场景下仍然有价值

  1. 需要强制约束流程时:继承式模板方法通过父类控制流程,子类无法绕过。组合式中,调用方理论上可以不传某些钩子,约束力稍弱。

  2. 框架/库的底层设计:Vue、React、Webpack 这些框架内部依然大量使用继承来定义基础类,因为框架需要强约束,不能依赖使用者"自觉"传参。

  3. 与现有面向对象代码集成:如果你维护的是一套旧版类组件,或者使用了 NestJS 这类重度依赖装饰器和继承的框架,传统的模板方法模式仍然是首选。

八、总结

模板方法模式是现代前端中最"透明"的设计模式。它的核心价值在于固定流程、开放扩展

场景 模板定义者 步骤填充者 实现方式
Vue 生命周期 Vue 框架(mount 流程) 开发者(mounted 钩子) 继承 / 组合
React 类组件 React 框架(渲染流程) 开发者(componentDidMount 等) 继承
通用请求封装(类) BaseAPI(request 方法) 子类(onSuccessonError 继承
数据加载 Hook useDataLoaderexecute 流程) 调用方(fetcher 函数) 组合
useRequest Hook execute 流程 调用方(钩子函数参数) 组合

模板方法模式让"流程"和"细节"分离。当你发现自己在多个地方写着结构相似但内容不同的代码时,问自己一句:这个流程能固定下来,只让调用方填空吗? 如果可以,模板方法模式就是你需要的答案------至于用继承还是组合来实现,取决于你的项目风格和约束需求。在函数式编程成为主流的今天,组合式模板方法往往是更灵活、更现代的选择。

相关推荐
Quz1 小时前
QML 线性布局:Row/RowLayout 与 Column/ColumnLayout
前端·qt
小陈工1 小时前
第8篇:Flask轻量级框架与扩展生态深度解析(下)
后端·python·面试
你为她披上外套时我正站在窗外1 小时前
用 Rust + WebAssembly 写了个字幕工具,文件全程不离开浏览器
前端
帅帅哥的兜兜1 小时前
AI模型初了解
前端
bonechips1 小时前
React + WebGPU(二):合成事件与进度条组件封装
前端·react.js
我是大卫1 小时前
【图】React源码解析-从底层数据结构、渲染调度到闭包原理深挖useRef的核心机制
前端·react.js·源码
王中阳Go1 小时前
面试拷打实录:候选人聊Agent/RAG时的典型误区,我给了这些“避坑指南”
后端·面试·agent
光影少年1 小时前
RN导航与路由
前端·react native·react.js
兰令水1 小时前
hot100【acm版】【2026.7.21打卡-java版本】
java·开发语言·算法·leetcode·面试