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

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

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

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

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

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


相关阅读,

图形编辑器:底层设计

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

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

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

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

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

相关推荐
崔庆才丨静觅6 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60616 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了7 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅7 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅7 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅7 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment7 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅8 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊8 小时前
jwt介绍
前端
爱敲代码的小鱼8 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax