图形编辑器开发:模块间如何通信?

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

图形编辑器,随着功能的增加,通常都会愈发复杂,良好的架构是保证图形编辑器持续开发高效的重要技术。

根据功能拆分成一个一个的小模块基本是家常便饭。那么模块之间是如何配合以及进行数据传输的呢?

编辑器 github 地址:

github.com/F-star/suik...

线上体验:

blog.fstars.wang/app/suika/

注入 Editor 实例

首先我们有一个主模块,也是入口模块,叫做 Editor。

为了高内聚低耦合,其下会根据功能拆分出很多的子模块。

这是为了让我们要改造特定的功能时,只需要改对应模块的小范围代码,不会被其他模块代码干扰,也不需要去理解它们。

子模块会在 Editor 初始化的时候,将 Editor 实例对象注入(大概算是一种依赖注入)。

kotlin 复制代码
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 类为例,它大概是这样的:

typescript 复制代码
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 类,来支持事件订阅。

javascript 复制代码
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 实现的话,需要用类型编程,让事件名是类型安全的,即事件名对应的监听器函数参数类型要匹配。

实现可以看我的这篇文章:

用 TypeScript 实现类型安全的 EventEmitter,这下不用怕写错事件名了

实现后的用法:

typescript 复制代码
const ee = new EventEmitter<{
  // 指定事件和对应的函数类型
  update(newVal: string, prevVal: string): void;
  destroy(): void;
}>();


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 的,它的做法是:

dart 复制代码
class A extends EventEmitter {
  // ...
}

A.on('event-1', () => {})

但我更建议用 **组合 **而不是继承的方式。

dart 复制代码
class A {
  emitter = new Emitter()
}

A.emitter.on('event-1', () => {})

继承并不是好文明,不加限制可能导致复杂的多层继承。我们应该多用组合,少用继承。

这样做的另一个次要好处是 EventEmitter 的方法不会污染 A 对象。

除了模块间用发布订阅方式通信,内核层(Editor对象)也常常利用它和 UI 层通信。

因为状态源保存在 Editor 对象中,所以需要用发布订阅的方式去同步状态给 UI 层。

以画布缩放的功能为例。

画布缩放管理类的实现如下:

typescript 复制代码
class ZoomManager {
  private zoom = 1;
  // 自己造的 EventEmitter 轮子
  private emitter = new EventEmitter<{
    zoomChange(zoom: number, prevZoom: number): void;
  }>();
  
  setZoom(zoom: number) {
    const prevZoom = this.zoom;
    this.zoom = zoom;

    // 触发 "zoom改变" 事件
    this.emitter.emit('zoomChanged', zoom, prevZoom);
  }
}

对应的需要拿到 zoom 值的 React 组件,会在组件挂载时绑定监听器(Vue 也是类似逻辑)。

scss 复制代码
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 对象,轻松主动访问任何其他模块。此外还可以用事件发布订阅的方式绑定监听器,在对应模块状态更新后被动地获得通知。

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


相关阅读,

图形编辑器:底层设计

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

图形编辑器开发:绘制图形工具

图形编辑器开发:最基础但却复杂的选择工具

图形编辑器:对齐功能的实现

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

相关推荐
摇光93几秒前
promise
前端·面试·promise
麻花201324 分钟前
WPF学习之路,控件的只读、是否可以、是否可见属性控制
服务器·前端·学习
.54824 分钟前
提取双栏pdf的文字时 输出文件顺序混乱
前端·pdf
jyl_sh32 分钟前
WebKit(适用2024年11月份版本)
前端·浏览器·客户端·webkit
狼叔1 小时前
前端潮流KK:科技达人与多面手,如何找到自己的乐趣?-浪说回顾
前端
zhanghaisong_20151 小时前
Caused by: org.attoparser.ParseException:
前端·javascript·html·thymeleaf
Eric_见嘉1 小时前
真的能无限试(白)用(嫖)cursor 吗?
前端·visual studio code
DK七七2 小时前
多端校园圈子论坛小程序,多个学校同时代理,校园小程序分展示后台管理源码
开发语言·前端·微信小程序·小程序·php
老赵的博客2 小时前
QSS 设置bug
前端·bug·音视频
Chikaoya2 小时前
项目中用户数据获取遇到bug
前端·typescript·vue·bug