设计模式在前端开发中的实践(九)——模板方法模式

模板方法模式

模板方法模式是一种行为设计模式,它在父类中定义了一个操作的算法骨架,而将一些步骤的实现延迟到子类中。通过这种方式,模板方法允许子类在不改变算法结构的情况下重定义算法的某些特定步骤。

1、基本概念

模板方法模式是一个十分简单且容易掌握的设计模式,如果你在之前没有看过设计模式相关的知识点,但是在大学的课程中,面向对象编程的基本功非常扎实,可能就已经不由自主的就掌握了这个设计模式,哈哈哈。

模板方法模式是一个基于继承的设计模式,它是里氏代换原则的体现。它的核心思路很简单,将一些抽象化的操作抽离到基类中,将一些可能变化的操作操作交给子类根据对应的业务实现,利用了多态的特性,从而实现代码复用。

尤其是在前几年React Hook没出现的时候,当时还需要用class编写类组件,使用模板方法模式将一些抽象的渲染内容(或公共的内容)父类渲染,子类继承父类,然后根据对应的业务重写父类的某些方法(又有点儿像Vue的插槽),开发效率相当高。

虽然模板方法模式是基于继承的设计模式,在ES6class出现之前,仍然在实际开发中常用,而且,模板方法模式往往可以和很多设计模式结合使用(如:工厂模式命令模式状态模式职责链模式策略模式等)。从我个人的开发经验来说的话,我觉得模板方法模式最常搭配的设计模式就是策略模式了。

现如今的JavaScript已经拥抱了函数式编程,模板方法模式在使用框架编写业务代码时可能应用的场景会相对较少一些了,但是如果你开发一些库的话,这个设计模式是一个你不得不掌握的设计模式。

模板方法模式的UML结构图如下:

2、代码范式

以下是基于一个股票交易场景给出的一个实现方式,由于科创板和创业板有资产的限制,因此,将验证方法抽离到子类

ts 复制代码
abstract class ContractTransaction {
  validVolume(num: number) {
    return num % 100 === 0;
  }

  validTransactionTime(time: Date) {
    const am = [9.5 * 3600 * 1000, 11.5 * 3600 * 1000];
    const pm = [13 * 3600 * 1000, 15 * 3600 * 1000];
    const tick =
      (time.getHours() * 3600 + time.getMinutes() * 60 + time.getSeconds()) *
        1000 +
      time.getMilliseconds();
    return (tick >= am[0] && tick <= am[1]) || (tick >= pm[0] && tick <= pm[1]);
  }

  abstract validContractRestrict(stockCode: string, assets: number): boolean;

  buy(stockCode: string, volume: number, assets: number): void {
    if (!this.validTransactionTime(new Date("2023/04/07 10:00:00"))) {
      console.log("非交易时间,无法交易");
      return;
    }
    if (!this.validVolume(volume)) {
      console.log("买卖数量必须是100的整数");
      return;
    }
    if (!this.validContractRestrict(stockCode, assets)) {
      console.log("因您的资产限制,无法买卖当前合约");
      return;
    }
    console.log(`您已成功买入合约:${stockCode},数量:${volume}`);
  }
}

class ShangHaiContractTransaction extends ContractTransaction {
  validContractRestrict(stockCode: string, assets: number): boolean {
    if (!/^(SHSE)?688/i.test(stockCode)) {
      return true;
    }
    return /^(SHSE)?688/i.test(stockCode) && assets >= 500000;
  }
}

class ShenzhenContractTansaction extends ContractTransaction {
  validContractRestrict(stockCode: string, assets: number): boolean {
    if (!/^(SZSE)?30/i.test(stockCode)) {
      return true;
    }
    return /^(SZSE)?30/i.test(stockCode) && assets >= 100000;
  }
}

function getTransactionStrategy(stockCode): ContractTransaction {
  let stg: ContractTransaction;
  if (/^(SHSE)?6/i.test(stockCode)) {
    stg = new ShangHaiContractTransaction();
  } else {
    stg = new ShenzhenContractTansaction();
  }
  return stg;
}

(function bootstrap() {
  const stocks = ["6000036", "688688", "002230", "300059"];
  stocks.forEach((code) => {
    const stg = getTransactionStrategy(code);
    stg.buy(code, 100 * Math.floor(Math.random() * 10), 30000);
  });
  // 您已成功买入合约:6000036,数量:200
  // 因您的资产限制,无法买卖当前合约
  // 您已成功买入合约:002230,数量:800
  // 因您的资产限制,无法买卖当前合约
})();

给大家补充一个知识点👉在JavaScript中,如果需要抽象类,可以使用如下形式:

js 复制代码
class SomeClass {
  someMethod() {
    throw new Error("this method must be implemented by sub-class");
  }
}

3、在前端开发中的实践

在前端开发中,模板方法模式应用场景太多了,凡是在业务中有某些业务具有一定的通用性,某些场景下又具有一些特殊性,这类场景基本都可以采用模板方法模式。

因此,就给大家看一下我印象深刻的一些模板方法模式实际应用场景。

3.1 在vue-router中的应用

它可以根据用户选择的模式决定应用特定模式的实现。

vue-router/src/index.js中,会根据用户选择的模式匹配相应的路由替换规则。(以版本3.5.4为例)

js 复制代码
// 节选
import { HashHistory } from "./history/hash";
import { HTML5History } from "./history/html5";
import { AbstractHistory } from "./history/abstract";

export default class VueRouter {
  constructor(options: RouterOptions = {}) {
    let mode = options.mode || "hash";
    this.fallback =
      mode === "history" && !supportsPushState && options.fallback !== false;
    if (this.fallback) {
      mode = "hash";
    }
    if (!inBrowser) {
      mode = "abstract";
    }
    this.mode = mode;

    switch (mode) {
      case "history":
        this.history = new HTML5History(this, options.base);
        break;
      case "hash":
        this.history = new HashHistory(this, options.base, this.fallback);
        break;
      case "abstract":
        this.history = new AbstractHistory(this, options.base);
        break;
      default:
        if (process.env.NODE_ENV !== "production") {
          assert(false, `invalid mode: ${mode}`);
        }
    }
  }
}

vue-router/src/history目录下,History类定义了一些基础的约束,面向不同API的实现策略,继承自History

js 复制代码
// 节选自vue-router/src/history/base.js
export class History {
  // 已省略无关代码
  // implemented by sub-classes
  +setupListeners: Function;
}

// 节选自vue-router/src/history/hash.js
export class HashHistory extends History {
  // 已省略无关代码
  /**
   * 哈希模式用hashchange事件进行监听
   */
  setupListeners() {
    const eventType = supportsPushState ? "popstate" : "hashchange";
    window.addEventListener(eventType, handleRoutingEvent);
    this.listeners.push(() => {
      window.removeEventListener(eventType, handleRoutingEvent);
    });
  }
}

// 节选自vue-router/src/history/history.js
export class HTML5History extends History {
  // 已省略无关代码
  /**
   * Html5 History模式用popstate事件进行监听
   */
  setupListeners() {
    window.addEventListener("popstate", handleRoutingEvent);
    this.listeners.push(() => {
      window.removeEventListener("popstate", handleRoutingEvent);
    });
  }
}

3.2 在typeorm中的应用

typeorm中(nodejs生态中大名鼎鼎的一个ORM库),它可以根据用户连接数据库,决定生成对应数据库的SQL方言 (这个词能够确切的阐述不同的数据库SQL语句的差异,所以就像我们普通话和四川话、湖南话的差异一样,所以叫做方言特别的贴切):

BaseQueryRunner

ts 复制代码
export abstract class BaseQueryRunner {
  // 省略代码,有兴趣的读者可以直接在github查看
}

MySQLQueryRunner

ts 复制代码
export class MysqlQueryRunner extends BaseQueryRunner implements QueryRunner {
  // 省略无关代码,有兴趣的读者可以直接在github查看
}

SqlServerRunner

ts 复制代码
export class SqlServerQueryRunner extends BaseQueryRunner implements QueryRunner {
  // 省略代码,有兴趣的读者可以直接在github查看
}

其余数据库的 runner,有兴趣的同学可以自行查看。

以下是两个Driver的示例:

MysqlDriver

ts 复制代码
/**
 * Organizes communication with MySQL DBMS.
 */
export class MysqlDriver implements Driver {
  // 省略其他无关方法
  /**
   * Creates a query runner used to execute database queries.
   */
  createQueryRunner(mode: ReplicationMode) {
    return new MysqlQueryRunner(this, mode);
  }
}

SqlServerDriver

ts 复制代码
/**
 * Organizes communication with SQL Server DBMS.
 */
export class SqlServerDriver implements Driver {
  // 省略其他无关方法
  /**
   * Creates a query runner used to execute database queries.
   */
  createQueryRunner(mode: ReplicationMode) {
    return new SqlServerQueryRunner(this, mode);
  }
}

然后在调用侧,根据数据库的环境,选择对应的Runner就可以了(typeorm是选择跟对应的Driver绑定,这个无关紧要,因为最终还是会根据数据库类型选择对应的Driver),而且日后将来需要新增新的数据库支持,再编写一套对应的QueryRunner实现即可,这样的设计是符合开闭原则的。

以下是typeormDriver工厂:

ts 复制代码
/**
 * Helps to create drivers.
 */
export class DriverFactory {
  /**
   * Creates a new driver depend on a given connection's driver type.
   */
  create(connection: DataSource): Driver {
    const { type } = connection.options;
    // 省略了一些代码
    switch (type) {
      case "mysql":
        return new MysqlDriver(connection);
      case "mssql":
        return new SqlServerDriver(connection);
      default:
        throw new MissingDriverError(type, [
          "mariadb",
          "mongodb",
          "mssql",
          "mysql"
        ]);
    }
  }
}

3.3 在早期的React类式组件中使用

在早期的React类式组件中,基于继承写页面是非常舒服的,比如一个管理系统,大多数场景都是增删改查,那基本上我们对其进行抽象的话,Body是一个表格,可以靠传入一些配置实现;Header可能存在,需要进行搜索;Body可能存在,需要进行分页;在这种项目里,模板方法模式是可以乱杀的,哈哈哈。

基于这种场景,我们先编写一个通用的框架Demo。

父类:

js 复制代码
import React, { Component } from 'react';
class BasePage extends Component {
    // 模板方法,子类可以重写这些方法来实现自定义的 header 和 footer
    renderHeader() {
        // 默认情况下,没有 header
        return null;
    }

    renderFooter() {
        // 默认情况下,没有 footer
        return null;
    }

    // Body 是必须的,所以在基类中实现
    renderBody() {
        return (
            <div className="page-body">
                {/* 具体页面的内容 */}
                {this.props.children}
            </div>
        );
    }

    render() {
        return (
            <div className="page">
                {this.renderHeader()}
                {this.renderBody()}
                {this.renderFooter()}
            </div>
        );
    }
}

export default BasePage;

具体的页面实现类:

js 复制代码
import React from "react";
import BasePage from "./BasePage";

class AdminPage extends BasePage {
  renderHeader() {
    return (
      <div className="page-header">
        {/* Header 内容 */}
        <h1>Admin Dashboard</h1>
      </div>
    );
  }

  renderFooter() {
    return (
      <div className="page-footer">
        {/* Footer 内容 */}
        <p>© 2024 Admin Dashboard</p>
      </div>
    );
  }
}

export default AdminPage;

不过,现在React的开发中大家都使用Hooks API了,相对于这种继承方式来说,使用Hooks开发是另外一种思想的体现:"组合代替继承",将一些通用的对象抽离到函数级别,模块的粒度适中,控制起来实际上相比继承更加灵活。

不过,大家千万不要因为这样就觉得模板方法模式不好哦,这是因为目前前端的发展趋势是面向函数式的编程方式,而在传统的面向对象编程的方式中,模板方法模式还有有自己的舞台的。

3.4 在NestJS广泛应用

在之前我关于NestJS源码分析的文章中,我们看到了模板方法模式的广泛应用。

为什么我们在分析到拦截器的时候,发现它是基于模板方法模式的设计的时候,我们在做守卫和管道的分析时就在偷懒呢,就是因为模板方法模式具备这样的特性,因为共性已经被抽离到了基础类了,我们只需要基于实际业务看实现类。

在这儿给大家分析一下NestJS的作者是怎么样把模板方法模式用到随心所欲的。

首先,定义了一个ContextCreator的抽象类。

ts 复制代码
// 以下代码有删减哦
export abstract class ContextCreator {
  // 被抽象的行为,由具体的子类实现
  public abstract createConcreteContext<T extends any[], R extends any[]>(
    metadata: T,
    contextId?: ContextId,
    inquirerId?: string,
  ): R;
  // 被抽象的行为,由具体的子类实现
  public getGlobalMetadata?<T extends any[]>(
    contextId?: ContextId,
    inquirerId?: string,
  ): T;

  public createContext<T extends unknown[] = any, R extends unknown[] = any>(
    instance: Controller,
    callback: (...args: any[]) => void,
    metadataKey: string,
    contextId = STATIC_CONTEXT,
    inquirerId?: string,
  ): R {
    // 怎么样获取到数据,由子类决定的,此处只管获取的数据,不管过程
    const globalMetadata =
      this.getGlobalMetadata &&
      this.getGlobalMetadata<T>(contextId, inquirerId);
    const classMetadata = this.reflectClassMetadata<T>(instance, metadataKey);
    const methodMetadata = this.reflectMethodMetadata<T>(callback, metadataKey);
    // 共性逻辑,获取到所有开发者配置的控件以应用
    return [
      ...this.createConcreteContext<T, R>(
        globalMetadata || ([] as T),
        contextId,
        inquirerId,
      ),
      ...this.createConcreteContext<T, R>(classMetadata, contextId, inquirerId),
      ...this.createConcreteContext<T, R>(
        methodMetadata,
        contextId,
        inquirerId,
      ),
    ] as R;
  }
}

这个类抽象了一个通用的行为,那就是获取元数据中定义的控件(拦截器,过滤器,守卫,管道),具体怎么获取控件,就由各自的控件实现类重写相应的方法了。

比如过滤器:

ts 复制代码
// 以下代码有删减哦
export class BaseExceptionFilterContext extends ContextCreator {
  protected moduleContext: string;

  constructor(private readonly container: NestContainer) {
    super();
  }
  // 过滤器的任务是获取所有开发者配置的过滤器
  public createConcreteContext<T extends any[], R extends any[]>(
    metadata: T,
    contextId = STATIC_CONTEXT,
    inquirerId?: string,
  ): R {
    // some logical
  }
}

上面的实现中,BaseExceptionFilterContext仅仅是确定了获得过滤器的过程,但是怎么样获取元数据的行为它也没有实现,继续交给它的子类实现,因为子类不是关键,我们就不展示了。

比如拦截器:

ts 复制代码
// 以下代码有删减哦
export class InterceptorsContextCreator extends ContextCreator {
  private moduleContext: string;

  constructor(
    private readonly container: NestContainer,
    private readonly config?: ApplicationConfig,
  ) {
    super();
  }

  public create(
    instance: Controller,
    callback: (...args: unknown[]) => unknown,
    module: string,
    contextId = STATIC_CONTEXT,
    inquirerId?: string,
  ): NestInterceptor[] {
    // some logical 
  }
  
  // 实现父类的实现
  public createConcreteContext<T extends any[], R extends any[]>(
    metadata: T,
    contextId = STATIC_CONTEXT,
    inquirerId?: string,
  ): R {
    // some logical
  }
  
  //实现父类的实现
  public getGlobalMetadata<T extends unknown[]>(
    contextId = STATIC_CONTEXT,
    inquirerId?: string,
  ): T {
    // some logical
  }
}

经过这样设计,对于这些控件的管理者来说是很方便的,只需要在它需要"上岗"的位置安排他们就可以完成系统的功能。

总结

模板方法模式的优点体现在这些方面:

  • 它在父类中定义了算法框架,因此可以重用算法框架,同时使得算法的维护集中于一个地方;
  • 通过允许子类重写算法中的特定步骤,其提供了很好的扩展性;
  • 确保了算法的结构保持不变,同时允许子类提供部分实现。这种结构化的方法也有助于防止代码重复;
  • 提供了明确的控制点和扩展点,有利于在复杂的系统中保持控制;

由于设计的骨架是固定的,它可能限制了实现的灵活性,就比如我们在阐述React类式组件的抽象中,如果面对比较复杂的页面,这个时候用起来需要处理的逻辑就会变得很复杂;因为它的骨架已经确定了,这也要求了子类必须遵循这种规范,在设计和实现的过程中,也会受一定的限制。

当我们有一个业务希望在不同情境下以不同的方式执行这个算法的特定步骤时,模板方法模式就会非常有用,我们在父类中定义抽象业务的不变部分,然后在子类根据业务实现可变的部分。

相关推荐
雾散声声慢1 分钟前
前端开发中怎么把链接转为二维码并展示?
前端
熊的猫2 分钟前
DOM 规范 — MutationObserver 接口
前端·javascript·chrome·webpack·前端框架·node.js·ecmascript
天农学子2 分钟前
Easyui ComboBox 数据加载完成之后过滤数据
前端·javascript·easyui
mez_Blog3 分钟前
Vue之插槽(slot)
前端·javascript·vue.js·前端框架·插槽
爱睡D小猪6 分钟前
vue文本高亮处理
前端·javascript·vue.js
开心工作室_kaic9 分钟前
ssm102“魅力”繁峙宣传网站的设计与实现+vue(论文+源码)_kaic
前端·javascript·vue.js
放逐者-保持本心,方可放逐9 分钟前
vue3 中那些常用 靠copy 的内置函数
前端·javascript·vue.js·前端框架
IT古董10 分钟前
【前端】vue 如何完全销毁一个组件
前端·javascript·vue.js
Henry_Wu00112 分钟前
从swagger直接转 vue的api
前端·javascript·vue.js
奋飞安全12 分钟前
初试js反混淆
开发语言·javascript·ecmascript