毕业班蹭饭地图制作工具 是一个所见即所得的图像编辑器,用来帮助高中毕业班的孩子们把班级同学的去向进行可视化。
本文主要记录我在开发这个应用时的一些心得,不涉及具体实现细节,应用是闭源的,考虑以后把框架开源。
一、编辑器应用设计的两个基本要求
1. 编辑器板块之间应避免耦合
例如:在 PS 等图像编辑器应用中,拾色器都有“从画板上取色”的功能,这意味着拾色器不仅承担颜色输入的功能,还要实现读取画板数据的功能。
不过实际上我的应用里并没有取色功能,因为部分用了 svg ,取色板做起来多少有点麻烦。
开发者面对这种需求的第一想法,肯定是让画板给拾色器提供一个专门的取色接口,俺也一样。但是这样做,拾色器就必须与画板耦合:
拾色器与画板的耦合只是比较直观的一个例子,编辑器中板块之间的协作场景极多,如果让它们两两耦合的话,后面扩展编辑器功能的时候会极其困难。
实际上这个应用并不是第一个版本,我在 2021 年上线了其首个版本,其板块之间耦合极其严重,尝试扩展应用的时候往往牵一发而动全身。
2. 编辑器必须有良好的状态管理,并严格遵循
对于任何一个有经验的前端开发,对着文档拼凑出一个可以编辑图像的应用绝非难事,但是图像编辑器的目的就是为了降低非专业人士处理图像的门槛,因此在交互设计上要进行容错,最普遍的容错设计就是撤销(undo
)和重做(redo
)。
在复杂的视图上进行撤销和重做是极其困难的,将视图完全抽象成状态则会容易许多。因此,普通前端应用的事件→视图
架构不再适用,而是要严格遵循事件→状态→视图
的架构,这样一来程序才能把编辑成果的时间“切面”保存在一个历史栈中,发生撤销/重做操作的时候从保存的历史“切面”恢复为状态。
当然很多现有应用也是采用事件→状态→视图
的架构来设计的——设计上是这样,但是开发过程中难免会有一部分状态被偷懒的开发者“截留”在组件里,导致“切面”数据没有记录视图的全部效果。
二、“毕业班蹭饭地图”制作工具的架构设计
1. 协议——负责板块之间通信,以防止板块耦合
仍以拾色器和画板的联动为例,实现步骤为:
- 画板向协议提供取色接口(即在画板上覆盖一张截图,用户点截图,就会获得对应位置的颜色参数);
- 拾色器订阅前述一个“取色协议”,用户点击取色按钮的时候,经协议向画板发出取色请求,画板开启取色功能;
- 用户在画板上选取颜色,颜色值经协议传送给拾色器并输入;
- 拾色器经协议向画板发送关闭取色的信号,画板即关闭取色功能。
这样一来,取色器与画板的解耦合就完成了,双方只负责自己的角色,不关心协议另一侧是什么对象。除了画板之外,任意板块都可以提供取色的接口,甚至可以在电脑上运行一个截图客户端,直接获取用户屏幕上任意一点的色彩信息,或者通过网络拾取别人屏幕上的色彩信息。
在整个“毕业班蹭饭地图制作工具”中,协议应用十分广泛,连最重要的状态管理机制都是通过相应的协议接入应用的,这意味着如果后面要改为 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
组件输出视图,但是为了把视图整合到应用里,我使用 HOC
对 React.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}
>
}
}
}
写法比较繁琐,横看像 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
呢?不存在的—— Widget
和 Zone
类的内部实现确保至少会有一个 "view"
类型,只不过到这地步的情况下,所有的zone
都会被渲染到一起,整个应用就没法使用了。
这样的方法还有一个好处:如果部件的功能同时适配了移动端和 PC 端,那么我们只需要针对两端各开发一套 Zone
体系,就可以跨端适配。
不过目前画板部件并不能适配移动端,所以并没有跨端支持。
5. 框架——负责整合、调度以上各板块
以上这些板块之间的功能需要整合方能协调,这个任务就交给了框架,以最简单的撤销/重做功能的实现为例,应用的部分组织方式大概就是这样的:
整个应用涉及到的各种区域、部件、协议、模式有数十种之多,全部画出来应该是非常壮观杂乱的,但在开发过程中我只需要关注眼下的板块就好。
三、使用到的主要技术
1. 视图框架和 UI 库
- 视图库: React
- UI库: AntD
- 脚手架: Create-React-App
2. 图像编辑部分
- 渲染层: ZRender
- 拾色器: @hello-pangea/color-picker
- 截图: html-to-image
2. 其他
- 表格解析 xlsx
- 压缩包读取 @zip.js
- 本地存储 localforage
四、心得
虽然我学习 TypeScript
很久了,但是工作里从未用到,这是我的第一个 TypeScript
应用,暴露了自己之前学习的许多盲点,看来学习编程还是要注重实践。
整个应用其实是没有什么成体系的“设计”之说的,很多想法都是在编写代码的时候突然冒出来的,所以本文其实是一篇“总结”,实际上应用中落地的代码并不全是按这些思路来的,例如“协议”有时候也被命名为Channel
、模式有时候被命名为 Layer
……多少有些杂乱。可见程序员确实需要学习一些架构、规范方面的知识和经验,能够在开始编码之前就能预见到足够多的问题并通过合适的设计来规避之。
状态管理模块是自己写的,使用 JSON 做序列化/反序列化,数据量大的时候性能问题明显,所以说技术不够的时候最好不要贸然造轮子,应该多学习和使用一些优秀的项目。