设计模式在前端开发中的实践(十)——命令模式

1,293 阅读10分钟

命令模式

命令模式能够算得上是在前端开发中最不会被滥用的设计模式之一,因为命令模式具有明显的使用业务场景,基本上都是前端实现各种编辑器的情况。

1、基本概念

命令模式是一种行为型设计模式,其目的是将一个请求或操作封装成一个独立的对象,以便于在不同的上下文环境中使用、传递和操作。

该模式实现了请求的发送者和接收者之间的松耦合,使你可用不同的请求对客户进行参数化;

命令模式的优点是支持请求排队记录日志,以及可撤销的操作。

正因为命令模式的这些特征,虽使得它的使用场景有限,但是使用场景特别明确(你不会有误用某个设计模式而增加系统的设计成本的心智负担)

命令模式的UML图如下:

command-pattern.png

上述这个UML图一眼看起来可能会让人摸不着头脑,逐一对其进行分析发现其实也不难理解。首先,Client类的依赖关系可以不用看,因为它不是命令模式的核心。

Command是一个接口,定义命令的规格,业务命令类根据其负责的业务逻辑实现Command接口,它的内部需要持有一个Receiver类的实例(关联关系),其目的是为了Command的实现类在执行特定操作的时候,能够将消息报告给外界(如果你不需要这样的操作,这一步也可以省略);

关键的设计在于Invoker类,它内部管理着一批命令(因此UML图中用的是聚合关系),而我们的撤销日志记录请求排队等操作全部都在于这个类中进行控制的(一般用队列实现,如果有复杂优先级的处理,还可以使用进行管理)。

2、代码示例

/**
 * 消息通知类
 */
class Receiver {
  notify() {
    console.log("通知消息已传达~~");
  }
}

/**
 * 命令接口
 */
interface Command {
  action: Action;

  exec(): void;
}

/**
 * 业务命令接口
 */
class CopyCommand implements Command {
  constructor(public action: Action) {}

  // #region 非必须,可以根据业务需要决定
  receiver: Receiver;

  setReceiver(r: Receiver) {
    this.receiver = r;
  }
  // #endregion

  exec(): void {
    // 非必须,可以根据业务决定
    this.receiver.notify();
    this.action.work();
  }
}

/**
 * 命令调用者,命令模式核心类
 */
class Invoker {
  protected cmd: Command;

  setCommand(cmd: Command) {
    this.cmd = cmd;
  }

  execCommand() {
    this.cmd.exec();
  }
}

class Action {
  work() {
    console.log("干活儿");
  }
}

const copyAction = new Action();
const copyCmd = new CopyCommand(copyAction);
const invoker = new Invoker();
invoker.setCommand(copyCmd);
invoker.execCommand();

上述代码中,CopyCommand的行为是外部注入(依赖倒置原则的体现)的,如果在实际业务中,你的业务场景如果足够简单的话,也可以直接将行为内聚到业务命令接口内,可以根据实际情况灵活调整。

3、在前端中的实践

命令模式是我学习设计模式中少有的一开始学习前端就掌握的设计模式,所以我在实际开发中应用了很多次了。

3.1 图形编辑器

我所负责的业务需求,只要遇到编辑器这类业务场景(几乎都会有撤销重做等需求),我都会使用命令模式进行实现。我就以Antv/X6@antv/x6-plugin-history插件的源码给大家举个例子。

antv-demo (1).png

import { KeyValue, Basecoat, Model, Graph } from "@antv/x6";
import "./api";

export class History
  extends Basecoat<History.EventArgs>
  implements Graph.Plugin
{
  public name = "history";
  protected redoStack: History.Commands[];
  protected undoStack: History.Commands[];
  protected batchCommands: History.Command[] | null = null;
  protected stackSize = 0; // 0: not limit

  protected readonly handlers: (<T extends History.ModelEvents>(
    event: T,
    args: Model.EventArgs[T]
  ) => any)[] = [];

  constructor(options: History.Options = {}) {
    super();
    const { stackSize = 0 } = options;
    this.stackSize = stackSize;
  }

  undo(options: KeyValue = {}) {}

  redo(options: KeyValue = {}) {}

  clean(options: KeyValue = {}) {}
  // #endregion

  protected createCommand(options?: { batch: boolean }): History.Command {
    return {
      batch: options ? options.batch : false,
      data: {} as History.CreationData,
    };
  }

  protected revertCommand(cmd: History.Commands, options?: KeyValue) {}

  protected applyCommand(cmd: History.Commands, options?: KeyValue) {}

  protected executeCommand(
    cmd: History.Command,
    revert: boolean,
    options: KeyValue
  ) {}

  protected addCommand<T extends keyof Model.EventArgs>(
    event: T,
    args: Model.EventArgs[T]
  ) {}

  protected notify(
    event: keyof History.EventArgs,
    cmd: History.Commands | null,
    options: KeyValue
  ) {}

  protected push(cmd: History.Command, options: KeyValue) {}

  protected undoStackPush(cmd: History.Commands) {}
}

export namespace History {
  export interface Command {
    batch: boolean;
    modelChange?: boolean;
    event?: ModelEvents;
    data: CreationData | ChangingData;
    options?: KeyValue;
  }
  export type Commands = History.Command[] | History.Command;
}

以上代码经过了节选,如果你想阅读源码,可以查看此处

上述代码,Commond类相当于定义了一个算法规范,然后我们可以根据某个具体的业务Commond实现这个Common类,并且重写它的某些方法,这就回到我们上一篇文章中所提到的模板方法模式👉设计模式在前端开发中的实践(九)——模板方法模式

然后,History类在管理各类命令时就会变得非常的灵活。

3.2 可取消的命令

命令模式的另外一种场景就是本例即将展示的实现发出命令的对象和执行命令的对象之间的解耦

这个例子是最近在Promise的处理中遇到的一点儿启发。

先给大家描述一下需求:

我需要做一个音乐审核后台,这个后台的用户需要判断音乐是否有噪音,每个人一天至少要审核几十首歌,一个音乐有20MB(因为是wav格式,没有压缩),如果想让用户在用的时候再下载的话,加载音乐预计花费 1 分钟的样子,使用起来极其难受;

用户提出能不能先将其缓存(比如我现在需要去喝杯咖啡,我可以先将一系列的资源缓存下来,一会儿回来之后,我再处理,将会是秒开,工作生活两不误),为了让用户实现一键缓存的效果(如果让用户一个一个的点,那得把开发骂死😂),此时不管他有多少待审核的,肯定是要全部缓存完的,这种场景肯定是要控制并发量的,否则用起来相当的卡顿,因此会引入队列的设计;

但是还有个问题,我页面上可能还有其它异步请求,缓存音乐对于我来说是一个优先级不是那么高的操作,对于其它异步请求,可以将其包裹成异步任务放在任务队列的前面去处理;

接着又有一个新问题,如果这个异步任务还没有执行,但是我此时想要撤销(比如一进来加载用户的已审核数据,虽然用户不是马上就要看,用户审核之后,需要重新拉取已审核的数据),避免重复执行。

开始时,我做了力扣的这道题,design-cancellable-function,于是我就在想能不能让异步函数也能取消?但是异步函数跟Generator函数约定每步返回一个Promise这种场景有个很大的区别,异步函数是自执行操作,如果想取消,只能取消整个函数,并且如果假设中间一旦有多个串行的异步任务,一旦开始也就再也控制不了了。

所以,对于异步函数本身的改造无法做到,那就只能借助设计模式,思考一下前面提到的关键词:并发控制队列优先级可撤销,毫无疑问,这个场景就是命令模式的绝佳用武之地。

以下是上述需求的实现:设计了一个任务调度器,将异步任务交给这个调度器,任务调度器会自动帮你执行,你只需要监听这个异步任务的结果结果即可,在异步任务调度器处理过程中,你还可以取消这个异步任务。

首先是对于一个可取消任务的设计,这个部分,就相当于是命令模式中的命令节点,但我并没有将其命名为XxxCommand

// 取消原因哨兵对象,使得调用方可以明确知道是取消而非错误信息
const CancelledReason = Symbol("Async task has been cancelled");
/**
 * 可取消的任务
 */
interface CancellableTask<T> {
  /**
   * 异步操作执行函数
   * @param args
   * @returns
   */
  action: (...args: any[]) => Promise<T>;
  /**
   * 取消任务
   * @returns
   */
  cancel: () => void;
}

/**
 * 将一个普通任务包裹成一个可取消的任务
 * @param fn 普通任务
 * @param args 普通任务的预设参数
 * @returns
 */
function createCancellableTask<T>(
  fn: (...args: any[]) => T | Promise<T>,
  ...args: any[]
): CancellableTask<T> {
  let trigger: ((reason?: any) => void) | null = null;
  let isCancel = false;
  const action = () => {
    // 如果在action方法调用之前就已经调用,那么此刻直接返回一个取消的Promise
    if (isCancel) {
      return Promise.reject(CancelledReason);
    }
    // 部署真正的异步任务,若在异步任务未完成之前取消,则返回取消的原因,否则取最终的结果作为Promise的结果
    return new Promise<T>((resolve, reject) => {
      trigger = reject;
      Promise.resolve(fn(...args))
        .then(resolve)
        .catch(reject);
    });
  };
  const cancel: () => void = () => {
    isCancel = true;
    typeof trigger === "function" && trigger(CancelledReason);
  };
  return {
    action,
    cancel,
  };
}

上述代码中,为什么createCancellableTask函数要设计成这个样子,其实借鉴了JScallapply函数的设计,事先将函数的参数预设,因为后面调用者再考虑参数,处理起来就难受,并且还会和任务调度器的处理逻辑耦合,得不偿失,这样的设计可以极大简化任务调度器的负担。

接着是任务调度器,可以将其视为命令模式示例UML中的Invoker类,以下就是完整实现,这个实现有参考渡一前端袁进老师在抖音短视频提到的任务调度器的实现:

interface AsyncTaskNode<T> {
  // 保存返回的Promise的resolve触发器
  resolve: (value: T) => void;
  // 保存返回的Promise的reject触发器
  reject: (reason: any) => void;
  // 任务真实节点
  cancellableTask: CancellableTask<T>;
}

type InsertAction = "push" | "unshift";

class AsyncTaskScheduler<T> {
  /**
   * 定义当前正在执行的异步任务
   */
  private runningTask = 0;
  /**
   * 定义任务调度器允许的最大异步并发量
   */
  private maxTask = 5;
  /**
   * 异步任务队列,用于记录暂时无法处理稍候需要处理的内容
   */
  private asyncTaskQueue: AsyncTaskNode<T>[] = [];
  /**
   * 定义方法,供外界任务内容加入到当前的调度器中执行
   */
  add(cancellableTask: CancellableTask<T>, ac: InsertAction = "push") {
    // console.log('xxxxxx')
    return new Promise((resolve, reject) => {
      if (this.runningTask < this.maxTask) {
        this.runningTask++;
        const { action } = cancellableTask;
        this.runTask(action, resolve, reject);
      } else {
        this.asyncTaskQueue[ac]({
          resolve,
          reject,
          cancellableTask,
        });
      }
    });
  }

  private runTask(
    action: (...args: any[]) => Promise<T>,
    resolve: (value: T) => void,
    reject: (reason?: any) => void
  ) {
    action()
      .then((response) => {
        this.runningTask--;
        resolve(response);
        this.run();
      })
      .catch((err) => {
        this.runningTask--;
        reject(err);
        this.run();
      });
  }

  /**
   * 优先插入的任务
   * @param cancellableTask
   */
  addFirst(cancellableTask: CancellableTask<T>) {
    this.add(cancellableTask, "unshift");
  }

  private run() {
    while (this.asyncTaskQueue.length && this.runningTask < this.maxTask) {
      const task = this.asyncTaskQueue.shift();
      const { cancellableTask, reject, resolve } = task!;
      const { action } = cancellableTask;
      this.runningTask++;
      this.runTask(action, resolve, reject);
    }
  }
}

以下是一个使用Demo:

function printMsg(msg: string) {
  return new Promise<string>((resolve) => {
    setTimeout(() => {
      resolve(msg);
    }, 3000);
  });
}

function errorMsg(msg: string) {
  return new Promise<string>((resolve, reject) => {
    setTimeout(() => {
      reject(msg);
    }, 3000);
  });
}

let task: CancellableTask<string> = createCancellableTask<string>(errorMsg, "hello world task1");
let task2: CancellableTask<string> = createCancellableTask<string>(
      printMsg,
      "hello world task2"
    );
let scheduler: AsyncTaskScheduler<string> =  new AsyncTaskScheduler<string>();

const p1 = scheduler.add(task);
const p2 = scheduler.add(task2);

p1.catch((err) => {
  console.log(err);
});

p2.then((res) => {
  console.log(res);
});
// 在异步任务1执行的过程中取消异步任务1
setTimeout(() => {
  task.cancel();
}, 300);

这个Demo中,p1执行catch逻辑,p2执行正常逻辑。

最后,需要补充的一点是,上述的设计并没有侵入业务代码,业务代码仍然具有原子性,即一旦某个任务开始做了,比如网络请求已经发送出去了,那就真的发出去了,这个是无法取消的,取消操作真正取消的是让外界不再处理这个操作的后续结果

这个代码在实际开发中处理异步任务有一定的实用性,有需要的读者可以收藏下来。

总结

在实现发出命令的对象和执行命令的对象之间的解耦或者支持撤销操作的业务场景时均可以使用命令模式,我们分别用两个具体的例子演示了命令模式适用的这两个场景。

命令模式分离了发起命令和执行命令的责任,所以一定程度上降低系统的耦合度;因为我们能够轻松的添加或替换命令,系统设计也比较灵活;因为调度过程中,我们用队列或堆保存着这些命令对象,可以很容易的控制命令的执行顺序。

但是命令模式也不是没有缺点,如果业务太多就会导致我们需要设计很多的命令类。