图形编辑器开发的一些坑

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

图形编辑器算是 Web 前端开发领域的其中一个天花板了。

对于功能复杂的图形编辑器,经过迭代多年,几十多模块互相关联,包含各种细碎的业务细节,复杂度足够高。

实现过程中要在设计架构上要合理规划解耦,实现功能还需要扎实的数据结构与算法、线代知识、图形学等知识,门槛是不低的。

下面讨论下开发大型图形编辑器项目遇到的一些坑。

(如果是简单小工具的话,其实并不需要太遵守下面的建议,怎么方便怎么来,花太多时间在设计上反而没这么必要)

图形库做了太多的事情

市面上也有不少图形库额外提供部分编辑器部分的能力,其中 fabric.js 是其中佼佼者。

所以在敏捷开发的驱动下,或者开发者本身没有图形编辑器开发经验的情况下,大家更倾向于用这些第三方库,然后在上面做二次开发。我承认这是一个还不错的做法,但前提是你的工具并不复杂

但如果你的编辑器是一个大型的项目,需要持续开发好几年,不断迭代,需要很多定制的交互,并加入非常多复杂的业务能力,那使用图形库的编辑器能力反而会让开发难以进行下去。

过于依赖图形库的编辑器逻辑,定制开发会变得异常困难,因为它们是黑盒子,虽然官方可能会提供一些定制化的接口,但很少,没法满足需求,可能最后不得写一些奇怪的代码,最后不可避免走向不可维护的结局。

你也可以给第三方库提 issue,但不好意思,你的需求优先级太低,最后是过了几年你想要的功能依旧在排期中。

你只能祈祷产品经理和设计师不要提太多过于细节的 UI 和交互的功能,说服他们能用就行。

这是职责划分不清晰导致的难以维护问题,你改不了这些图形库的底层逻辑,因为它就不是你写的。

在开发复杂图形编辑器的场景下,我认为不应该让第三方图形库负责编辑器业务功能,它应该做纯粹的渲染工作。

编辑器的逻辑需要开发人员自己实现一套,虽然造这个轮子很花时间,但是这一切是值得的。因为这个和我们自己的业务的特性紧密结合,扩展维护都会非常方便。

图形模型设计不合理

图形编辑器离不开图形的绘制,像是矩形、椭圆、多边形、星形、路径等。

有人会直接用图形库给的图形类,去继承然后扩展功能。

其实这并不合适,正如前面说的,这图形类首先是用来渲染的,然后可能还带着一些简单的业务能力(如 hitTest、事件等),这些都用上是不好维护扩展的。

我们要遵循单一职责原则,做好分层。

正确的做法是分成三种模型:

  1. 业务模型;

  2. 渲染模型;

  3. 几何算法模型

大致像这样。

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 的操作执行了好几次,实际上转换一次就好了。

最好改成 getXxxsetXxx 的形式,这样使用者就能明显感知到它可能存在副作用。

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 上就知道哪些其他模块的哪些开关被临时关闭了,而不是一个个模块去查看。

结尾

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


相关阅读,

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

关于图形编辑器数据的后端持久化

相关推荐
程序视点3 小时前
IObit Uninstaller Pro专业卸载,免激活版本,卸载清理注册表,彻底告别软件残留
前端·windows·后端
前端程序媛-Tian4 小时前
【dropdown组件填坑指南】—怎么实现下拉框的位置计算
前端·javascript·vue
嘉琪0014 小时前
实现视频实时马赛克
linux·前端·javascript
烛阴4 小时前
Smoothstep
前端·webgl
若梦plus5 小时前
Eslint中微内核&插件化思想的应用
前端·eslint
爱分享的程序员5 小时前
前端面试专栏-前沿技术:30.跨端开发技术(React Native、Flutter)
前端·javascript·面试
超级土豆粉5 小时前
Taro 位置相关 API 介绍
前端·javascript·react.js·taro
若梦plus5 小时前
Webpack中微内核&插件化思想的应用
前端·webpack
若梦plus5 小时前
微内核&插件化设计思想
前端
柯北(jvxiao)5 小时前
搞前端还有出路吗?如果有,在哪里?
前端·程序人生