毕业班蹭饭地图制作工具 是一个所见即所得的图像编辑器,用来帮忙高中毕业班的孩子们把班级同学的去向进行可视化。
本文次要记录我在开发这个利用时的一些,不波及具体实现细节,利用是闭源的,思考当前把框架开源。
一、编辑器利用设计的两个根本要求
1. 编辑器板块之间应防止耦合
例如:在 PS 等图像编辑器利用中,拾色器都有“从画板上取色”的性能,这意味着拾色器不仅承当色彩输出的性能,还要实现读取画板数据的性能。
不过实际上我的利用里并没有取色性能,因为局部用了 svg,取色板做起来多少有点麻烦。
开发者面对这种需要的第一想法,必定是让画板给拾色器提供一个专门的取色接口,俺也一样。然而这样做,拾色器就必须与画板耦合:
graph LR
用户事件 --> 拾色器 -- 申请取色 --> 画板
画板 -- 点取的色彩 --> 拾色器
拾色器与画板的耦合只是比拟直观的一个例子,编辑器中板块之间的合作场景极多,如果让它们两两耦合的话,前面扩大编辑器性能的时候会极其艰难。
实际上这个利用并不是第一个版本,我在 2021 年上线了其首个版本,其板块之间耦合极其重大,尝试扩大利用的时候往往牵一发而动全身。
2. 编辑器必须有良好的状态治理,并严格遵循
对于任何一个有教训的前端开发,对着文档拼凑出一个能够编辑图像的利用绝非难事,然而图像编辑器的目标就是为了升高非专业人士解决图像的门槛,因而在交互设计上要进行容错,最广泛的容错设计就是撤销 (undo
) 和重做 (redo
)。
在简单的视图上进行撤销和重做是极其艰难的,将视图齐全形象成状态则会容易许多。因而,一般前端利用的 事件→视图
架构不再实用,而是要严格遵循 事件→状态→视图
的架构,这样一来程序能力把编辑成绩的工夫“切面”保留在一个历史栈中,产生撤销 / 重做操作的时候从保留的历史“切面”复原为状态。
graph LR
历史栈 -- 历史切面 --> 状态治理
状态治理 -- 状态切面 --> 历史栈
用户事件 -- 撤销 or 重做 --> 历史栈
用户事件 -- 扭转 --> 状态治理 --> 视图渲染
当然很多现有利用也是采纳 事件→状态→视图
的架构来设计的——设计上是这样,然而开发过程中难免会有一部分状态被偷懒的开发者“截留”在组件里,导致“切面”数据没有记录视图的全副成果。
二、“毕业班蹭饭地图”制作工具的架构设计
1. 协定——负责板块之间通信,以避免板块耦合
仍以拾色器和画板的联动为例,实现步骤为:
- 画板向协定提供取色接口(即在画板上笼罩一张截图,用户点截图,就会取得对应地位的色彩参数);
- 拾色器订阅前述一个“取色协定”,用户点击取色按钮的时候,经协定向画板收回取色申请,画板开启取色性能;
- 用户在画板上选取色彩,色彩值经协定传送给拾色器并输出;
- 拾色器经协定向画板发送敞开取色的信号,画板即敞开取色性能。
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
组件输入视图,然而为了把视图整合到利用里,我应用 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}
<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
呢?不存在的—— Widget
和 Zone
类的外部实现确保至多会有一个 "view"
类型,只不过到这境地的状况下,所有的 zone
都会被渲染到一起,整个利用就没法应用了。
这样的办法还有一个益处:如果部件的性能同时适配了挪动端和 PC 端,那么咱们只须要针对两端各开发一套 Zone
体系,就能够跨端适配。
不过目前画板部件并不能适配挪动端,所以并没有跨端反对。
5. 框架——负责整合、调度以上各板块
以上这些板块之间的性能须要整合方能协调,这个工作就交给了框架,以最简略的撤销 / 重做性能的实现为例,利用的局部组织形式大略就是这样的:
graph TB
编辑器框架 -- 引入 --> 全局模式
全局模式 -- 初始化 --> 状态治理协定
全局模式 -- 引入 --> 撤销 / 重做按钮
撤销 / 重做按钮 -- 操纵 --> 状态治理协定
撤销 / 重做按钮 -. 渲染到.-> 按钮区域
编辑器框架 -- 引入 --> 按钮区域
subgraph 区域 Zones
按钮区域
end
状态治理协定
整个利用波及到的各种区域、部件、协定、模式有数十种之多,全副画进去应该是十分 壮观 芜杂的,但在开发过程中我只须要关注眼下的板块就好。
三、应用到的次要技术
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 做序列化 / 反序列化,数据量大的时候性能问题显著,所以说技术不够最好不要贸然造轮子,应该多学习一些优良的我的项目。