纯前端图像编辑器“毕业班蹭饭地图制作工具”的一点开发心得

发布于:2023-06-07 ⋅ 阅读:(623) ⋅ 点赞:(0)
毕业班蹭饭地图制作工具 是一个所见即所得的图像编辑器,用来帮助高中毕业班的孩子们把班级同学的去向进行可视化。
本文主要记录我在开发这个应用时的一些,不涉及具体实现细节,应用是闭源的,考虑以后把框架开源。

一、编辑器应用设计的两个基本要求

1. 编辑器板块之间应避免耦合

例如:在 PS 等图像编辑器应用中,拾色器都有“从画板上取色”的功能,这意味着拾色器不仅承担颜色输入的功能,还要实现读取画板数据的功能。

不过实际上我的应用里并没有取色功能,因为部分用了 svg ,取色板做起来多少有点麻烦。

开发者面对这种需求的第一想法,肯定是让画板给拾色器提供一个专门的取色接口,俺也一样。但是这样做,拾色器就必须与画板耦合:

graph LR
用户事件 --> 拾色器 --申请取色--> 画板
 画板 --点取的颜色--> 拾色器

拾色器与画板的耦合只是比较直观的一个例子,编辑器中板块之间的协作场景极多,如果让它们两两耦合的话,后面扩展编辑器功能的时候会极其困难。
实际上这个应用并不是第一个版本,我在 2021 年上线了其首个版本,其板块之间耦合极其严重,尝试扩展应用的时候往往牵一发而动全身。

2. 编辑器必须有良好的状态管理,并严格遵循

对于任何一个有经验的前端开发,对着文档拼凑出一个可以编辑图像的应用绝非难事,但是图像编辑器的目的就是为了降低非专业人士处理图像的门槛,因此在交互设计上要进行容错,最普遍的容错设计就是撤销(undo)和重做(redo)。
在复杂的视图上进行撤销和重做是极其困难的,将视图完全抽象成状态则会容易许多。因此,普通前端应用的事件→视图架构不再适用,而是要严格遵循事件→状态→视图的架构,这样一来程序才能把编辑成果的时间“切面”保存在一个历史栈中,发生撤销/重做操作的时候从保存的历史“切面”恢复为状态。

graph LR
历史栈 --历史切面--> 状态管理
状态管理 --状态切面--> 历史栈
用户事件 --撤销or重做--> 历史栈
用户事件 --改变--> 状态管理 --> 视图渲染

当然很多现有应用也是采用事件→状态→视图的架构来设计的——设计上是这样,但是开发过程中难免会有一部分状态被偷懒的开发者“截留”在组件里,导致“切面”数据没有记录视图的全部效果。

二、“毕业班蹭饭地图”制作工具的架构设计

1. 协议——负责板块之间通信,以防止板块耦合

仍以拾色器和画板的联动为例,实现步骤为:

  1. 画板向协议提供取色接口(即在画板上覆盖一张截图,用户点截图,就会获得对应位置的颜色参数);
  2. 拾色器订阅前述一个“取色协议”,用户点击取色按钮的时候,经协议向画板发出取色请求,画板开启取色功能;
  3. 用户在画板上选取颜色,颜色值经协议传送给拾色器并输入;
  4. 拾色器经协议向画板发送关闭取色的信号,画板即关闭取色功能。
sequenceDiagram
拾色器 ->> 取色协议: 发起取色请求
取色协议 ->> 画板: 调起取色功能
画板 -->> 画板: 用户在取色界面选择颜色
画板 ->> 取色协议: 用户选取的颜色
取色协议 ->> 拾色器: 颜色值
拾色器 ->> 取色协议: 关闭取色
取色协议 ->> 画板: 关闭取色

这样一来,取色器与画板的解耦合就完成了,双方只负责自己的角色,不关心协议另一侧是什么对象。除了画板之外,任意板块都可以提供取色的接口,甚至可以在电脑上运行一个截图客户端,直接获取用户屏幕上任意一点的色彩信息,或者通过网络拾取别人屏幕上的色彩信息。
在整个“毕业班蹭饭地图制作工具”中,协议应用十分广泛,连最重要的状态管理机制都是通过相应的协议接入应用的,这意味着如果后面要改为 Pinia 或者 Redux 之类的状态管理方案的时候,可以直接做一个胶水层替换目前的实现。
当然,板块与协议之间存在一定的耦合,这是无法避免的。

2. 模式——负责组合某一类功能

在一个画板应用里,往往会有多种不同类型的图元需要管理,如文字、图片、多边形,这些图元拥有不同的特性,把它们全部放在一起开发是不理智的。所以在这个应用里,我将不同的功能放到不同的模式开发,例如图片模式、文字模式等。
有人可能要问:把图形分别开发了,那图形之间的协同怎么办?——答案是用协议连通彼此。
由于模式将一整个功能都集成在一块了,一个模式相当于一整类功能的入口,如果要增删画板的某一项功能,在代码里面增减对应的模式即可:

  editor.registerMode(new GlobalMode());
  editor.registerMode(new RegionMode());
  editor.registerMode(new ImageMode());
  editor.registerMode(new TextMode());
  // AuthorMode 仅作者可用,所以线上版将其注释掉
  // editor.registerMode(new AuthorMode());

3. 部件——负责渲染视图

作为一个以前端技术为支撑的应用,最终还是要渲染成 HTML 的,承担这一任务的东西,我把它叫做部件,在代码里则使用 Widget 表示。
部件使用 React 组件输出视图,但是为了把视图整合到应用里,我使用 HOCReact.Component 组件做了一层包装,导致视图的写法有些“非主流”,类似这样:

class TestWidget extends Widget {
  private state = new WidgetState({ count: 0 });

  // 这个 renderer 不是 React.FC, 而是一个 React.FC 工厂
  renderer: ({ initWidgetState }) => {
    const { state } = this;
    // initWidgetState 专门用来绑定 React 与 Widget 的状态
    const [getState, setState] = initWidgetState(state);

    // 这里的返回值才是真正的 React.FC
    // 但是请注意里面无法使用任何 React Hooks
    return () => {
      const { count } = getState();
      return <>
        当前数值:{count}
        <button
          onClick={() => { setState({ count: count + 1 }) }}
        > 加一 </button>
      </>
    }
  }
}

写法比较繁琐,横看像 React.Component,纵看像React.FC,好在 TypeScript 可以及时提醒我怎么去写,但是不支持热更新。

4. 区域——作为渲染部件的容器

使用“模式”来组合一系列功能,从逻辑上来讲,确实是非常合理的。
但是从视图上讲,如果你移除了文字渲染模式,就等于同时移除:添加文字的按钮、输入文字的弹窗、编辑文字的表单……这些东西都是部件(Widget),而每个部件应当被渲染到不同的容器里,如果采用组件树的思想去组织部件容器与部件的关系,那么增删“模式”的时候就需要去操纵组件树挨个修改部件的容器(或者组件树状态)。
这个问题的解决方法是——让部件决定自己渲染到哪一个区域(Zone),区域会订阅部件的生命周期,从而更新视图。

部件(Widget)和区域(Zone)之间采用声明渲染类型的方式确定彼此。例如一个Zone 声明渲染 ["draw-board-view","view"],而一个 Widget声明接受 ["draw-board-view","main-view","view"]渲染——那么这个Widget就会被交给此Zone渲染。

在这个例子中,如果没有任何Zone声明渲染 "draw-board-view",则Widget可能会被交给声明渲染 "main-view"Zone
如果没有对应的 Zone 呢?不存在的—— WidgetZone 类的内部实现确保至少会有一个 "view"类型,只不过到这地步的情况下,所有的zone都会被渲染到一起,整个应用就没法使用了。

这样的方法还有一个好处:如果部件的功能同时适配了移动端和 PC 端,那么我们只需要针对两端各开发一套 Zone 体系,就可以跨端适配。

不过目前画板部件并不能适配移动端,所以并没有跨端支持。

5. 框架——负责整合、调度以上各板块

以上这些板块之间的功能需要整合方能协调,这个任务就交给了框架,以最简单的撤销/重做功能的实现为例,应用的部分组织方式大概就是这样的:

graph TB 

编辑器框架 --引入--> 全局模式

全局模式 --初始化-->状态管理协议
全局模式 --引入-->撤销/重做按钮
撤销/重做按钮 --操纵--> 状态管理协议
撤销/重做按钮 -.渲染到.->按钮区域

编辑器框架 --引入--> 按钮区域

subgraph 区域Zones
  按钮区域
end

状态管理协议

整个应用涉及到的各种区域、部件、协议、模式有数十种之多,全部画出来应该是非常壮观杂乱的,但在开发过程中我只需要关注眼下的板块就好。

三、使用到的主要技术

1. 视图框架和 UI 库

  1. 视图库: React
  2. UI库: AntD
  3. 脚手架: Create-React-App

2. 图像编辑部分

  1. 渲染层: ZRender
  2. 拾色器: @hello-pangea/color-picker
  3. 截图: html-to-image

2. 其他

  1. 表格解析 xlsx
  2. 压缩包读取 @zip.js
  3. 本地存储 localforage

四、心得

虽然我学习 TypeScript 很久了,但是工作里从未用到,这是我的第一个 TypeScript 应用,暴露了自己之前学习的许多盲点,所以说学习编程还是要注重实践。
整个应用其实是没有什么成体系的“设计”之说的,很多想法都是在编写代码的时候突然冒出来的,所以本文其实是一篇“总结”,实际上应用中落地的代码并不全是按这些思路来的,例如“协议”有时候也被命名为Channel、模式有时候被命名为 Layer……多少有些杂乱,可见程序员确实需要学习一些架构、规范方面的知识。
状态管理模块是自己写的,使用 JSON 做序列化/反序列化,导致明显的性能问题,所以说技术不够的手最好不要贸然造轮子,应该多学习一些优秀的项目。

本文含有隐藏内容,请 开通VIP 后查看