图形编辑器中用到的一些设计模式

668 阅读14分钟

大家好,我是前端西瓜哥。

今天我们讲一下图形编辑器用到的一些设计模式。

因为要使用设计模式,所以文中绝大多数代码使用了带有类型系统的 TypeScript 语言。

发布订阅模式

发布订阅模式:在对象之间定义一个一对多的依赖,当一个对象状态改变的时候,所有依赖的对象都会自动收到通知。

这个我们其实在前端开发中已经挺常见的。

比如监听鼠标事件,不需要的时候取消监听。

// 事件响应函数
const handler = () => {
  // ...
}
// 监听
document.body.addEventListener('click', handler);
// 取消监听
document.body.removeEventListener('click', handler);

图形编辑器的功能非常多,为了更好地扩展和维护,通常会根据功能划分成非常多的小模块。

它们之间可能有依赖关系,当模块 A 的状态改变时,另一个模块 B 需要立即拿到这个新的状态,然后执行特定的逻辑。

比如摄像机类的 zoom 改变了,UI 上的 zoom 值需要更新,以及控制点管理类也需要重新计算控制点在视口上的位置。

如果我们把这些逻辑代码直接放到摄像机类下,那其实就很不方便维护,摄像机类和其他模块耦合在一起了,新增或移除模块都要需要跑到摄像机类下进行修改,这样不利于插件化扩展。

我们希望修改客户端代码,而不是要修改底层的框架代码。

这种情况下,我们就需要用发布订阅模式,让模块 A 来暴露一个方法,并定义好一些事件。

然后另一个模块 B 调用这个方法将自己的逻辑封装的事件响应函数传进去。之后事件触发时,这个事件响应函数就会被触发,然后运行我们希望执行的逻辑。

发布订阅库的使用

我们需要找一个发布订阅库。

发布订阅类的实现非常简单。

可以参考 node.js 的来实现一个简易版的  EventEmitter

此外也有轮子可用,如 Vue 推荐的 mitt,或是 PubSubJS。

我个人推荐自己实现一个,也就几十行代码。可以先支持基础的触发事件 emit、添加监听函数 on 和取消监听函数 off。之后有需要再扩展新的方法,比如只监听一次的 once 方法,可以给监听器设置优先级等等。

组合优于继承

然后就是将发布订阅的功能加入到需要用到它的模块中。

我不推荐使用继承的方式,即如下的写法:

class Camera extends EventEmitter {
  // ...
}

一个是继承不要经常用,尤其是多层级的多重继承会导致代码积重难返,难以维护。

另一个原因是,模块继承了太多 EventEmitter 上的方法,并将它们公开出去了。

这些方法首先可能会和模块自己的方法名重名,其次我们不希望模块讲 EventEmitter 的一些方法公开出去,比如触发事件的 emit 方法。

触发事件的权限应该把控在模块自己的手中,其他模块不应该管理这个事件的触发,即使要,也应该通知这个模块,让它自行来决定是否要触发事件。

所以我们最好用组合的方法,像下面这样写,封装一层,将  eventEmitter 对象设置为私有,然后只暴露 on 和 off 方法出去。

interface Events {
  zoomChange(zoom: number, prevZoom: number): void;
}

class Camera {
  private eventEmitter = new EventEmitter<Events>();

  on<T extends keyof Events>(eventName: T, listener: Events[T]) {
    this.eventEmitter.on(eventName, listener);
  }
  off<T extends keyof Events>(eventName: T, listener: Events[T]) {
    this.eventEmitter.off(eventName, listener);
  }
  
  // ...
}

策略模式

策略模式:定义一族算法类,将每个算法分别封装起来,让它们可以互相替换。策略模式可以使算法的变化独立于使用它们的客户端(这里的客户端代指使用算法的代码)。

策略模式的主要作用是避免冗长的 if else 分支判断,换成一个动态的映射表,根据类型选择对应的策略类来使用。

策略模式例子 1:工具策略类

在图形编辑器中,经典的场景就是不同工具类的定义和切换

我们会先定义好工具类接口(interface),然后让具体的工具类(如选择工具、绘制矩形工具)去实现它们

定义工具类接口:

interface ITool {
  type: string;

  onActive: () => void;
  onInactive?: () => void;

  onStart: (event: PointerEvent) => void;
  onDrag: (event: PointerEvent) => void;
  onEnd: (event: PointerEvent) => void;
}

实现具体的绘制矩形类。

class DrawRectangleTool implements ITool {
  type: 'DrawRectangle'
  static type: 'DrawRectangle'
  // ...
  constructor(private editor: Editor) {}

 onActive() {
    this.editor.setCursor('crosshair');
  }
  onStart(event) {
    this.startPoint = { x: event.clientX, y: event.clientY };
    // ...
  }
  onDrag(event) {
    const point = { x: event.clientX, y: event.clientY };
    this.updateRect(this.startPoint, point);
  }
  onEnd(event) {
    this.editor.history.push(new AddGraphicsCmd(this.rect));
  }
}

然后我们会创建一个工具管理类,用于管理定义的各种具体工具类,并实现切换。

class ToolManager {
  /** tool type(string) => tool class constructor */
  private toolCtorMap = new Map<string, IToolClassConstructor>();

  registerToolCtor() {
    this.toolCtorMap.set(type, toolCtor);
  }
  
  toolTool(toolName: string) {
    const currentToolCtor = this.toolCtorMap.get(toolName);
    const currentTool = new currentToolCtor(this.editor);

    const prevTool = this.currentTool;
    this.currentTool = currentTool;

    prevTool && prevTool.onInactive();
    currentTool.onActive();
  }
}

const toolManager = new ToolManager();

// 注册工具类
this.registerToolCtor(DrawRectTool);
this.registerToolCtor(SelectTool);t

因为我们的工具策略类是有状态的,所以我们要存的是类本身,而不是类实例

这种情况可以不存类,存类实例,但你要确保在正确的时机将类实例的状态进行重置,这个很容易漏写,直接将示例销毁然后重建是更简单的选择。不推荐这种做法,除非创建类实例的代价非常大。

如果策略类是无状态的,我们可以直接存策略类实例。

每当切换工具时,我们会基于 type 找到对应的工具类,创建出一个新的类实例,设置为当前工具,并将旧的工具示例丢弃。

class ToolManager {
  // 切换工具
  toggleTool(toolName: string) {
    // 创建 对应的工具类
    const currentToolCtor = this.toolCtorMap.get(toolName);
    const currentTool = new currentToolCtor(this.editor);

    const prevTool = this.currentTool;
    this.currentTool = currentTool;

    prevTool && prevTool.onInactive();
    currentTool.onActive();
  }
}

策略模式例子 2:缩放图形控制点策略对象

我们对图形进行缩放(resize)时,它的 6 个不同方向的缩放控制点,进行计算时的缩放中心、宽高变换等细节是有微妙的区别的。

对此我们需要定义一个缩放操作策略类接口,然后实现 6 个不同的具体策略对象类。

但因为我们的策略类是不需要状态的,所以我们可以直接存策略类实例。而且 TypeScript 不像 Java 这种强类型语言,是可以直接创建一个字面量对象的。

因此我们跳过类的声明,直接声明字面量策略对象的集合。如下:

interface IResizeOp {
  // 缩放的基点如何计算
  getLocalOrigin(width: number, height: number): IPoint;
  // 新的矩形如何计算出来
  getNewSize(
    newLocalPt: IPoint,
    localOrigin: IPoint,
    rect: { width: number; height: number },
  ): {
    width: number;
    height: number;
  };
}

// 直接把所有策略对象放到一起。
const resizeOps: Record<string, IResizeOp> = {
  sw: {
    getLocalOrigin: (width: number) => ({ x: width, y: 0 }),
    getNewSize: (newLocalPt: IPoint, localOrigin: IPoint) => ({
      width: localOrigin.x - newLocalPt.x,
      height: newLocalPt.y - localOrigin.y,
    }),
  },
  // ...
  n: {
    getLocalOrigin: (width, height) => ({ x: width / 2, y: height }),
    getNewSize: (newLocalPt, localOrigin, rect) => ({
      width: rect.width,
      height: localOrigin.y - newLocalPt.y,
    }),
  },
}

所以,设计模式需要根据具体的编程语言进行调整

像是 JavaScript 这种可以直接声明对象的语言,在一些场景下,就没有必要先创建一个类,然后创建它的实例对象了,直接用字面对象即可。

此外单例模式也是同理。

策略模式例子 3:复杂的工具类

有些工具类会比较复杂,当用户进行不同的操作时,会进入不同的模式。

比如选择工具,当用户在控制点上按下鼠标,会进入 "updatedByControl" 的模式,对应的拖拽控制点修改图形属性的逻辑。

当用户在图形上按下鼠标,则会进入 "moveSelectedGraphics" 的模式,对应移动图形的逻辑,

当用户在空白区域按下鼠标,则会会进入 "moveSelectedGraphics" 的模式,执行绘制选区的逻辑,等等。

这里逻辑都放到一个工具 SelectTool 中,相当于多条不一样的支线缠绕在了一起,是不好维护的。

这时候我们又可以用策略模式,梳理出不同的模式流程,拆分出子工具策略类,将复杂度互相隔离。

class SelectTool implements ITool {
  onStart(e: PointerEvent) {
    let type = '';
    // ...
    if (isHitControl) {
      type = 'updatedByControl';
    } else if (isHitElement) {
      type = 'moveSelectedGraphics';
    } else if (isHitEmpty) {
      type = 'drawSelection';
    }

    const SubToolCtor = this.getSubToolCtor(type);
    this.subTool = new SubToolCtor(this);
    this.subTool.onActive();
    this.subTool.onStart(e);
  }

  onDrag(e: PointerEvent) {
    this.subTool.onDrag(e);
  }

  onEnd(e: PointerEvent) {
    this.subTool.onEnd(e);
  }
}

像是 CAD 这类更复杂的编辑器无法通过一轮的点击加拖拽操作,就完成一个图形的绘制,通常会分步骤一点点完成,就会更复杂,这时候用策略模式隔离复杂度就非常重要了。

模板模式

模板方法模式在一个方法中定义一个算法骨架,并将某些步骤推迟到子类中实现。模板方法模式可以让子类在不改变算法整体结构的情况下,重新定义算法中的某些步骤。

简单来说,模板模式就是已经实现好了一个大的算法或是业务框架,但有几个小的地方需要根据不同场景,需要执行的逻辑不同。

模板模式主要是为了解决代码复用的问题

有些模块的逻辑基本一样,只有少数的几个地方不同,我们可以抽象出一个有相同逻辑的模板抽象类,定义几个需要子类实现的抽象方法,然后具体子类继承并实现这些抽象方法即可。这样多个模块复用了同一份代码。

图形编辑器中的一个典型例子就是绘制图形工具类

绘制矩形工具类的逻辑是,按下鼠标记录第一个点,然后拖拽鼠标,创建矩形并基于第一个点和当前鼠标位置实时更新矩形的属性,拖拽结束则完成绘制。

图片

如果换成圆形、正多边形、星形其实也是同样的逻辑,它们的不同点只在于创建的图形不同,更新的逻辑也基本一致(如果都是用 x、y、width、height 表达,那更新逻辑也一样)。

所以我们抽象一个图形绘制模板抽象(abstract)类,来做代码的复用。

子类必须实现的方法需要设置为抽象(abstract)方法。

abstract class DrawGraphicsTool implements ITool {
  // 这里省略一大堆业务逻辑
  /**
   * 根据传入的矩形对象,返回一个具体的图形类对象
   */
  protected abstract createGraphics(
    rect: IRect,
  ): SuikaGraphics | null;
}

然后绘制矩阵类只要实现 createGraphics 方法。

class DrawRectTool extends DrawGraphicsTool implements ITool {
  protected override createGraphics(rect: IRect) {
    rect = normalizeRect(rect);
    const graphics = new Rect(
      {
        ...rect
      },
    );
    return graphics;
  }
}

其他图形同理,比如椭圆:

class DrawEllipseTool extends DrawGraphicsTool implements ITool {
  protected override createGraphics(rect: IRect) {
    rect = normalizeRect(rect);
    const graphics = new Ellipse(
      {
        ...rect
      },
    );
    return graphics;
  }
}

之后如果要添加新的图形类型,如果新的图形类型的创建逻辑和矩形相同,那就可以实现这个 DrawGraphicsTool 抽象类,实现一下 createGraphics 方法即可。

职责链模式

职责链模式:将请求的发送和接收解耦,让多个接收对象都有机会处理这个请求。将这些接收对象串成一条链,并沿着这条链传递这个请求,直到链上的某个接收对象能够处理它为止。

职责链,就是我们构建了一条处理器链条,请求会依次通过这些处理器,当符合某个处理器的条件时,会进入这个处理器进行处理,处理完就结束了,不会继续。

还有一种变体,就是进入这个处理器后,还会再传递给下一个处理器,所有处理器都会走一遍。

职责链模式的优点是,可以让每个处理器只处理自己的专门的逻辑,提高代码的内聚性。然后我们也可以灵活地动态调整处理器,比如临时加入一些高优先级的处理器,调整处理器的顺序等。

图形编辑器中,快捷键管理类 就用到了职责链。

我们需要对编辑器的行为注册快捷键,比如 Esc 绑定为取消图形的选中逻辑,Delete 表示删除选中图形。

我们可以实现一个基于职责链的快捷键管理类。

class KeyBindingManager {
  private keyBindingMap = new Map<number, IKeyBinding>();
 
  bindEvent() {
  const onKeyDown(e: KeyboardEvent) {
      // 遍历职责链,如果符合条件,就执行然后结束循环
      for (const keyBinding of this.keyBindingMap.values()) {
        if (this.isKeyMatch(keyBinding.key, e)) {
          keyBinding.action(e);
          return;
        }
      }
    }
    // 监听浏览器的键盘事件
    document.addEventListener('keydown', onKeyDown);
  }
  // 给一个行为注册快捷键
  register(keybinding: IKeyBinding) {
    this.keyBindingMap.set(id, keybinding);

    token++;
    return token;
  }
}

使用:

editor.keybindingManager.register({
  key: 'esc',
  action: () => {
    editor.selectSet.clear();
  },
});

editor.keybindingManager.register({
  key: ['delete', 'backspace'],
  action: () => {
    editor.selectSet.removeSelect();
  },
});

之后我们就可以动态修改这些注册好的处理器。

像是我们进入路径编辑器模式时,需要覆盖掉默认的一些快捷键。

比如 Esc 会改为退出路径编辑器模式,Delete 也会改为删除 path 上选中的一些锚点。

只要我们在路径编辑器激活时,职责链的开头加上这些新的处理器即可。职责链的短路特性会让逻辑只执行第一次匹配的处理器。然后路径编辑器退出时,把这些处理器移除即可。

命令模式

命令模式:将请求(命令)封装为一个对象,这样可以使用不同的请求参数化其他对象(将不同请求依赖注入到其他对象),并且能够支持请求(命令)的排队执行、记录日志、撤销等(附加控制)功能。

在图形编辑器中,可以用命令模式实现历史记录。

我们先定义好命令类接口:

interface ICommand {
  type: string
  execute: () => void;
  undo: () => void;
}

实现不同的命令类。

// 更新图形属性命令类
class UpdateGraphicsCommand implements ICommand {
  constructor(
    private graphics: Graphics,
    private oldAttrs: Record<string, any>,
    private newAttrs: Record<string, any>
  ) {}
  
  execute() {
    this.graphics.updateAttrs(this.newAttrs)
  }
  
  undo() {
    this.graphics.updateAttrs(this.oldAttrs)
  }
}

然后是将这些命令放在一起进行管理的历史记录类 History。

class History {
  redoStack: ICommandItem[] = [];
  undoStack: ICommandItem[] = [];
  
  // 添加命令
  pushCommand(
    command: ICommand
  ) {
    this.undoStack.push(command);
    this.redoStack = [];
  }

  undo() {
    const command = this.undoStack.pop();
    this.redoStack.push(command);
    command.undo();
  }
  
  redo() {
    const command = this.redoStack.pop();
    this.undoStack.push(command);
    command.execute();
  }
}

用户进行的操作会封装为命令类,添加到历史记录的命令列表中。

const history = new History()

const updateGraphicsCommand = new UpdateGraphicsCommand(
  rectGraphics,
  { x: 0 },
  { x: 1 }
);

history.pushCommand(updateGraphicsCommand)

// 撤销
history.undo()
// 重做
history.redo()

结尾

我是前端西瓜哥,关注我,学习更多图形编辑器知识。


相关阅读,

事件订阅的几种实现风格

图形编辑器:历史记录设计

图形编辑器开发:基于 transfrom 的图形缩放

图形编辑器开发:钢笔工具的实现

图形编辑器开发:快捷键的管理

图形编辑器:工具管理和切换