大家好,我是前端西瓜哥。
图形编辑器算是 Web 前端开发领域的其中一个天花板了。
对于功能复杂的图形编辑器,经过迭代多年,几十多模块互相关联,包含各种细碎的业务细节,复杂度足够高。
实现过程中要在设计架构上要合理规划解耦,实现功能还需要扎实的数据结构与算法、线代知识、图形学等知识,门槛是不低的。
下面讨论下开发大型图形编辑器项目遇到的一些坑。
(如果是简单小工具的话,其实并不需要太遵守下面的建议,怎么方便怎么来,花太多时间在设计上反而没这么必要)
图形库做了太多的事情
市面上也有不少图形库额外提供部分编辑器部分的能力,其中 fabric.js 是其中佼佼者。
所以在敏捷开发的驱动下,或者开发者本身没有图形编辑器开发经验的情况下,大家更倾向于用这些第三方库,然后在上面做二次开发。我承认这是一个还不错的做法,但前提是你的工具并不复杂。
但如果你的编辑器是一个大型的项目,需要持续开发好几年,不断迭代,需要很多定制的交互,并加入非常多复杂的业务能力,那使用图形库的编辑器能力反而会让开发难以进行下去。
过于依赖图形库的编辑器逻辑,定制开发会变得异常困难,因为它们是黑盒子,虽然官方可能会提供一些定制化的接口,但很少,没法满足需求,可能最后不得写一些奇怪的代码,最后不可避免走向不可维护的结局。
你也可以给第三方库提 issue,但不好意思,你的需求优先级太低,最后是过了几年你想要的功能依旧在排期中。
你只能祈祷产品经理和设计师不要提太多过于细节的 UI 和交互的功能,说服他们能用就行。
这是职责划分不清晰导致的难以维护问题,你改不了这些图形库的底层逻辑,因为它就不是你写的。
在开发复杂图形编辑器的场景下,我认为不应该让第三方图形库负责编辑器业务功能,它应该做纯粹的渲染工作。
编辑器的逻辑需要开发人员自己实现一套,虽然造这个轮子很花时间,但是这一切是值得的。因为这个和我们自己的业务的特性紧密结合,扩展维护都会非常方便。
图形模型设计不合理
图形编辑器离不开图形的绘制,像是矩形、椭圆、多边形、星形、路径等。
有人会直接用图形库给的图形类,去继承然后扩展功能。
其实这并不合适,正如前面说的,这图形类首先是用来渲染的,然后可能还带着一些简单的业务能力(如 hitTest、事件等),这些都用上是不好维护扩展的。
我们要遵循单一职责原则,做好分层。
正确的做法是分成三种模型:
-
业务模型;
-
渲染模型;
-
几何算法模型;
大致像这样。
arduino
// 实体模型
class RectEntity {
constructor() {
// 渲染模型
this.graphics = new RectGraphics(width, height);
// 几何算法模型
this.geo = new GeoRect(width, height);
}
getMid() {
returnthis.geo.getMid();
}
}
业务模型就是为你的业务服务的,它们会作为业务上的最小模块,组成树。
渲染模型则是画布上真正绘制出来的基本图形,通常我们会选一个第三方库来用。
复杂的业务模型可能需要用多个渲染模型组合在一起进行渲染,比如标注实体,需要用三角形、线、文本组成。
最后是 几何算法模型,因为业务模型通常涉及到大量的几何算法,所以最好是抽成一个专门计算的类。另外封装成一个个纯函数也是可以的,如果不需要缓存的话。
此外,抽出几何算法模型还有一个很大的好处,就是业务模型通常在上下文中,且可能会和其他模型有依赖关系,导致单独的业务模型的计算无法单独工作。
举个例子,文本实体需要依赖某个文本样式实体,我们需要在预览弹窗查看修改文本样式实体某个设置项时,这个文本实体模型的预览效果。
这时候可以用几何算法模型,在不提供业务模型的情况下,运算出我们想要的结果。
几何模型同时也可以被其他的业务模型复用。
渲染和业务逻辑解耦后,我们平时开发通常只要在业务模型做修改,不需要过多理解它底层的渲染逻辑。
此外,我们也能很方便就能换掉渲染模型下对应的图形库(比如从 svg 换成 webgl),或者同时使用多种图形库,这些改造几乎不会因为依赖关系也要对业务模型去做改造。
即使把渲染模块丢掉,编辑器也能运行起来,数据正常更新,只是不渲染而已,这代表我们还能开发一个命令行客户端,甚至在服务端运行,渲染什么的不需要。
连 UI 框架都耦合在一起了
当我看到一个复杂图形编辑器是用 react-konva 开发的,我会有种这个编辑器 大概没救了 的感觉。
因为耦合又加了一层。
Konva 在渲染能力上本身就已经耦合了一些编辑器业务,然后这下更好了,我们再耦合一个 react,然后再用 react 的思想和特性去开发,吃满 react 特性的 buff,写一堆让人津津乐道的 react 灵活写法。
本来就不好更换渲染引擎,这下好了,如果想换成 Vue 框架或未来更流行的 UI 框架,那不好意思,做不到,来一波大重构吧。
Excalidraw 开源白板工具算是这么一种情况,虽然没用 react-konva,但因为和 react 的高强度绑定,它也是不好换的,但比基于 react-konva 开发要好不少,把和 react 的逻辑抽离出来会简单一些。
可能说替换 UI 框架的需求不大可能,但 去掉 UI 框架的需求还是有的。
一个常见场景是在详情页做图文混排,需要多个只读模式的画布,不提供任何交互。
如果详情页是用 Vue 写的,那意味着你还得装个 react,此外还有 UI 层依赖的 React 组件库和其他各种库,这些会让详情页变得相当重,即使编辑器 UI 最好会被隐藏掉。
好的做法是提供一个和 UI 框架无关的纯粹的编辑器内核 SDK,绑定到 canvas 元素上就可以启动了。
临时开发个小编辑器你用 react-konva 还好,但你要长期维护,不断迭代功能,等着把自己埋进去吧。
集中管理还是分散管理
集中管理,就是把所有的逻辑都放到一个方法里,可能通过不同的组合方式走完一个流程。
分散管理,就是每个分支都单独出来,各自实现。
举个例子,需要导出当前画布为 svg,可能会这样写:
typescript
class Editor {
toSVG() {
for (const object of objects) {
// 省略一些公共逻辑
// 如果是矩形,就这样这样
if (isRect(object)) {
// 此处省略 N 行具体实现逻辑
}
elseif (isCircle(object)) {
// ...
}
// ...
else {
// ...
}
}
// ...
}
}
这种写法是从功能的角度,将不同的图形的具体实现放在一起,看起来能一下子看完所有的实现,还能复用一些相同逻辑,但最后还是不利于后期维护。
首先随着需求的增加,每个图形的逻辑变得复杂,这个方法于是会变得膨胀。
上面这个例子是比较简单的了。
如果你看 excalidraw 的绘制图形的逻辑,你会在 onPointerDown 下面看到根据不同模式执行各自的逻辑,还混合了一些公共逻辑,然后一堆细碎的分支。我是不敢想像当我们要改一个小功能,被强制看这一大坨方法是有多酸爽了。
回到上面这个例子,当我们要新增一个图形,就需要在这里开一个新的分支,然后这个方法也会变得复杂。
如果你就是喜欢这种思想,那一定还有其他的地方也是类似的写法,你要把这些地方一个个找到,然后加上这些逻辑。
如果支持插件化,可以让用户自定义图形类,这种做法其实也不好提供接口,因为这里的 toSVG 太具体了,在里面接入合适的插槽显得有些困难,因为可能有各种分支穿插其中。
建议还是分而治之。
typescript
class BaseObject {
toSVG() {
// 通用实现,或者作为抽象方法,让子类必须实现
}
}
class RectObject {
toSVG() {
// 自己的实现
}
}
class Editor {
toSVG() {
for (const object of objects) {
const svgStr = object.toSVG();
// ...
}
}
}
把具体的功能放到图形类上,以图形类为维度添加各种需要支持功能,这样内聚到图形类上就更好维护了,向外提供插件也更方便了。
一些重复逻辑可以放到图形基类上,根据需要复用,或是通过模板模式组合起来。
可能图形类会聚集大量的方法,觉得不优雅,但这个问题不大,我们可以抽出一些 Helper 子类挂在图形类上就好了。
谁才是正确的状态?
我是经常发现一个情况,同一个状态在多个模块下都保存了,或者存了这个状态的部分信息。
不管怎样,多个模块互相独立,通过回写或事件进行同步,多个状态都要处理业务逻辑,维护上会非常困难。大概算是一种双向数据流?
好的做法是做成单向数据流,状态只在一个模块下维护,其他模块只是同步。
当其他模块发生更新操作时,直接调用实际维护该状态的模块,待更新后,维护该状态的模块再通过事件通知回来,然后再真正更新。
Vue 不说话,把代理对象丢给你
当我们把对象作为 props 传入到 Vue3 的组件时,对象会变成代理对象,以实现响应式更新功能。
如果组件内用到编辑器内核的方法,需要传入这个对象,记得转为原来对象再使用(Vue3 的 toRaw 方法)。
否则可能会有一些隐藏的 bug,像是用全等判断的逻辑就会失败。因为代理对象已经不是原对象了,虽然用起来是等效的。
没想到你是这样的 setter/getter
个人建议少用 setter 和 getter。
csharp
class BaseObject {
get size() {
// ...
}
set size() {
// ...
}
}
setter 和 getter 隐藏了它和普通属性读写的区别,但里面却可能会有很多反直觉的副作用。
甚至里面有一些昂贵的操作,但使用者是无感的,且大概率没有意识到这个问题。
举个例子,下面实现了获取选中的图形数组的 getter,其实因为实际是用 Map 储存的,这个 getter 会把一个 Map 转换为数组返回,时间复杂度 O(n)
。
csharp
class SelectedManager {
selectedMap = new Map();
get selectedItems() {
return Array.from(this.selectedMap.values());
}
}
使用者如果不知道里面的实现,以为就是个普通的变量,拿着这个 selectedManager.selectedItems
到处用,比如这样子:
ini
if (selectedManager.selectedItems.length) {
const rectCount = selectedManager.selectedItems.filter(item => isRect(item)).length;
const circleCount = selectedManager.selectedItems.filter(item => isCircle(item)).length;
// ...
}
这个 Map 转换为 Array 的操作执行了好几次,实际上转换一次就好了。
最好改成 getXxx
和 setXxx
的形式,这样使用者就能明显感知到它可能存在副作用。
ini
const selectedItems = selectedManager.getSelectedItems();
if (selectedItems.length) {
const rectCount = selectedItems.filter(item => isRect(item)).length;
const circleCount = selectedItems.filter(item => isCircle(item)).length;
// ...
}
如果操作昂贵,命名上可以做提醒,如 getSelectedItemsExpensive
。
不考虑一下复用吗?
我们开发过程中,可能要实现各种的方法,有时候为了方便,直接在类上加一个方法就完事了。
但我更希望能够,尽量遵循最小知识原则,这样能更好的复用。
其实仔细观察,你可能会发现你新加的方法,虽然用到了类实例的成员,但只是读取了几个值然后计算得到一个结果返回,并不会修改类实例的属性,比如一些几何算法运算,求特定的图形的包围盒。
那其实可以抽出一个纯函数来专门处理这件事情,也可以放到类的静态方法中,但可能会让类变得有点大。如果可以复用,可以放到更外层的可以给多个地方复用的位置上。
无意义缓存太多,且维护不好
缓存的作用是,对频繁被访问的特定条件的结果,缓存其中间过程的运算,使其运算量减少,得以快速被访问。
缺点是需要做好精细的维护。
个人不推荐上来就加缓存的,因为通常维护不好。
如无必要,勿加缓存。
比如计算一个矩形,有些方法除了一个 rect 字面量对象,返回了 x、y、width、height,还返回了 maxX、maxY、midX、midY 这些额外的计算属性,也就是缓存属性,它们基于前者运算得到。
如果这个 rect 在后续的逻辑中直接修改了 width、height,那就同样要修改缓存属性,这个很容易忘记,尤其是代码写的很乱的情况下,不知道怎么的就被改了。
这些计算属性需要进行的运算非常快,根本没必要缓存。
像是图形编辑器持久化图形树数据,如果图形本身保存了 parentId 和 sortKey,children 数组作为缓存其实没有必要持久化,因为我们完全可以在初始化时通过 parentId 和 sortKey 重新构造出 children,且计算量也不大。
children 可以在运行时去维护,导出来可以直接丢了。
建议只对一些计算量比较大,且比较少变化的状态进行缓存,常见的有图形的包围盒,因为 hover 的时候频繁触发,如果图形属性不更新,其包围盒是不会更新的。
当然对应的你要维护好缓存,在图形属性更新时清空包围盒,然后调用方法时,再懒加载计算包围盒。
A 开启时 B 要关闭
当我们开启某个模式,其他模块的一些逻辑可能临时禁用掉。
比如当前是选中工具状态,绑定了对应的鼠标事件,此时用户按下了空格键,此时会临时进入拖拽模式,也有对应的鼠标事件响应逻辑。
选中工具就需要临时禁用掉此时的鼠标事件逻辑。
于是在所有的事件响应函数都加上阻断逻辑:
kotlin
if (editor.canvasDragger.isActive()) {
return;
}
// 正常逻辑
后面随着模块越来越多,我发现 if 条件太多了,而且 A 和 B 的关联关系也不明显,容易写出问题。
打个比方,这就好比你去饭堂点饭,你不点饭,饭堂阿姨看你一眼确定你要点什么饭。
这样同学只要吃饭就行了,但饭堂阿姨的要考虑的事情就多了,A 同学要吃这个,B 同学要吃这个,都要记下来。
如果有一天新同学 C 来了,但招生办的人又没和饭堂阿姨说,C 同学来打饭的时候,饭堂阿姨也不知道他要吃什么,最后 C 同学什么都没吃到,然后饿坏了。
正确的解法还是让同学自己点菜,阿姨才不管你是什么同学呢,她只要遵循你的指令就好了,即使你不点菜。
因此,应该是让模块 B 自己在合适的时候关闭掉模块 A 的一些行为,当然模块 A 也要主动提供这些开关出来。然后在合适的时候复原 A 的状态。
虽然改写后的逻辑并没有变少,但我们能显式地知道在模块 B 上就知道哪些其他模块的哪些开关被临时关闭了,而不是一个个模块去查看。
结尾
我是前端西瓜哥,关注我,学习更多图形编辑器知识。
相关阅读,