大家好,我是前端西瓜哥。
图形编辑器,随着功能的增加,通常都会愈发复杂,良好的架构是保证图形编辑器持续开发高效的重要技术。
根据功能拆分成一个一个的小模块基本是家常便饭。那么模块之间是如何配合以及进行数据传输的呢?
编辑器 github 地址:
https://github.com/F-star/suika
线上体验:
https://blog.fstars.wang/app/suika/
注入 Editor 实例
首先我们有一个主模块,也是入口模块,叫做 Editor。
为了高内聚低耦合,其下会根据功能拆分出很多的子模块。
这是为了让我们要改造特定的功能时,只需要改对应模块的小范围代码,不会被其他模块代码干扰,也不需要去理解它们。
子模块会在 Editor 初始化的时候,将 Editor 实例对象注入(大概算是一种依赖注入)。
class Editor {
sceneGraph: SceneGraph;
setting: Setting;
viewportManager: ViewportManager;
toolManager: ToolManager;
commandManager: CommandManager;
zoomManager: ZoomManager;
hostEventManager: HostEventManager;
selectedElements: SelectedElements;
// ...
constructor(options: IEditorOptions) {
// 也有些模块不需要和其他模块通信
this.setting = new Setting();
// 将 Editor 示例作为子模块的构造参数
this.sceneGraph = new SceneGraph(this);
this.viewportManager = new ViewportManager(this);
this.toolManager = new ToolManager(this);
this.commandManager = new CommandManager(this);
this.zoomManager = new ZoomManager(this);
this.selectedElements = new SelectedElements(this);
// ...
this.hostEventManager = new HostEventManager(this);
this.hostEventManager.bindHotkeys();
this.zoomManager.zoomToFit(1);
}
}
子模块会将其保存为一个私有成员属性。
以子模块 ZoomManger 类为例,它大概是这样的:
export class ZoomManager {
private editor: Editor
// ...
constructor(editor: Editor) {
// 将传入的 Editor 对象保存为私有属性
this.editor = editor
// ...
}
zoomIn(cx?: number, cy?: number) {
// 通过 this.editor 访问到其他模块
const zoomStep = this.editor.setting.get('zoomStep');
// ...
}
子类的子类如果也要用 editor,我们就再传,主打一个透传,人手一份 Editor。
这样所有的子模块就都能拿到 Editor 对象,然后通过这个 Editor 对象去访问其他的子类。
最小知识原则
其实这种做法并不满足设计模式的 最小知识原则(或者叫迪米特法则)。
所谓最小知识原则,指的是每个模块只和应该要用到的模块要交流,不要和用不到的模块发生关系。
甚至你可以抽一层接口或类继承的方式,将细粒度达到被关联模块的某几个需要用到的方法。
目前我的项目还处于早期阶段,复杂度很低,所以没必要这么做,之后会不断添加功能中让关联模块发生着变化。不应该过早优化。这是项目变得非常复杂,且开发人员非常多的时候才需要考虑优化。
事件发布订阅
前面注入的方式,都是通过 主动的方式 去访问其他模块。
有时候我们需要用 被动的方式 去拿到其他模块的数据,这时候我们常常会用 发布订阅 模式。
发布订阅模式,就是对象间存在一对多的依赖时,但一个对象改变状态,所有的依赖对象会自动收到通知。
做法通常就是模块加入的事件(event)的概念,并提供一些方法接受监听器(函数),当这个模块的某些状态发生改变时,就会这些监听器一一执行,并将最新状态传入。
这个其实我们并不陌生,像是定时器(setTimeout)、DOM 元素的事件(click、mouseover 等)都是用了这个设计模式。
Nodejs 也有个专门的 EventEmitter 类,来支持事件订阅。
const { EventEmitter } = require('events');
// 创建事件触发器实例
const emitter = new EventEmitter();
// 给 event-1 事件添加监听器
emitter.on('event-1', (a, b) => {
console.log('收到事件1消息,参数为:', a, b);
});
// 触发事件,并提供参数。
emitter.emit('event-1', 3, 4);
// 移除指定监听器
// emitter.off('event-1', handler);
可惜 Web 端并没有这个轮子,得自己造或者找个轮子。
因为轮子实现并不复杂,我是更建议自己实现,方便修改和扩展。
通常我们只要实现 on、off、emit 三个方法就好了。
我们如果用 TypeScript 实现的话,需要用类型编程,让事件名是类型安全的,即事件名对应的监听器函数参数类型要匹配。
实现后的用法:
const ee = new EventEmitter();
const handler = (newVal: string, prevVal: string) => {
console.log(newVal, prevVal)
}
ee.on("update", handler);
ee.emit('update', '前端西瓜哥上班前的精神状态', '前端西瓜哥上班后的精神状态')
ee.off("update", handler);
// 编译报错(数字不匹配字符串类型)
// 'number' is not assignable to parameter of type 'string'
ee.emit('update', 1, 2)
// (val: number) => void' is not assignable to parameter of type '() => void
ee.on('destroy', (val: number) => {})
轮子的话我建议 mitt,同时这个轮子是 Vue3 官方推荐的(实现跨组件通信的一种方式),主要原因是它也是 类型安全 的。
这个轮子很简单,高级方法也很少,源码实现也就 100 多行,你完全可以拷贝过去自己改。
模块如何使用事件
在 Nodejs 的内部模块,是通过继承的方式使用 EventEmitter 的,它的做法是:
class A extends EventEmitter {
// ...
}
A.on('event-1', () => {})
但我更建议用 **组合 **而不是继承的方式。
class A {
emitter = new Emitter()
}
A.emitter.on('event-1', () => {})
继承并不是好文明,不加限制可能导致复杂的多层继承。我们应该多用组合,少用继承。
这样做的另一个次要好处是 EventEmitter 的方法不会污染 A 对象。
除了模块间用发布订阅方式通信,内核层(Editor对象)也常常利用它和 UI 层通信。
因为状态源保存在 Editor 对象中,所以需要用发布订阅的方式去同步状态给 UI 层。
以画布缩放的功能为例。
画布缩放管理类的实现如下:
class ZoomManager {
private zoom = 1;
// 自己造的 EventEmitter 轮子
private emitter = new EventEmitter();
setZoom(zoom: number) {
const prevZoom = this.zoom;
this.zoom = zoom;
// 触发 “zoom改变” 事件
this.emitter.emit('zoomChanged', zoom, prevZoom);
}
}
对应的需要拿到 zoom 值的 React 组件,会在组件挂载时绑定监听器(Vue 也是类似逻辑)。
const ZoomActions = () => {
const editor = useContext(EditorContext);
const [zoom, setZoom] = useState(1);
// 组件挂载 hook
useEffect(() => {
if (editor) {
// 初始化时要主动获取 zoom 值
setZoom(editor.zoomManager.getZoom());
// 通过事件同步 core 层的状态
const handler = (zoom: number) => {
setZoom(zoom);
};
editor.zoomManager.emitter.on('zoomChanged', handler);
// 组件销毁时解绑
return () => {
editor.zoomManager.emitter.off('zoomChanged', handler);
};
}
}, []);
}
结尾
本文简单介绍了图形编辑器架构中,如何进行模块间的通信。
对于某个模块间,可以通过入口 Editor 对象,轻松主动访问任何其他模块。此外还可以用事件发布订阅的方式绑定监听器,在对应模块状态更新后被动地获得通知。