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

2023年 9月 11日 37.9k 0

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

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

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

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

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论