大家好,我是前端西瓜哥。
图形编辑器,随着功能的增加,通常都会愈发复杂,良好的架构是保证图形编辑器持续开发高效的重要技术。
根据功能拆分成一个一个的小模块基本是家常便饭。那么模块之间是如何配合以及进行数据传输的呢?
编辑器 github 地址:
https://github.com/F-star/suika
线上体验:
https://blog.fstars.wang/app/suika/
注入 Editor 实例
首先我们有一个主模块,也是入口模块,叫做 Editor。
为了高内聚低耦合,其下会根据功能拆分出很多的子模块。
这是为了让我们要改造特定的功能时,只需要改对应模块的小范围代码,不会被其他模块代码干扰,也不需要去理解它们。
子模块会在 Editor 初始化的时候,将 Editor 实例对象注入(大概算是一种依赖注入)。
子模块会将其保存为一个私有成员属性。
以子模块 ZoomManger 类为例,它大概是这样的:
子类的子类如果也要用 editor,我们就再传,主打一个透传,人手一份 Editor。
这样所有的子模块就都能拿到 Editor 对象,然后通过这个 Editor 对象去访问其他的子类。
最小知识原则
其实这种做法并不满足设计模式的 最小知识原则(或者叫迪米特法则)。
所谓最小知识原则,指的是每个模块只和应该要用到的模块要交流,不要和用不到的模块发生关系。
甚至你可以抽一层接口或类继承的方式,将细粒度达到被关联模块的某几个需要用到的方法。
目前我的项目还处于早期阶段,复杂度很低,所以没必要这么做,之后会不断添加功能中让关联模块发生着变化。不应该过早优化。这是项目变得非常复杂,且开发人员非常多的时候才需要考虑优化。
事件发布订阅
前面注入的方式,都是通过 主动的方式 去访问其他模块。
有时候我们需要用 被动的方式 去拿到其他模块的数据,这时候我们常常会用 发布订阅 模式。
发布订阅模式,就是对象间存在一对多的依赖时,但一个对象改变状态,所有的依赖对象会自动收到通知。
做法通常就是模块加入的事件(event)的概念,并提供一些方法接受监听器(函数),当这个模块的某些状态发生改变时,就会这些监听器一一执行,并将最新状态传入。
这个其实我们并不陌生,像是定时器(setTimeout)、DOM 元素的事件(click、mouseover 等)都是用了这个设计模式。
Nodejs 也有个专门的 EventEmitter 类,来支持事件订阅。
可惜 Web 端并没有这个轮子,得自己造或者找个轮子。
因为轮子实现并不复杂,我是更建议自己实现,方便修改和扩展。
通常我们只要实现 on、off、emit 三个方法就好了。
我们如果用 TypeScript 实现的话,需要用类型编程,让事件名是类型安全的,即事件名对应的监听器函数参数类型要匹配。
实现后的用法:
轮子的话我建议 mitt,同时这个轮子是 Vue3 官方推荐的(实现跨组件通信的一种方式),主要原因是它也是 类型安全 的。
这个轮子很简单,高级方法也很少,源码实现也就 100 多行,你完全可以拷贝过去自己改。
模块如何使用事件
在 Nodejs 的内部模块,是通过继承的方式使用 EventEmitter 的,它的做法是:
但我更建议用 **组合 **而不是继承的方式。
继承并不是好文明,不加限制可能导致复杂的多层继承。我们应该多用组合,少用继承。
这样做的另一个次要好处是 EventEmitter 的方法不会污染 A 对象。
除了模块间用发布订阅方式通信,内核层(Editor对象)也常常利用它和 UI 层通信。
因为状态源保存在 Editor 对象中,所以需要用发布订阅的方式去同步状态给 UI 层。
以画布缩放的功能为例。
画布缩放管理类的实现如下:
对应的需要拿到 zoom 值的 React 组件,会在组件挂载时绑定监听器(Vue 也是类似逻辑)。
结尾
本文简单介绍了图形编辑器架构中,如何进行模块间的通信。
对于某个模块间,可以通过入口 Editor 对象,轻松主动访问任何其他模块。此外还可以用事件发布订阅的方式绑定监听器,在对应模块状态更新后被动地获得通知。