浏览器绘图工作台:四种画布、宿主权限与状态持久化
本文目录
“画图工具”听起来像一个 Canvas 组件,实际更像一组共享宿主能力、却拥有不同数据模型的编辑器。本篇基于本机一个 React/TypeScript 插件代码快照做工程审查:其中有手绘白板、形状编辑、节点流程图和 3D 场景四种入口。这里描述的是代码结构与本轮本地修复,不代表插件已经在原宿主重新安装或完成生产验收。
四种画布不是同一种数据
- 手绘模式使用专业白板引擎,重点是笔迹、文本、图形、选择和只读查看。它的文档通常是元素集合,而不是一张最终图片。
- 形状编辑器使用 Canvas 对象模型,可选取、移动和编辑矩形、圆形、三角形、线、箭头与文本。保存结构化对象比只存截图更容易继续编辑。
- 流程图编辑器使用图结构:节点有类型、属性与端口,边有起终点和路由。撤销、复制、吸附、缩放和节点模版都属于图编辑领域,不应塞进普通画笔状态。
- 3D 场景保存几何类型、位置、旋转、缩放、材质和相机相关状态,再由 WebGL 场景重建。它的序列化对象和 2D 笔迹完全不同。
因此,合理的外层架构是“共享宿主壳 + 按需加载的编辑器 + 各自版本化的数据格式”。路由只负责选择引擎、传递数据与权限、接收变更;各引擎负责自己的编辑模型。把四种格式强行扁平化为图片会丢掉节点关系、文字可编辑性、对象选择和 3D 变换。
宿主壳要先解决加载、权限和状态
插件入口按当前工具动态加载大型编辑器,避免每次打开都下载白板、图形、流程图和 3D 运行时。壳层还要处理加载中、加载失败、无数据和只读状态。一个常见陷阱是:宿主异步返回“当前是否可编辑”,而编辑器只在第一次挂载时读取这个值。若初始只读状态被引擎缓存,后来权限切换成功,画布看起来仍不能编辑;反过来,默认可编辑又会在权限读取失败时放开危险操作。
本轮检查将权限初值改为只读,只有宿主明确回答可编辑才开放修改;异步结果在组件卸载后不再写 React 状态。权限变化时,编辑器实例按只读/编辑状态切换重新挂载,让底层引擎重新接收配置。这个客户端状态只用于交互,不是安全边界:真正保存数据的宿主接口仍必须在服务端验证当前用户的文档权限。
当前宿主事件接口在这份代码快照里可注册监听,但没有暴露对应注销方法。卸载时可以用活动标记让回调变成无操作,避免迟到结果修改已销毁组件;不过这不等于监听器已从宿主移除。若宿主未来提供 unsubscribe/返回清理句柄,应真正解绑;长期打开和关闭插件时也应检查监听器是否累积。
异步加载必须拒绝过期结果
嵌入数据会在初始化时读取,宿主的外部数据变化事件也可能触发再次读取。假设读取 A 很慢,之后读取 B 很快返回:如果 A 最后才完成,它就会把较新的 B 覆盖掉。请求序号可以表达“只接受最后一次读取”:
let latestRequest = 0;
async function reload() {
const requestId = ++latestRequest;
const result = await readHostData();
if (requestId !== latestRequest) return;
applyToEditor(result);
}
组件卸载时递增序号,使未完成读取自然失效;失败和 finally 分支也要检查序号,否则较旧请求仍可能覆盖新请求的错误或加载状态。本轮在本地存储 hook 中加了请求序号、卸载标记,并在最新读取成功后清除旧错误。
Canvas 事件处理器会捕获旧设置
形状编辑器初始化后会长期持有鼠标事件处理器。如果处理器闭包捕获首次渲染的颜色、线宽、字体大小、只读属性和保存回调,用户后面改了工具栏设置,下一笔仍可能使用旧颜色;权限改成只读后,旧处理器也可能仍按可写方式响应。频繁销毁重建整个 Canvas 可以绕开一部分闭包问题,但会带来选择丢失、重建开销和事件重复绑定。
本轮改为让稳定的 Canvas 事件处理器在触发时读取最新配置引用,同时保留当前工具模式的引用。这样创建新形状会读取最新颜色和笔宽,提交时也调用最新保存回调。初始化异步恢复 JSON 时还要考虑加载失败、组件提前卸载和宿主数据再次变化,避免旧画布覆盖新状态。
箭头由线段和箭头头部组合,若把 Fabric 内部对象直接当作公开 JSON 协议的一部分,库升级后可能无法还原。跨版本持久化更稳妥的方式是定义自己的形状 schema,在保存时转换为明确字段,在读取时做版本迁移与字段校验。
流程图要保存拓扑而不只是位置
流程图的最小数据单元是节点与边。节点可有稳定 ID、节点类型、标题、端口和业务数据;边要保存 source/target 节点及端口、连接线类型和标签。复制节点后必须生成新 ID,删除节点时要明确关联边的处理策略。端口的方向和类型可以约束连线,避免画出语义无效的图。
撤销重做应只观察用户可见的模型变更,而不是每次画布渲染都进入历史。拖动连续事件可以先在内存预览,pointer up 时提交一个事务;键盘快捷键要检查焦点位置,避免用户正在输入节点文本时触发全局撤销或删除。大图可把 JSON 序列化和布局计算移到 Worker,但需验证克隆成本与取消旧任务的策略。
3D 变换的最终值要写回模型
3D 编辑器拖动 TransformControls 时,网格对象直接在 Three.js 场景中移动,而 React 保存的对象数组仍可能是旧值。每隔一段时间读取网格变换可以让属性面板跟随变化,却不能保证用户在两个采样点之间松手时,最终值一定写入宿主。重新打开文档后因此可能看到拖动前的位置。
本轮在本地增加了拖拽结束提交:松开变换控件时读取 mesh 的最终 position、rotation、scale,更新 React 场景模型并调用宿主保存回调。拖动中的更新仍用于面板预览,回调通过最新引用避免间隔任务继续使用旧函数。还需在原宿主验证高频拖动、快速切换选择、切换只读权限和关闭弹窗时的最终状态。
3D 对象也需要边界:限制对象数量、网格细分、纹理尺寸与透明材质;否则一个来源不明的序列化场景就能令低端手机掉帧或耗尽显存。读取 JSON 前应限制体积、数组长度、数字范围和允许的几何类型,不应把用户数据当作可执行代码。
存储格式、体积和恢复策略
当前宿主数据通过 JSON 表示,再编码成 Base64 字符串后写回。Base64 通常比原始 JSON 大约多三分之一;如果数据里含有图片 data URL,膨胀与内存占用会更明显。高频编辑应做防抖或合并提交,但页面离开前要处理尚未提交的最后一批变更;保存接口还应返回成功/失败状态,不能让界面静默显示“已保存”。
更稳健的长期协议可包含 schemaVersion、editorType、updatedAt 和 data,并按编辑器类型做白名单解析。保存前限制最大 JSON 字节数和嵌套深度;解析时设置合理的节点/笔迹数量上限;未来改格式时提供逐版本迁移。图片等大二进制资产应考虑宿主附件或对象存储引用,而不是无上限塞入文档 JSON。
如果宿主事件与本地自动保存同时发生,还要确定冲突策略:以宿主最新版本为准、乐观并发控制、或提示用户选择保留哪一版。仅靠 500ms 左右的防抖并不能解决跨标签页覆盖、网络重试重复写入或设备断电。
移动端与输入设备验证
移动端绘图优先使用 Pointer Events,配合 pointer capture 让手指移出画布边界后仍能收到完整笔画,并处理 pointercancel、页面失焦和触控笔输入。画布容器要声明合适的 touch-action,否则浏览器会把拖动画布识别成页面滚动;但这也意味着应限制白板内部的手势区域,不要让整个文章页都失去正常滚动。
高分屏需要把 CSS 尺寸与 Canvas 位图尺寸分开,按 devicePixelRatio 设置 backing store,并在 ResizeObserver 触发时重绘。DPR 应设上限,避免高像素手机将内存放大数倍。轨迹以画布归一化坐标存储,适应容器变宽后仍保持相对位置;变更画布宽高时重放模型,而不是直接拉伸一张旧截图。
本篇下方的独立白板 Demo 使用 Pointer Events,提供颜色、笔宽、橡皮擦、撤销、清空和 PNG 导出。它只在当前浏览器页面的 Canvas 里运行,不连接宿主、不写博客后台,也不上传笔迹;它是说明交互原理的小型体验,不是原绘图插件的完整移植。
回归清单
- 初次权限读取期间必须只读;失败时不开放编辑;从只读切到可编辑后,工具必须真正重新初始化。
- 连续切换颜色、笔宽、字体和工具模式后,下一次绘制应读取最新设置。
- 初始化读、外部变化读和组件卸载并发时,旧请求不能覆盖新结果或重置其加载状态。
- 在 2D、流程图、3D 各自保存、关闭、重开,检查模型结构和外观一致。
- 3D 变换结束立刻关闭并重开,最终位置、旋转与缩放仍然存在。
- 绘制到一半触发 pointercancel、切换工具、调整视口或失焦时,不遗留卡住的笔画状态。
- 在桌面触控板、鼠标、触屏和触控笔上验证压力/手势策略、撤销、导出和页面滚动。
- 模拟宿主读取/保存失败、数据超限、损坏 JSON、未知 schemaVersion 与外部并发修改。
- 长时间打开/关闭插件,观察宿主事件监听器和 Canvas/WebGL 资源是否有清理路径。
本地检查结果与边界
本轮审查对代码快照修复了编辑权限默认放行、过期读取竞态、权限变化后编辑器没有重建、Fabric 事件处理器持有旧设置,以及 3D 变换最终值未提交等问题。修改位于本机插件仓库,没有替用户安装回原宿主或发布该插件;完整构建、宿主授权态保存、移动设备手势和长期资源清理仍要分别实测。
绘图插件的关键不只是“能画”,而是编辑器模型各自可演进、宿主权限默认收紧、异步读写不乱序、变更确实落盘,并且在退出后能释放资源。先把这些边界做扎实,再扩展模板、协作、媒体嵌入与主题能力,会比继续堆工具按钮更可靠。
官方资料
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。