自研图编辑器Diagram-Design:Canvas渲染与数据模型设计全解析 diagram-design 这类项目我陆陆续续折腾过好几个版本。最早是在一个内部工单系统里画流程图后来帮一个团队做架构图展示工具再后来就是把它沉淀成一个相对独立的模块。说实话做图表设计工具最磨人的不是那层 UI 好不好看而是你拖一个框、拉一条线的时候背后的数据结构、渲染策略、交互状态到底是不是一套自洽的逻辑。最近整理手头代码发现这套东西已经比当初清晰太多所以干脆把完整的拆解思路和实操过程写出来给正在做、或者准备做类似工具的朋友一个参照。1. 整体设计与思路拆解1.1 这类工具的核心价值diagram-design 说白了就是让用户在浏览器里通过拖拽、连线、编辑文本拼出一张节点关系图。它可以画流程图、架构图、思维导图、UML甚至脑暴图。市面上成熟产品很多但它作为独立项目依然有存在的必要因为业务方往往会提一些专属需求比如私有化部署、自定义节点类型、导出专属格式、嵌入现有后台系统。我做的这个 diagram-design定位是一个纯前端的图编辑器内核。它不绑定任何后端数据输出为 JSON只要调用方把 JSON 存进自己的数据库下次加载时丢回来就能恢复画布。这个设计带来的优势是极大的可集成性无论你是 Vue 项目、React 项目还是上一代 jQuery 老系统只要把它嵌进一个页面通过标准接口传数据就能获得一个完整的在线绘图面板。1.2 技术选型的底层逻辑技术选型上我踩过一次大坑。最早那版我用的是绝对定位的 div 节点加 SVG 连线交互一多就崩因为每个节点都要监听一堆事件节点一多页面卡到没法用。后来重新设计时我定了几个硬性原则渲染层必须分层。底层画布与上层 UI 控件分离避免频繁重绘整棵 DOM 树。核心逻辑尽量框架无关。虽然我平时用 React但图编辑器这种重交互场景底层用原生 TS 写 core上层再用 React 包装会明显提升可控性。节点、连线、画布三类实体各自维护独立的数据结构但都挂在同一个 store 上用不可变更新来保证可回溯。我最终选的是 Canvas 2D 作为主渲染层。有人说 SVG 的交互好做、缩放不变形但真正到了几千个节点时SVG 的 DOM 节点数会成为压垮浏览器的最后一根稻草。Canvas 渲染色块和线条本来就是强项唯一的短板是拾取逻辑得自己做但这件事并不复杂后面我会详细讲。1.3 项目整体架构整个项目的模块划分我是照着四层模型来设计的第一层是数据层也就是 Model定义 Node、Edge、Canvas 的数据结构只负责描述和序列化。第二层是逻辑层也就是 Controller负责处理所有画布内交互行为包括选中、拖拽、框选、缩放、连接。第三层是渲染层也就是 Renderer把逻辑层的状态翻译成 Canvas 绘制指令例如绘制圆形、矩形、路径、文本。第四层是应用层也就是 UI包括工具栏、属性面板、右键菜单、快捷键绑定等。UI 只通过命令模式与逻辑层通信不直接修改数据层。这个分层带来的直接好处是如果将来想把渲染层从 Canvas 换到 WebGL或者加一个 SVG 导出模式只需在渲染层动手脚数据模型和交互逻辑完全不用变。在真实项目中我就是靠这一条迅速满足了老板“加一个导出高清 PNG”的需求。2. 核心功能与数据模型设计2.1 节点、连线、画布的数据模型有句话说得好数据模型设计得好后期功能全是复制粘贴设计得烂后期全在擦屁股。我这张图里的数据模型长这样export interface DiagramNode { id: string; type: string; // 节点类型 x: number; y: number; width: number; height: number; data: Recordstring, any; style?: { fill?: string; stroke?: string; strokeWidth?: number; cornerRadius?: number; }; } export interface DiagramEdge { id: string; source: string; // 源节点 id target: string; // 目标节点 id sourcePort?: string; // 源端口标识 targetPort?: string; // 目标端口标识 label?: string; style?: { stroke?: string; strokeWidth?: number; dashed?: boolean; }; } export interface DiagramModel { nodes: DiagramNode[]; edges: DiagramEdge[]; position: { x: number; y: number }; // 视口原点 zoom: number; }我坚持让节点的位置信息x、y直接放在顶层而不是塞进 data 里。这样做的原因是几乎所有的渲染和碰撞检测都会高频访问坐标字段放在顶层能减少解引用层级另外一个很实际的好处是序列化后的 JSON 更扁平给后端存储做索引也更高效。连线的设计我特意用了 source 和 target 的 id 引用而不是把节点对象嵌套进去。这样既保证数据引用关系清晰又避免了一次修改节点位置后需要同时更新无数条连线的坐标字段。连线上的文字我用一个可选的 label 字段默认渲染在线的中点上方。2.2 为什么选 Canvas 而不是 SVG 或 DOM这是个日经问题。为了做成决策我简单列了一个对比表基本可以作为通用参考维度Canvas 2DSVGDOM 方案渲染性能高适合几千节点中节点多时卡顿低不适合大规模图拾取命中需自行实现自带事件绑定自带事件绑定样式灵活性极高高但受 SVG 规范约束依赖 CSS 能力导出位图天然支持需转图较麻烦缩放处理需重绘矢量不变形容易模糊对我而言最关键的还是渲染性能。一个架构工具在打开一个有 1500 个节点的项目时如果拖动画布都一顿一顿那用户体验基本归零。Canvas 的另一个优势是导出图片特别顺手调用一个 canvas.toDataURL() 就能生成 PNG完全不用借助外部的 DOM 克隆库。当然Canvas 的短板我也吃过大亏。它没有 DOM 节点所以你不能直接给某个节点绑定 click 事件。我的方案是在 render 的时候把所有节点的包围盒额外记录到一份空间索引中每次 mouse event 触发时用鼠标坐标去查索引命中哪个节点就给哪个节点派发事件。这个工作量大概多写两百行但对性能几乎没影响。2.3 状态管理与不可变更新图编辑器的状态管理不建议把每个节点都塞进全局 store 的深层次对象里然后到处触发“更新 $deep”这种操作。一旦节点数量大整包替换对象的不可变操作成本会非常高造成渲染抖动。我采用的是“store 中保存一个大对象 变更时生成新对象”的模式这样任何时刻撤销栈里只记录一次变更差异而不是快照整个画布。举个例子拖拽一个节点时连续触发 60 帧 mousemove如果不做合并撤销记录里会塞进 60 条几乎一样的记录。我的做法是拖拽开始时创建一个 transaction把整个过程记录为一次变更结束时只提交一条“移动节点 {id, from, to}”的记录。这样哪怕用户拖着节点转了一圈再放手撤销也只撤销一步。这个不可变更新的核心是一个纯函数function updateModel(model: DiagramModel, patch: PartialDiagramModel): DiagramModel { return { ...model, ...patch }; }访问节点用的是model.nodes.find(n n.id id)修改节点时返回nodes.map(n n.id targetId ? { ...n, ...patch } : n)。这套写法配合 React 的 useSelector 做依赖比较性能相当稳。3. 画布交互机制与实操实现3.1 视口变换与坐标转换任何图编辑器都有两套坐标系画布坐标即数据中的世界坐标和屏幕坐标浏览器视口坐标。为了支持缩放和平移我维护了一个 viewport 对象里面有 offset 和 zoom。屏幕坐标与画布坐标的换算关系很简单const screenX (canvasX - viewport.offset.x) * viewport.zoom; const screenY (canvasY - viewport.offset.y) * viewport.zoom;反过来鼠标落点换算回画布坐标const worldX screenX / viewport.zoom viewport.offset.x; const worldY screenY / viewport.zoom viewport.offset.y;最开始我偷懒用 DOM 的 CSS transform 直接做缩放和位移看起来轻松但一旦画布上出现文本字体渲染就会被缩放弄得发虚。后来改成在 Canvas 绘制逻辑里统一先调用ctx.translate(offset)再调用ctx.scale(zoom)保证所有图形和文字都按矢量方式绘制任意缩放下都清晰锐利。绘制伪代码function render(ctx: CanvasRenderingContext2D, model: DiagramModel, viewport: Viewport) { ctx.clearRect(0, 0, viewport.viewportWidth, viewport.viewportHeight); ctx.save(); ctx.translate(-viewport.offset.x, -viewport.offset.y); ctx.scale(viewport.zoom, viewport.zoom); // 绘制连线 model.edges.forEach(edge drawEdge(ctx, edge)); // 绘制节点 model.nodes.forEach(node drawNode(ctx, node)); ctx.restore(); }有点需要注意在拖动或缩放过程中不要在 mousemove 回调里每次都跑全量渲染。常见的优化手段是使用 requestAnimationFrame 合并渲染帧。我的实现里有一个requestRender()函数它默认设置一个dirty标记并在下一帧统一执行 render。这样即使一秒钟触发 60 次 mousemove实际绘制次数也不会超过屏幕刷新率。3.2 节点拖拽与连接交互节点拖拽是整个编辑器里最基础也最容易出 bug 的功能。我做的流程是鼠标按下时判断命中了哪个节点。如果命中记录起始坐标和节点初始位置。鼠标移动时根据当前坐标与起点的偏移量计算出目标位置用clone map的方式更新节点数据。鼠标松开时提交一次变更记录。这里有一个我踩过多次的坑不要直接用 mousemove 的clientX/clientY作为位移量。因为当鼠标移出画布再移回来或者鼠标移入了 iframe坐标参考系会乱掉。我在实现中每次取坐标前都会先尝试用event.offsetX / offsetY相对于 Canvas DOM 元素作为基础值如果拿不到再用 getBoundingClientRect 换算。只有基于同一参考系的差值才是可靠的。连接线的交互比节点拖拽更复杂一些。用户需要从节点的某个连接点按住鼠标拉出一条临时线拖到目标节点上方时松手。我的做法是维护一个pendingEdge对象内容只有 source 和临时终点坐标。每次渲染时看到有 pendingEdge 就额外画一条虚线并实时更新终点坐标跟着鼠标走。如果终点命中了某个节点就高亮该节点的所有连接点松手后创建一条真实的 DiagramEdge。过程中要特别留意命中范围的设计。连接点Port的可点击区域不能太小否则用户很难对准。我通常会让可交互半径比视觉半径大 4~6 像素本质上就是给了一个容错缓冲区。3.3 框选与多选逻辑多选用的也是 Canvas 命中检测。最常见的交互是在空白处按下鼠标拖出一个矩形选区选区范围内的节点被标记为选中。实现上选区是一个屏幕空间内的矩形但节点的位置存储在画布空间坐标中。所以在做包含判断时需要把选区的屏幕坐标换算成画布坐标再与节点包围盒做相交测试。这个细节如果一开始不做后面很容易出现缩放到 200% 时明明看着选中了但实际选不中的诡异问题。选区命中判断的代码function isNodeInRect(node: DiagramNode, rect: {x: number; y: number; width: number; height: number}) { return node.x rect.x rect.width node.x node.width rect.x node.y rect.y rect.height node.y node.height rect.y; }命中之后多选状态下选中的节点集合我统一用一个Mapstring, DiagramNode来保存前端 UI 里展示属性面板时可以支持批量修改颜色、字体、位置等属性。4. 渲染层细节与导出功能4.1 节点外观与连线路径的自定义能力节点不能只画一个矩形那样太单调。项目中我会给节点定义类型字典每种类型对应一套绘制函数。比如“开始”节点画圆角矩形“判断”节点画菱形“数据库”节点画圆柱柱体。每个绘制函数都接收一个 DrawingContext 对象里面包含 Canvas 上下文、节点数据、缩放级别等信息。为了让用户能在界面上自定义样式我把节点的颜色、边框、粗细、圆角都默认映射到 style 字段。不传 style 时按照节点类型查一个默认主题保证画布出来的效果是统一的。如果用户改了某个节点样式那条记录里显式携带 style渲染时优先采用。连线路径我支持两种直角折线Orthogonal和贝塞尔曲线Bezier。实现正交连线时我用了一个manhattan函数计算中间点。简单来说就是先计算源节点和目标节点的中心点如果水平距离大于垂直距离就画一条水平线到中间点再竖向走到目标水平线最后横向走到目标。当然真实逻辑还会考虑绕开节点但基本盘的折线实现已经能满足 80% 的场景。下面是我用了很久的正交连线简化版function manhattanPath(source: Point, target: Point): Point[] { const midX (source.x target.x) / 2; return [ { x: source.x, y: source.y }, { x: midX, y: source.y }, { x: midX, y: target.y }, { x: target.x, y: target.y }, ]; }需要注意的是如果源节点与目标节点的垂直中心线离得非常近折线就会出现重叠或交叉。这种情况我在实际代码里做了个偏移量校准让中线稍微偏一下视觉上会好很多。4.2 小地图Minimap实现小地图是专业工具里很难缺的一个部件它让用户快速看到当前视口在大画布中的位置。实现其实不复杂我是把整张画布按比例缩小渲染到一个小 Canvas 中然后叠加一个表示视口的矩形框。小地图关键点在于它不用高频更新因为节点的位置不会随时变化只有拖拽和缩放时才需要更新。我是在“画布静止超过 500ms”后统一渲染一次小地图这样避免了每次 pan 都重新绘制的性能开销。小地图框选定位也是能快速上一个档位的功能。它本质上就是把小地图上的点击坐标换算成大画布坐标然后直接平移视口。换算公式同样是缩放系数的逆运算。4.3 导出 PNG、SVG、JSON 的完整方案导出是我这边需求最常变的地方。先是要求导出图片然后要求导出 SVG后来甚至要求导出 PPT 可以打开的格式。我的策略是导出函数全部做成纯函数输入 DiagramModel 和导出选项输出所需内容。导出 PNG我首先设置一个离屏 Canvas尺寸按画布所有节点的总包围盒计算然后按照同样的渲染管线把内容绘制上去最后走toDataURL(image/png)。这里有个容易漏的细节如果原始画布经过了缩放用户希望导出的是 1:1 的清晰版本导出时要把 ctx.scale 设置成 1并按节点实际大小重算像素尺寸。导出 SVG 的方案采用的是把节点和连线翻译成 SVG 元素。把 Canvas 绘制逻辑换成 SVG 绘制逻辑其实挺容易因为 Canvas 的ctx.fillRect和 SVG 的rect是一一对应的。我写了一个适配器函数遍历所有节点和边输出 SVG 字符串再通过 Blob 下载。这种格式导出后可以在浏览器里直接看也可以插入文档工具里再编辑。JSON 导出就是直接把当前的 DiagramModel 序列化导出前稍微做一下清洗比如去掉空字符串字段。导入 JSON 时注意做数据校验比如字段类型是否为 number、id 是否重复避免错误数据直接拖垮画布。5. 性能优化与兜底策略5.1 大规模图渲染的瓶颈分析与取舍图编辑器最容易翻车的就是节点数量上来了。在我优化之前画布渲染 1000 个节点都费劲。分析 Chrome Performance 面板后发现主要问题出在两处一是每帧都创建新的 Canvas 渐变对象垃圾回收压力大二是绘制文本时浏览器会做大量重排版和字体解析。对应优化方案有这几招节点样式尽量用纯色填充少用阴影或渐变。真要阴影就在节点数量少于 300 时才开启。文本缓存。把每个节点的文本先绘制到一个离屏 Canvas 上绘制主画布时直接drawImage把文本块贴上去。字体只解析一次后续只是位图拷贝性能提升非常明显。视口剔除。只绘制当前视口范围内的节点视口外的节点直接跳过。这个要点其实实现成本很低收益却很大。视口剔除逻辑function isNodeVisible(node: DiagramNode, viewport: Viewport) { const left viewport.offset.x; const top viewport.offset.y; const right left viewport.viewportWidth / viewport.zoom; const bottom top viewport.viewportHeight / viewport.zoom; return !(node.x node.width left || node.x right || node.y node.height top || node.y bottom); }5.2 常见卡顿场景的排查方法拖拽时卡顿通常是渲染层每次都在执行耗时操作。建议先按 F12 打开 Performance Recorder记录一次拖动操作看看主线程时间都耗在哪里。很多时候你会发现大量时间花在 React component 的 render 而不是 Canvas 绘制上因为属性面板里某个字段的改动触发了整棵组件树更新。这种状况的解法是画布组件使用 memo确保没有相关的状态变化时不重渲染。节点列表不直接传给画布组件而是传一个“节点版本号”版本号变化时才触发重绘。属性面板的输入框尽量 onBlur 时再提交不要每次 onChange 都更新 store否则输入过程中也会频繁触发画布重绘。我曾经在用户拖拽一个大节点的时候旁边属性面板实时回显坐标。每次输入框数字变化都要引发一遍 React 全量 diff导致拖拽掉帧。最后改成拖拽结束才统一更新属性面板流畅度立刻上了一个台阶。5.3 撤销重做与历史记录的稳妥实现撤销重做模块是编辑器里最容易被小看的一个部分。我的实现思路比较朴素就是用两个栈一个 undo 栈、一个 redo 栈。每次执行变更操作时先 push 变更记录进 undo 栈清空 redo 栈。undo 时弹出一条变更记录反向应用同时 push 到 redo 栈。这套方案里关键是变更记录怎么写才能简洁且可逆。比如“新增节点”的反向操作是“删除该 id 的节点”我定义成type Change | { type: addNode; node: DiagramNode } | { type: removeNode; nodeId: string } | { type: moveNode; nodeId: string; from: Point; to: Point } | { type: updateNodeStyle; nodeId: string; from: Recordstring, any; to: Recordstring, any };applyChange 和 revertChange 两个函数通过 switch 处理不同类型。由于是不可变数据模型实现起来非常顺手都不用担心破坏原始对象。有一个小提示撤销栈需要设置上限比如最多保留 200 条。不然用户长时间编辑后堆了上万条记录虽然单条不大但全量保留也容易撑爆内存。6. 踩坑实录那些测试时才暴露的隐蔽问题6.1 拖拽坐标偏移Canvas 边距与 CSS 缩放第一个隐蔽问题大家基本都会遇到。Canvas 元素如果通过 CSS 设置了max-width: 100%或外层容器加了 padding那么鼠标事件的 offsetX 与画布实际像素坐标就会产生偏移。特别是 Retina 屏幕的设备Canvas 的实际尺寸和 CSS 尺寸不一致取到的坐标会直接错位。我的解决方案是初始化时做一次设备像素比适配const dpr window.devicePixelRatio || 1; canvas.width cssWidth * dpr; canvas.height cssHeight * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);同时取鼠标坐标时使用event.offsetX但如果 Canvas 的 CSS 尺寸与内部逻辑尺寸不一致需要乘一个倍率。所以每次 resize 后我会重新计算一个scaleX和scaleY在命中检测时乘上去。6.2 文本输入弹窗导致的焦点丢失这个坑是在节点内编辑文本时发现的。我用一个绝对定位的 textarea 覆盖在节点上方用户输入时textarea 获得焦点。但一旦用户滚动滚轮缩放画布textarea 的位置如果没跟着更新它就会停留在原地输入的内容却对应的节点移动走了错位到离谱。我的解决策略是给滚轮事件绑定一个 throttle 处理在缩放开始时先把 textarea 的值写入节点数据然后隐藏 textarea等缩放结束后再重新定位并显示。这套写法的体验虽然不是实时跟随但至少不会丢焦点、不会错乱。比较好的实践是把输入编辑状态设计成一个显式的“editing 节点 ID”字段。只有处于编辑模式的节点才显示 textarea其他情况下画布保持纯 Canvas 渲染。这样焦点冲突和渲染冲突都降到最低。6.3 导出图片背景全黑的诡异问题导出图片时最莫名其妙的一个现象是导出的 PNG 背景是黑色。其实原因很简单离屏 Canvas 本身是透明背景但在 toDataURL 之前如果没把背景填充为白色直接导出时会保留透明通道。很多看图工具对透明通道的显示是黑底看起来就像一张黑图。解决方式是在导出前先用白色填充一遍离屏画布exportCtx.fillStyle #ffffff; exportCtx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);我顺便把导出选项设计为可传入背景色这样用户以后如果想导透明底图做素材也能一键切换灵活处理。7. 架构设计经验总结这段算是我最想分享的踩过不少坑攒出来的经验。如果让我重做一次 diagram-design我会把下面几条列到开发排期最开始的位置第一数据模型与 UI 彻底解耦。节点、边、画布状态都必须有独立的类型定义序列化和反序列化要做到不依赖任何 UI 组件。这一步做好后期加右键菜单、加多选、加批量操作都不太会推翻重来。第二渲染管线设计成纯函数。输入 model 和 viewport输出画面。不要有任何外部状态。这样导出、打印、截图全部复用同一条渲染路径。第三事件系统统一走“命令注册 分发”模式。所有画布内交互都注册为命令比如 dragNode、selectAll、addNode。命令可以被快捷键、右键菜单、工具栏按钮共同复用。否则每个功能入口自己写一套逻辑后面维护成本翻倍。第四交互状态机不能乱。我在内部维护了一个 stateMachine执行任何操作前先判断当前状态是否允许。比如拖拽节点时不允许再画贝塞尔线缩放过程中不允许框选。状态机虽然写起来啰嗦但能让交互流程非常可预测。第五要留后门。为未来可能的 WebGL、Worker 渲染、多人协同都要预留接口。数据层只保存纯 JSON不做任何 DOM 依赖渲染色块与业务逻辑完全隔离将来即使换引擎也只是替换 renderer。8. 常见问题速查表为了方便排查我把日常被问得最多的问题整理成一张速查表覆盖了画布、导出、性能等主要模块问题现象可能原因解决方案画布空白但节点存在视口偏移或缩放异常检查 viewport offset 是否为合法数值调用 resetView() 恢复拖拽节点后位置跳跃坐标参考系不一致确保统一使用 Canvas 内部坐标换算时不要混用 clientX文字模糊Canvas 未适配 DPR设置 canvas.width 为 CSS 宽度乘 devicePixelRatio并调用 setTransform连线位置错乱节点坐标更新时未同步更新边连线的坐标应在渲染时即时通过节点 id 查最新节点位置导出图片背景黑色未填充背景色导出前用背景色填充离屏 Canvas大量节点时拖动卡顿没有做视口剔除或频繁创建对象增加视口剔除逻辑减少渐变和阴影文本走离屏缓存撤销把整个画布回滚变更记录粒度太大改为记录最小操作差异如 move 记录 from 和 to鼠标滚轮缩放不自然缩放中心固定为原点计算鼠标位置围绕鼠标所在位置做锚点缩放这张表并不全面但覆盖了我实际运行中反复遇到的大部分问题。真正排查时建议还是先用 Performance 看主线程时间线再按模块定位不要凭感觉瞎猜。9. 实测数据参考我的项目在本地测试环境里节点数量在 2000 时全量渲染一帧的时间约 12~18ms加上拖拽时 requestAnimationFrame 的调度体感基本流畅。如果打开节点文本缓存帧时间还能降到 8ms 左右。真实业务里通常单图也不会超过几百个节点所以这个表现已经完全够用。动态拖拽时单个节点的命中检测耗时在 0.1ms 以下因为我把包围盒存储成了扁平数组并用简单的二维范围过滤。400 个连接点的边界检测也能在 1ms 内完成不需要引入复杂的四叉树。但如果节点数超过 5000建议还是先做网格索引或四叉树避免线性的全量遍历。另外导出高分辨率图片时如果设计稿长宽很大要注意浏览器单个 Canvas 的面积限制。浏览器对 Canvas 的宽高有上限超过后图片会空白或直接报错。微信那种巨长图导出我建议把整幅图切成多块 Tile 分别导出再在服务端或 canvas 拼接。10. 后续扩展方向与一点个人体会开发完这套 diagram-design 之后我又在上面加了不少插件比如基于节点 type 自定义渲染、快捷键复制粘贴、导入外部 JSON 自动布局、框选后批量对齐等。这里我最大的体会是设计“合理的数据模型”比写“好看的 UI”重要太多。数据模型抽象到位新的功能基本就是往上面堆组合而不是重构。我之前也想过要不要接入 AntV X6 或 React Flow 这样的成熟库坦白说如果团队只是为了做一张简单的架构图确实没必要重复造轮子。但如果你是希望图表编辑器作为核心产品的一部分并且深度集成到自己的业务流程里那自研内核反而更可控。成熟的库会替你解决通用交互但当你需要改交互逻辑、增加定制类型、混入私有算法时它的封装会变成沉重的负担。如果现在要从零开始重做这个项目我大概率会把网络通信和多人协同放到更前期设计里来因为冲突处理和合并策略一旦后期再补牵一发而动全身。但从目前这套架构看数据层的纯 JSON 设计已经为多人协同留好了良性基础后续要做的基本就是给每个变更操作加一个 op id 和时间戳再用广播方式同步就行。拿这段经验来说做类似工具前不妨先花三天把数据模型定死、把交互状态机画出来。后面省下的返工时间可能会是这三天的十倍都不止。