多引擎在线绘图插件:从宿主协议到 Excalidraw、Fabric、X6 与 Three.js
本文目录
当一个文档平台需要“插入可编辑的图”,单一画布库往往无法同时满足手绘、平面设计、流程图和三维场景。一个更灵活的方案是建立统一的宿主协议与工具注册表,再让不同画布引擎各自负责擅长的交互。本文基于一次本地插件源码审阅,抽象出这种多引擎嵌入式创作工具的架构、数据边界与验收办法;不包含企业平台名称、私有接口或原始仓库代码。静态阅读不能证明宿主 SDK、权限、打包和文档保存行为在线上正常。
把工具做成注册表,而不是条件分支
插件将每种画布描述为一个工具配置:稳定类型、展示名称、MIME 类型、默认尺寸和入口组件。路由根据 URL 中经过白名单校验的 tool 类型选择对应工具;通过 React lazy/Suspense 延迟加载 Excalidraw、Fabric、X6 和 React Three Fiber,避免用户只用白板时也下载所有图形运行时。
配置表可以成为所有入口的单一事实来源:工具面板用它渲染卡片,插入文档时使用同一 MIME 类型,独立 webapp 路由也使用同一类型解析。新增引擎时只需扩展注册表和对应组件,而不是在多处复制展示名、颜色和默认宽高。
这里有一个重要边界:URL 参数只能决定选择哪个已知工具,不应该被当成任意组件名、模块路径或脚本地址。未知类型应展示可恢复的错误页;不能将未经验证的用户输入交给动态 import。
宿主能力通过适配层注入
嵌入式工具通常需要读取文档数据、监听数据变更、取得当前用户信息、判断是否可编辑以及调整画布尺寸。插件代码通过宿主 SDK 调用这些能力,独立的工具组件则只接收普通 props:
interface DrawingToolProps {
data: string | null
readOnly: boolean
onChange: (serializedData: string) => void
userInfo: UserInfo
}
这种分层让具体画布尽量不依赖宿主 API,理论上更容易做本地预览和单元测试。实际落地时最好再包一层 HostDocumentAdapter,把 getData、setData、用户上下文、可编辑状态、画布尺寸和事件订阅统一封装;这样升级宿主 SDK 时不必修改每个画布引擎。
只读状态应由宿主权限事实驱动,而不是仅靠 query 参数。插件可以根据 isEditable() 与后续的 editableChange 事件更新 UI,但最终是否允许写入仍应由宿主保存接口验证。若用户在编辑中途失去权限,工具要禁用写操作、提示未保存状态,并确保旧会话的延迟保存不会覆盖权限变化后的文档。
一个稳定的数据协议
不同引擎的数据结构差异很大。插件将序列化结果存入文档的固定键,并用 MIME 类型标记画布种类,再以 Data URL 包装 JSON。读写的概念流程如下:
const raw = await host.getData(DRAWING_DATA_KEY)
const json = decodeDrawingData(raw, expectedMimeType)
const next = serializeCanvasState(editorState)
await host.setData(DRAWING_DATA_KEY, encodeDrawingData(next, expectedMimeType))
每种引擎都应有独立且带版本号的 schema,例如 { schemaVersion: 1, tool: 'flowchart', data: ... }。恢复旧文档时先校验 MIME 与 schema,再做迁移;遇到损坏数据应显示“无法读取,可导出原始数据或复制备份”的恢复入口,不能静默当成空画布并在下一次自动保存时覆盖原文件。
Data URL 是编码形式,不是压缩,也不是加密。Base64 通常会让内容比原始 UTF-8 更大;大图和长流程图因此可能明显增加文档体积。应设置明确的字节上限,避免同步大块 JSON 阻塞主线程,并为大量图片考虑 Blob、附件资源或宿主原生文件对象。导入外部内容要验证 MIME、尺寸、schema 版本和图片引用,不能因字符串含有 base64, 就认为内容可信。
编辑器生命周期比保存按钮更重要
画布每次 onChange 都可能触发高频保存。用几百毫秒防抖可以减少宿主写入,但要补全整个生命周期:
- 保存队列必须按文档 ID 与工具类型隔离,旧文档的慢写入不能覆盖刚打开的新文档。
- 组件卸载时不能简单丢掉仍待执行的防抖定时器;应 flush 最后一次变更、等待正在进行的写入,或向读者明确提示尚未保存。
dataChange、editableChange等订阅应保存 unsubscribe 函数,并在组件卸载时清理。- 多个异步读取可以用递增序号或 AbortSignal 丢弃过期结果;保存也需要文档版本或宿主提供的乐观锁。
- 写入失败不能只打控制台日志;需要可见的“已保存/保存中/保存失败”状态、重试动作和本地恢复副本策略。
- 频繁拖动时可进一步用 requestAnimationFrame 或节流提交画布状态,交互结束再持久化,避免每一帧都编码完整文档。
若宿主支持保存版本号,可以让写入携带读取时的 revision,服务端发现版本变化就返回冲突,由编辑器提示重新加载或比较,而不是静默覆盖协作者的最新修改。
多种画布引擎如何各司其职
| 引擎 | 适用内容 | 集成关注点 |
|---|---|---|
| Excalidraw | 草图、白板、头脑风暴 | 场景恢复、导出权限、图片资源与只读模式 |
| Fabric.js | 平面设计、海报和自由对象 | 画布尺寸变化、对象 schema、图片跨域与序列化 |
| AntV X6 | 节点、端口、连接线和流程图 | 插件注册、节点类型迁移、撤销重做和图布局 |
| React Three Fiber | 三维场景和空间对象 | WebGL 上下文释放、设备像素比、帧循环与低端设备性能 |
一个公共 DrawingFrame 可以承载标题、折叠、加载、错误和只读状态;但它不应该吞掉画布专有的保存状态或错误详情。Three.js 等重量级引擎应独立动态加载,WebGL 初始化失败时要给出可读降级内容,而不只是空白区域。
体验与安全检查表
- 工具卡片具备键盘可达性、清晰的插入状态和取消方式;点击后错误可以重试。
- 默认画布尺寸、宿主可调整尺寸以及小屏适配三者一致;缩放、拖动与触屏手势不互相抢事件。
- 所有画布提供撤销/重做、清空确认、导出和自动保存状态;只读模式隐藏或禁用所有修改入口。
- 图片上传验证文件大小、格式和解码尺寸;导入 JSON 有深度、对象数和文本长度限制。
postMessage若用于跨 iframe 通信,必须验证origin、消息 schema、来源窗口和允许动作;不要用'*'发送敏感文档数据。- 宿主 API 调用失败、事件延迟、离线、登录失效和权限撤销都要有可解释状态。
- 在多文档切换、快速卸载、重复插入、保存超时和大画布场景验证没有数据串写或丢失。
- 移动端、无 WebGL 环境、减少动画偏好和窄容器都要有明确的降级路径。
源码复核补充:白名单、保存收尾与失败重试(2026-10-17 本地)
对个人绘图插件的当前源码再做一次定向检查后,确认了几处比“建议验收”更具体的边界:
- 工具名必须检查自有配置项。 旧的 URL 白名单使用 JavaScript 的
in运算符,它也会命中对象原型上的constructor、toString等属性。现已改成自有属性判断;定向运行验证确认四种已配置工具仍可解析,原型属性和未知名称都回到“未知工具”状态。这是工具路由边界修正,不代表整套插件已经完成安全审计。 - 读取侧已有过期响应保护。 文档数据读取会增加序号;如果用户快速切换或触发新读取,较早返回的异步结果不会覆盖较新的状态。这是有用的并发保护,但它只约束读取结果,不等同于保存冲突检测。
- 防抖写入的卸载尾部已做本地修复。 保存 hook 现在保留最新待写值,卸载时清理计时器并尝试 flush;多个异步写入通过串行队列执行,避免较慢的旧写入在新值之后覆盖文档。新增的 Node 回归测试验证卸载会提交最后一次变更,以及前一笔写入未完成时仍按顺序提交较新内容。宿主
setData是否在卸载后继续有效、是否真正持久化仍需集成环境验收;这不是对宿主数据落盘的证明。 - 可见保存状态和重试已在本地源码接通。 hook 暴露待保存、保存中、已保存和失败状态;宿主拒绝写入时会保留当前挂载会话中的待写内容,公共画布框显示失败原因并提供重试按钮。新增回归验证首次写入失败后,重试可以再次提交相同内容。重试数据目前只保存在内存中,编辑器卸载期间发生的失败仍没有可见恢复入口,也没有本地恢复副本。
- 事件解绑能力尚不能下结论。 读取侧注册了数据变更监听,回调会在组件失活后停止处理;当前 hook 没有展示对应的显式 unsubscribe。宿主 SDK 是否随 iframe 生命周期自动清理未由这份源码证明,应检查公开 SDK 契约或在反复打开/关闭后做运行时验证,不能直接称为内存泄漏。
这轮是有限范围的本地源码修复与 hook 回归测试,没有登录目标文档平台,也没有上传、保存或发布真实文档。失败状态和重试入口只在本地代码中通过测试;宿主卸载后的持久化、事件解绑、权限变化及协作冲突仍需真实运行验收,不能把本地测试扩大解释为平台集成已通过。
2026-10-19 源码复核:权限默认关闭、保存回执与多引擎状态
当前本地 checkout 又增加了一批工作区改动。以下描述限定于静态源码和本地检查,不代表这些改动已经安装到宿主或发布:
- 权限默认失败关闭。
useEditMode初始为只读,只有宿主isEditable()明确返回布尔值true后才开放编辑;拒绝或异常保持只读。编辑器 key 随只读状态变化而改变,促使具体画布用新权限重新挂载。这改善了客户端状态,但不能替代宿主对每次写入的授权。 - 保存结果可见。 共享 hook 将 500 ms 防抖后的写入串行排队,追踪当前待写内容;画布框显示待保存、保存中、已保存或失败,并提供同一挂载会话内的重试。卸载时会清除计时器并尝试 flush。三项 hook 回归分别覆盖卸载提交最新草稿、慢写入期间维持顺序、失败后重试;不能证明宿主在 iframe 销毁后仍会完成写入。若卸载后的写入失败,虽然 hook 暂存引用会保留在已卸载实例中,用户已没有可见恢复入口。
- 长生命周期回调读取最新设置。 Fabric 事件处理器经 ref 读取最新颜色、线宽、字体、只读状态和
onChange,避免画布初始化后继续使用旧闭包。X6 也经 ref 读取最新属性;宿主数据变化时静默导入并清除 undo/redo 历史,遇到无效 JSON 则保留当前画布。 - 待确认的 X6 撤销风险。 若宿主把插件自己的
setData也广播为dataChange,上述静默导入会再次执行cleanHistory(),可能在保存回声到达后清空本地撤销历史。当前只有源代码证据,没有宿主事件契约或运行复现;应在真实宿主中绘制、等待自动保存、再执行撤销,并同时区分外部协作者更新。无效 JSON 被静默保留也尚无可见错误状态,可能让用户误以为旧画布就是最新文档。 - 变换最终值持久化。 Three.js 控件在拖拽结束时读取对象位置、旋转和缩放,并提交最终状态;Fabric 则避免持久事件回调使用旧工具栏值。此处没有针对画布 UI 的自动化回归,也没有 WebGL、触控或真实设备性能验证。
- 权限与无障碍收尾(2026-10-19 本地修复)。 保存失败时只在可编辑状态显示“重试保存”;只读模式仍会说明保存失败,但不会提供看似可用的写入操作。折叠图标按钮现在显式使用
type="button",并带有动态读屏名称与aria-expanded状态。标题区域本身仍是可点击div,键盘用户应使用旁边的语义化折叠按钮;宿主事件监听仍未见明确 unsubscribe。前端隐藏操作只是交互约束,宿主写入接口仍必须独立鉴权。
本轮在当前本地插件目录执行 pnpm test 6/6、pnpm exec tsc --noEmit --incremental false 通过。pnpm lint 未通过:0 个错误、33 条 no-explicit-any 警告,因配置最大警告数为 0 而退出失败;不能据此判断这些警告都是本轮新增。没有运行构建、压缩发布包、安装宿主插件或访问真实文档。后续验收优先覆盖权限撤销时待写内容、宿主保存回声与 X6 撤销栈、卸载写入失败恢复、坏数据提示、事件解绑和键盘操作。
2026-10-20 类型收敛与绘图交互复核(仅本地)
在既有未提交工作基础上继续审阅绘图插件,补齐官方类型并修复 Fabric 绘图、持久化和箭头定位缺陷:
- React 演示控件改用明确的状态 setter 类型;Excalidraw 使用其公开 API、初始场景与回调类型;Fabric 鼠标事件及画笔属性采用库类型;X6 的连接校验、节点/连线事件和 Stencil 搜索使用 SDK 推导/声明的模型类型;宿主嵌入数据列表不再手工断言成
any[]。图片与 WebApp 的尺寸更新按subType分别传入imageId或 URL,避免把图片误当成 WebApp。 - Fabric 的直线工具此前创建的是普通
Line,拖动处理器却只查找没有设置过的_line自定义字段;修正后又通过事件级测试发现,拖动更新和初始object:added之间没有最终快照提交,松开指针时画布看起来正确但宿主数据仍是旧几何。现在鼠标释放时统一提交最终 JSON。箭头则改为以独立线段/三角头跟随指针,释放后才交给 FabricGroup自动计算子对象局部坐标,避免给组和绝对坐标子对象重复叠加起点偏移。 - 新增无头交互回归,使用真实 Fabric
Line、Triangle、Group几何对象和受控画布事件模拟,检查直线终态可序列化、箭头端点/箭头头位置不偏移、矩形最终尺寸被保存;pnpm test现为 9/9。pnpm exec tsc --noEmit --incremental false、严格pnpm lint(0 warning)及pnpm build均通过;构建仍提示大型分块。测试没有绘制真实浏览器 Canvas 像素,也没有安装到文档宿主或执行真实手势。 - 本轮只改本机插件源码与测试,没有压缩/安装插件、登录宿主、写入真实文档或发布插件。相邻的用户未提交修改均保留;宿主权限、嵌入生命周期、跨设备行为和实际画布渲染仍待验收。
验收矩阵
| 场景 | 验收结果 |
|---|---|
| 新建与恢复 | 空画布可创建;刷新后数据、画布类型和尺寸恢复 |
| 保存边界 | 连续编辑后只保存最新值;卸载、切换文档时末次修改不丢失 |
| 权限变化 | 只读状态禁止编辑;运行中撤销权限后不再写入 |
| 并发编辑 | 版本冲突可见且不静默覆盖其他编辑者数据 |
| 异常数据 | 错误 MIME、未知 schema、超限 JSON 和坏图片可安全拒绝并恢复 |
| 设备能力 | WebGL 不可用、窄屏、触屏和键盘操作都有可理解结果 |
| 体积性能 | 单引擎按需加载;大数据编码与保存不阻塞主要交互 |
源码显示一个真实插件已具备多引擎注册、动态加载、宿主只读状态和文档数据读写的基本骨架;本地已经补上保存队列串行化、卸载 flush 尝试、统一保存状态与失败重试,但 schema 迁移、数据体积限制、卸载失败后的恢复以及真实宿主环境验收仍是需要优先确认的工程项。本文不是线上安全或运行报告。
在线体验:浏览器本地白板
下面的白板演示把画布初始化、Pointer Events、撤销与导出串成一个可操作流程。它独立运行在当前页面的 Canvas 中,支持鼠标和触屏;笔迹不会上传,也不会调用原插件宿主接口。它用来体验文章讨论的画布交互边界,不代表 Excalidraw、Fabric、X6 或 Three.js 插件已完整移植到博客。
参考资料
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。