大家好,我是前端西瓜哥。
今天我们讲一下图形编辑器用到的一些设计模式。
因为要使用设计模式,所以文中绝大多数代码使用了带有类型系统的 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()
结尾
我是前端西瓜哥,关注我,学习更多图形编辑器知识。
相关阅读,