圖形編輯器開發:模塊間如何通信?
大家好,我是前端西瓜哥。
圖形編輯器,隨著功能的增加,通常都會愈發復雜,良好的架構是保證圖形編輯器持續開發高效的重要技術。
根據功能拆分成一個一個的小模塊基本是家常便飯。那么模塊之間是如何配合以及進行數據傳輸的呢?
編輯器 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<{
// 指定事件和對應的函數類型
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 的,它的做法是:
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<{
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 也是類似邏輯)。
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 對象,輕松主動訪問任何其他模塊。此外還可以用事件發布訂閱的方式綁定監聽器,在對應模塊狀態更新后被動地獲得通知。