图表设计这个话题,很多人第一反应是"直接用现成的库不就行了",但真当一个项目需要深度定制交互和视觉时,现成方案往往成了最大的约束。这篇文章从零搭建 diagram-design 项目的完整过程,包括技术选型的真实权衡、数据模型设计、画布交互实现、自动布局、序列化导出以及性能优化这几个核心环节,每一部分都会讲清楚"为什么这么做"和"实际踩到过哪些坑"。适合准备做可视化编辑器、图形建模工具,或者想深入理解绘图工具底层实现的人参考。
1. 为什么自己从零搭一套 diagram 引擎,而不是直接用现成方案
先交代一下这个项目的背景。当时我在做一个面向工业设备运维的数据图表面板,用户需要把设备、传感器、控制逻辑画成一张可交互的拓扑图,同时希望节点能拖拽、连线能编辑、整体能缩放平移、不同角色能看到不同粒度的信息视图。市面上成熟的开源产品和商业工具不少,我也认真评估过,但我最终决定把基础交互层全部自己实现,只保留了一些渲染层面的工具函数。
做出这个决定基于三点考虑。其一是视觉和交互的定制深度。现成框架会提供一套完整的默认行为,但往往意味着你只能在它的配置项范围内"填写"。用户需要的拖拽吸附、锚点高亮、连线走向记忆这类体验,如果停留在配置层,永远没法做到位。其二是数据模型需要贴合业务。设备运维图表的节点不是简单矩形,每个节点携带状态枚举、告警等级、实时指标值,连线也不是简单箭头,而是带有方向性、带宽、甚至链路延迟语义。这些业务语义需要深度耦合在数据层,如果底层模型是通用图模型,上层要反复转换,架构会越来越别扭。其三是导出和协作需求。用户需要把图保存为结构化 JSON 下发到其他系统,也需要导出成标准图片用于报告,如果依赖庞大框架,序列化格式完全受制于人。
这倒不是一概否定现成库,实际情况是,如果你的项目就是标准流程图、脑图,或者原型验证,拿现成方案是效率最高的。但如果目标是做"有业务灵魂的编辑器",自研基础层反而是捷径。我当时评估的一个关键指标是"每秒帧率和节点数量"——自研 SVG 渲染器在 2000 节点以内表现稳定,而通用框架在 800 个节点左右就开始出现拖拽卡顿。这个数据直接影响了我的决策。
2. 数据结构建模:把图纸变成可计算、可存取的模型
diagram-design 的核心不在一笔画得多漂亮,而在数据模型怎么设计。整个引擎我分成三层:模型层、渲染层、交互层。模型层是纯 TypeScript 描述的结构化数据,不依赖任何 DOM;渲染层把模型映射成坐标和样式;交互层处理用户的鼠标键盘行为,再回写模型。三层互相解耦,这是整个项目能持续迭代的基础。
2.1 节点、连线、锚点:三类基础对象的设计
模型层最基础的是节点、连线和锚点。节点需要记录 id、类型、坐标、尺寸、样式和业务数据。坐标我用的是"相对画布坐标",也就是逻辑坐标,不是屏幕坐标,因为画布能缩放平移,两者不相等。尺寸统一用矩形宽高,即使将来做多边形节点,也要给一个最小外界矩形,很多布局算法和碰撞检测要靠它。
连线是模型层最大的坑。连线需要存储两端连接的节点 id 和锚点 id,而不是简单存首尾坐标,因为节点被拖动时连线端点必须跟着移动。如果存坐标,就需要每次手动同步,很容易漏掉。连线路径本身用折线模型,记录经过的若干路由点,每个路由点都是相对画布坐标。贝塞尔控制点不存进数据模型,而是渲染时实时计算,因为控制点随着锚点位置变化而变化,存下来很快就失效了。
锚点挂在节点下面,每个节点有若干个锚点。锚点是连线真正附着的位置,它相对于节点有固定偏移,比如矩形左中右可以各放一个。这里有个细节:锚点必须拥有全局唯一的 id,不能只存"节点 id + 索引",因为某些场景下锚点顺序会被调整,索引不稳定就会导致连线错乱。锚点还携带着方向向量,向量用于判定连线出口方向,决定起始端点的走向。
这三个对象是核心,但实际搭建起来还需要一个 GraphModel 容器。容器维护三张表:节点表、连线段表、锚点索引表。锚点索引表是一个 Map,key 是锚点 id,value 是它所属节点和偏移量。这样查询"某根线连到哪个锚点、锚点画在哪里"都能 O(1) 完成。
2.2 用纯 JSON 驱动的画布:为什么拒绝把状态散落到组件里
刚开始做时,我踩过一个很不好的坑:把节点位置、缩放、选中态直接放在组件内部 state 里。几十个组件分散更新,互相通信靠事件总线,改数据后经常出现组件状态同步不一致,缩放画布时甚至出现节点文字漂移好几秒的诡异现象。
后来我把整个画布改成"纯 JSON 驱动"。应用层维护一个不可变的 AppState 对象,包含所有节点的位置、连线、选中 id、缩放级别、视口平移量。用户任何交互都走统一的 reducer:dispatch 一个行为,比如 MOVE_NODES、ADD_LINE、SELECT,由 reducer 生成新的 state,再交由渲染层全量 diff。这样的好处是撤销重做变得非常自然,因为历史记录就是 state 快照的数组。坏处是如果每次交互都生成全量新对象,大图的 GC 压力会很大。
为了让 diff 高效,节点表我采用了 keyed 集合存储,用 Map 而不是普通对象数组。Map 能保持插入顺序,遍历时稳定,改单个节点时只用替换 Map 中对应项,引用没被修改的节点保持原位。这样渲染层的 diff 算法可以快速跳过没有变更的对象。数据模型要永远保持可序列化,所有字段要么是字符串要么是数字,不能存函数、类实例或者 DOM 引用。这样把 state 发送到别的机器重新还原成画布时,不需要任何额外处理。
2.3 坐标系的换算:逻辑坐标、屏幕坐标和渲染层的关系
diagram 系统里很多 bug 都出在坐标换算上,所以我有必要单独梳理。画布有一个视口概念,相当于一个"镜头",它有两个参数:scale 缩放倍数和 translate 平移偏移。逻辑坐标是用户感知的图纸坐标,屏幕坐标是鼠标实际点击的位置。换算公式是:
屏幕坐标 = 逻辑坐标 × scale + translate
反推则是:
逻辑坐标 = (屏幕坐标 − translate) / scale
鼠标事件拿到的坐标是相对容器的屏幕坐标,我需要先按上面的公式转成逻辑坐标,再去和节点矩形做碰撞检测。有一个参数特别容易漏——CSS 里的 border 和 padding。如果容器的内容区和视口不是完全重合,事件 offsetX 的基准会偏,我直接在容器的内容区外包一层 div,事件全部在内容区上监听,把边框和 padding 全部去除,彻底绕开这个问题。
另一个容易忽略的是设备像素比。高清屏上如果直接用 CSS 像素绘制 SVG,边缘会发虚,但这个问题要用 viewBox 解决而不是手动缩放坐标。SVG 的 viewBox 保证所有矢量图形在缩放时依然锐利,逻辑坐标不受影响,这条经验让我少写了二十行坐标补偿代码。
3. 渲染层的自研实现:SVG 为主、Canvas 为辅的分层渲染架构
第一版我试过全用 Canvas 渲染节点和连线,因为 Canvas 的绘制性能在节点数量大时优势明显。但后续发现开发效率太低:Canvas 没有 DOM 节点概念,所有命中检测都靠自己写,文本框的输入处理也特别麻烦。后来调整为 SVG DOM 渲染为主,Canvas 只用来做超大规模节点场景下的辅助图层。
3.1 SVG 为什么够用,什么情况下必须切换 Canvas
在 90% 的场景下,单屏节点数量在 1000 以内,SVG 的性能完全够用。SVG 天然支持事件委托、样式热更新、光标形状变化、CSS 过渡动画,这些特性用于编辑器交互开发非常爽。我可以直接给某个
当单屏节点超过 1500 个时,SVG 的 DOM 节点数量开始拖慢渲染和事件响应。性能瓶颈主要不在绘制,而在布局和事件命中。这个时候我用了一个折中方案:只有一个节点图层和一个连线图层,保持整个 SVG 的 DOM 数量不超过 3000,再多出来的节点放在 Canvas 里画。Canvas 图层只负责静态背景绘制,交互仍然由上层透明的 SVG 事件层接管。用户点击时先用数学几何检测命中了哪个模型对象,再更新状态触发重绘。这样既保证了交互一致性,又压低了 DOM 开销。
这个方案具体做法是三层结构:最底层是静态 Canvas(地图底纹、大网格)、中间是 SVG 主图层(节点、连线和标签)、最上层是一个透明 SVG 事件层。事件层没有图形元素,只有三个透明 rect 覆盖全画布,分别负责拖拽空白平移、拖拽画布选中框、以及后续的框选命中。所有事件通过一个中心派发器转发,避免一个个元素绑监听。
3.2 节点样式渲染与连线路径计算的数学细节
节点渲染我推荐做两层组合:底图形状和顶部状态层。底图形状一般是 rect 或者 path,状态层包括选中描边、hover 高亮、告警闪烁。状态层的颜色变化通过 CSS 变量控制,而不是直接修改 fill 属性。这样比如切换黑暗模式时,根节点换一个 CSS 变量,全图节点自动统一响应,不用每个节点单独更新。
连线路径计算是这个项目里最需要耐心的部分。直线连接好说,但如果节点之间有多个连线,或连线需要避开其他节点,路径就不只是两点一线了。我实现了一种按端口方向确定贝塞尔控制点的方法:起始点和终点各带一个方向单位向量,控制点 = 端点坐标 + 方向向量 × 弯曲系数。这个弯曲系数我默认取两个端点曼哈顿距离的三分之一,然后 clamp 到 40 到 200 之间,实测效果比较自然。
有向连线还需要在终点附近画箭头。箭头不能简单放在终点坐标,因为终点往往在锚点附近,箭头会被节点边框盖住。正确的做法是先算出终点方向向量,让箭头沿向量向外偏一点,偏移量是箭头长度的一半再加 2 像素。这个偏移动画如果做成手感,拖拽时箭头会沿锚点周围滑动,看起来非常高级,实际代码只有十几个判断分支,并不复杂。
3.3 文本渲染:比想象中容易出问题的小环节
文本是图表设计里低频但烦人的部分。文本块需要支持换行、字号缩放、被选中后能编辑。SVG 的 text 元素并不擅长自动换行,我用 tspans 方案手动切行。麻烦在于测量一行文本的宽度,SVG 没有直接 API,我的做法是给它加一个共享的 hidden text 元素,用 getComputedTextLength 获取像素宽度,然后通过二分切割找到当前行能容纳多少个字。这个方案在只有少量标签文本时性能完全没问题。
文本缩放有个视觉问题:画布缩放时文本如果跟着整体缩放,字号小于 12px 后就会变得又小又糊。业界惯用做法是"恒指文本",即文本始终面向屏幕保持固定字号,这个在 SVG 里用向量效果实现,渲染时把 text 元素的缩放转置回去,但位置仍然按逻辑坐标计算。我做的是阈值切换:缩放倍率低于 0.5 时显示简化标签,缩放到 0.3 以下时用符号替代文本。节省渲染 mtime 就不用管了。
文本编辑用了一个隐藏的原生 textarea 控件,当用户双击文本块时,在块的位置叠加一个透明 textarea,失去焦点后把内容回写模型。这里的坑在于 textarea 的字体应该和文本块的 font-family 完全一致,偏差哪怕一像素都会导致光标错位。
4. 交互系统的实现:拖拽、框选、缩放、吸附,一个都不能少
交互是图表编辑器最体现"韧性"的部分。很多人以为拖拽就是 mousemove 里更新坐标,但这个简单逻辑背后有很多需要约束的细节,比如节点拖拽时需要连线的端点跟着移动、其他节点是否碰撞、关键时刻是否吸附到锚点等等。
4.1 拖拽节点时,连线和锚点如何联动刷新
拖拽节点的基础逻辑:mousedown 时记录起始逻辑坐标和节点原来位置,mousemove 时计算出位移量,把选中节点集合整体平移。注意位移量必须是逻辑坐标差,不是屏幕像素差,否则缩放时拖动速度会错位。监听事件时我把 mousemove 和 mouseup 注册在全局 document 上,而不是容器上,因为鼠标移出容器时仍需继续跟踪,直到抬起才结束拖拽。
拖拽过程中,每条连接该节点的连线端点要即时更新。我的实现将"连线端点位置"始终从"锚点当前坐标"推导,渲染阶段实时调用一个 resolveLineRoute 函数。所以拖拽节点发生时,只要节点坐标更新了,连线自然会跟着走,没有任何额外事件通知。这里最大的隐藏问题是拖拽性能和路由重算次数。每条线都重算贝塞尔控制点是可以接受的,但如果连线数量上千,高频 mousemove 下依然会卡。优化方案是拖拽期间只更新起点终点,线碰撞检测在 mouseup 之后才做,不让用户在拖拽中等待重算。
还有一个细节是群组拖拽。节点集合整体平移时,如果某个节点同时被框选和与另一个节点有连线关系,连线两端的节点都在选中集合内,连线不参与平移,但端点坐标会动态跟随各自锚点,所以整体图形保持连接关系不断。实现上我不需要为连线做平移特判,只需要"连线端点永远从锚点取坐标"这一个原则。
4.2 框选命中:如何区分"点击选中"和"拖拽框选"
框选是最容易误触发的操作之一。按下鼠标—移动—松开,如果位移量超过 4 像素,就视为框选,否则视为点击选中。这个阈值很重要,太小会误触,太大则框选小对象时失灵。我取的是 4px,实测下来比较稳健。
框选命中分两种:包含式和交叉式。包含式是节点矩形完全落在框内,这个适合精确选择;交叉式是只要节点和框有交集就算选中,适合快速扩大选择范围。我默认使用包含式,但按住 Shift 时切换为交叉式。框选时框本身是矩形,画出来是一个浅蓝半透明矩形加蓝色描边。实际工程里,框选矩形会归一化处理,因为鼠标可以向左上方向拖,rect 的宽高不能是负数。
框选时对连线的处理,我一开始没考虑,后来发现用户框选后其实更希望"把经过区域内的连线也一并选中"。这个有点反直觉但很实用。于是框选完成后,我额外做了一步:遍历所有连线,判断连线的折线路由点中是否有任意一点落在框内,有则加入选中集合。线选中和节点选中用不同的视觉样式:节点是高亮描边,连线是变色加粗。
4.3 画布缩放和平移的视口计算
画布缩放通过滚轮控制,平移通过拖拽空白区域或按住 Space 拖拽完成。缩放有个很重要的细节:"以鼠标所在位置为中心缩放"。如果直接简单 scale=scale*delta,视口会默默偏移,用户会觉得画布在往里"钻"。实现上需要做两步:
视口视口 = 先记录鼠标在逻辑坐标的位置 p1,然后更新 scale 和 translate,使得变换后鼠标对应的逻辑坐标仍为 p1。术语上叫缩放锚点。代码里就是 transform 公式的逆推,先把鼠标屏幕位置转成逻辑位置,再根据新的 scale 计算 translate = 鼠标屏幕位置 − 鼠标逻辑位置 × scale。这样就稳定了。
平移我用一个平移模式:进入平移状态时,记录按下时的视口 translate 和鼠标起点屏幕坐标,mousemove 中 translate = 起点 translate + 当前屏幕坐标 − 起点屏幕坐标。这里不需要乘 scale,因为 translate 本身就是屏幕像素级别的偏移。
缩放限制范围我取 0.2 到 5 之间,低于 0.2 容易让文本无法辨认,高于 5 像素化严重。限制范围内还做了惯性结束——鼠标滚轮停止后,如果之前连续滚动速度较快,会在 150ms 内继续缓慢逼近目标 scale,这个手感很多人不留意,但真做了差别很大。
4.4 连线过程中的锚点吸附与方向判别
连线工具交互流程:单击起始锚点,进入画线模式,鼠标经过目标节点时高亮可用锚点,松开完成连线。做画线模式时,最难的部分是判定"鼠标当前到底吸附了哪个锚点"。我维护了一个"当前热点锚点"的概念:鼠标移动时遍历附近所有节点的锚点,找到距离阈值内最近的那个作为吸附目标;没有就置空。阈值设成 24 像素逻辑距离,这个值我调过很多次,太大会吸附过远锚点,太小用户要精确点到顶点附近。
锚点吸附完成后,连线起点方向向量是从锚点指向节点中心方向的反方向,终点方向向量则是从终点锚点指向鼠标当前位置方向。这样连线出端口时方向自然,不会出现横穿身体的情况。连线过程中实时渲染的是"临时预览线",它不是真正的连线数据,只是在画布上加了一层临时 path。松开鼠标时,把这个预览线转成真实的连线模型,插入 GraphModel。
这里有另外一个坑:用户可能在空白处松开,那就不该生成连线。预览线需要有一个全局状态线是否处于可完成状态,在绑定 mouseup 时判断命中锚点是否为真。同时按住 Esc 应该取消本次画线状态,还原所有高亮。
5. 自动布局与智能整理:从手排图纸到算法辅助编排
用户手拖完的图往往乱,所以买了编辑器的功能的人一定会期望"帮我自动排一下"。这是图表工具另外一个高价值的真正核心点。自动布局算法我实现了一个相对朴素但非常好用的层次化布局器,不追求 80% 场景的完美,而追求 99% 场景的"看得过去,不重叠,连线短,方向一致"。
5.1 层次化布局的原理和实现思路
层次化布局本质上是有向无环图的分层。第一步是对图做拓扑排序,找出哪些节点在最左边,哪些在同层。我用的原因是连线本来就带有方向性,从设备 A 到监控 B,B 应该在 A 的右侧。基于这个语义做 LR 布局,能最大程度保持用户初衷。
第二步是层分配。对每个节点计算 longest-path-distance,也就是从源点出发最长可达的边数,该距离作为它的层级。所有同层节点会被分配到同一垂直坐标带,保证所有连线方向统一向右。
第三步是层内排序。层内的顺序直接影响边交叉数量,所以我在层内用简单的基于"重心启发式"的多次迭代:每一轮重新计算每个节点在此层的理想相对位置,按邻居节点在中位数的位置排序,迭代 10 次后通常能显著减少交叉。完整算法会做 4 轮重心排序,因为每轮排序都依赖上一轮的结果,已经到可接受的效果。
第四步是坐标计算。节点 x 坐标由层级数*层间距得到,y 坐标在同层内按"层内顺序"均匀分布。我加入了一点随机扰动:如果两个节点同层且业务类型相同,交换它们的位置再比较连线总长度,保留更短的那个。这是贪心方法,但效果很容易让用户感觉"好像是自动排过的"。
5.2 避免节点重叠的分离算法
布局结束后难免会有几对节点靠很近,特别是某些节点因为 out-degree 高,形状比较大。分离算法我用了简单的"碰撞分离 + 共振传播":遍历所有节点对,如果重叠则给每个节点一个斥力推向两边,迭代 3 次。这个方法能解决 80% 重叠,但有时候会把连线拉得过长。
更实用的办法是布局后统一做一次"紧凑化"步骤:坐标离散化后,把所有顶点的横纵坐标同时偏移最小量,让它们只保留必要间隙。间隙我定义为节点尺寸的一半加 20px,在离散化时把不足间隙的点逐个移开,同时保证移动不引入新的重叠。这个紧凑化对整个图的比例感和美观度提升非常明显。
5.3 针对特殊子图的人工规则处理
自动布局不可能覆盖所有用户心思,比如"我想让这条高亮链路始终在左边"。所以我增加了约束布局的概念:用户可以对一个子图的多个节点设置"手动锁定",锁定节点不参与自动布局,其他节点围绕锁定节点布局扩散。实现上就是在拓扑分层时先把锁定节点当锚点,然后让其他节点以锚点为起点做 BFS 扩散。
自动布局必须可撤销。在布局前要快照整个节点位置表,布局完成后视为一次命令,注册进 undo stack。这样用户对自动布局结果不满意时,可以一键返回手排状态。自动布局一跑就是几百毫秒,快照的成本和布局耗时差不多,所以它必须生成完整的新 state,不能在原 state 上原地改动。
5.4 导出与序列化:JSON、SVG、PNG 三种格式的设计
图表工具产出必须能被复用和传播。序列化格式我定义为"JSON 包"而非单张图片。JSON 包包含 schema 版本号、节点数组、连线数组、视口信息、样式引用和业务数据快照。版本号字段特别重要,模型迭代后旧数据必须能兼容打开。我在版本升级时写了一个 migration 函数链,每个版本升级对应一个函数,旧的包可以逐级升级到最新。
SVG 导出相对简单:把当前画布的整体视口作为 viewBox,收集节点和连线渲染时生成的 SVG 字符串,塞进一个 svg 根节点。但字体是一个大坑:如果系统没有部署对应的 iconfont,导出后的 SVG 字体全部退化。我后来在导出前先做一步:把所有文本转成 path 轮廓。可惜 SVG 原生不支持 text-to-path,所以我在导出时嵌入 Google Fonts 的字体样式链接,保证在任何浏览器打开时自动加载。
PNG 导出需要用到 canvas 和 SVG 的融合。先把 SVG 字符串内嵌成一个 Image 对象,drawImage 到 canvas,再 toBlob 导出。这里要设置背景色为白色,否则渲染透明度混乱。跨域字体在绘制时要用 canvas 会报 tainted,解决方案是把字体 base64 内嵌到 SVG 的 style 里,而不是外链,这样绘制时不需要跨域请求。
6. 性能调优与踩坑记录:2000 节点卡顿的定位与修复
这个项目里我历经过两次性能卡顿问题,一次是节点一多,拖拽明显掉帧;另一次是保存 JSON 时,大图序列化耗时太长导致界面冻结。记录一下解决思路,也许对后来者有帮助。
6.1 大图拖拽掉帧的主因:不必要的全量重绘
拖拽掉帧发生在节点数量超过 500 时。我第一反应是大图 SVG 太重,但其实根本原因是拖拽时的每次 mousemove 都触发了整个 GraphModel 的渲染 diff,而 diff 函数遍历了所有节点。节点 500 个时还算流畅,到 800 个全部更新一遍就很吃力了。
解决方法:把渲染更新拆成不同的"脏区"。我发现拖拽时只有节点位置变化,连线的贝塞尔控制点变化也只牵扯被移动节点的邻接边,静态背景没有变化,完全不需要全量重绘。改造后我把"移动事件"和"样式事件"分开,移动事件直接更新 DOM 属性 g transform,而不触发整个 GraphModel diff;样式事件如 hover 才需要精确到一个节点上的 fill 变更。这个改造把拖拽过程从每帧 20 毫秒降到了 2 毫秒。
6.2 requestIdleCallback 做防抖布局和增量渲染
自动布局计算量不小。计算 2000 个节点分层排序时,如果一次性在主线程跑完,界面会空白几百毫秒。我用 requestIdleCallback 把布局计算拆分:第一步先算拓扑分层,因为这一步不需要看视觉坐标;第二步在下一帧空闲时算排序和坐标;第三步更新 DOM。三个步骤中间让浏览器有机会绘制,用户看不到"卡顿"而是看到"渐进式整理"。
增量渲染用了类似思路。节点渲染前,我先判断节点是否变化:位置、尺寸、层级样式、业务数据。四者都没变化,就跳过对 DOM 操作;变化了才更新。这个做法需要一个从节点 id 到 DOM 元素的 WeakMap 映射,而且必须保证节点删除时 DOM 也被清理,否则 WeakMap 里残留引用会导致内存泄漏。
6.3 撤销栈的内存控制:快照为什么不能无限保存
撤销栈本质是一个数组,里面存放每次操作后的完整 state 快照。如果每次拖拽都存一拍,用户拖了 20 次就会存 20 份 JSON,每份 500KB,很快内存就爆了。我后来在这个空栈上做了三个优化:拖拽动作合并为一次快照,而不是每次 mousemove 都存;历史记录保存的是"启动拖拽前的快照",一次拖拽结束最多存两个快照(前与后);栈深限制为 100 步,超过 100 自动丢弃最早的快照。
这三个优化做完后,总内存占用低了一个数量级。我还做了一个很实用的小功能:对比当前 state 和上一次快照的差异,如果差异极小(只有 1px 位移),不入栈。用户在画布微调时不会被撤销打断。
6.4 类型系统对项目的后期价值
类型系统在前期看起来是拖慢开发速度,但到后期数据模型迭代时帮我抓回了很多 bug。GraphModel 的每个核心对象都用 interface 严格约束,方法参数全部显式标注,这样在重构"连线从折线改为贝塞尔"时,TypeScript 编译器把所有遗漏的调用点都标红了,不用自己一个一个找。
尤其是联合类型节点类型,例如"设备节点"和"文本节点"各自的 properties 不同,用类型收窄后,switch 内都安全访问。如果当时没有类型系统,这个项目的隐患会非常多。对于任何多维数据模型系统的长期演进,类型系统不是锦上添花,而是保命工具。
7. 如果再给我一次机会,我会少做和坚持做哪些事
项目做完拿给真实用户用了一段时间,也收集了一些反馈。如果让我重新开放这一轮,有几件事的变化会很大。首先是最初的架构决策中,我用二分裁剪做了矢量编辑,直接导致连线路径不支持拖拽中间节点自由改弯。但随着用户对路由线条精细排布的诉求变高,这个限制逐渐成为痛点。如果早点对连线做可编辑折点,用户满意度会高很多。
第二是阴影和滤镜效果,在 SVG 里看起来很漂亮,但在 Canvas 导出和低端机上掉帧明显。如果一开始就规范"可渲染效果白名单",就不会出现"编辑器里很美,导出后全失效"的反差。这也让我吸取了一条经验:编辑器能力范围和导出能力范围要从一开始就是同一个集合,而不该在 UI 和导出画两条平行线。
坚持做对的事:一是数据模型与渲染分离。所有交互行为都是纯函数式描述,不依赖任何 DOM,后来远程协作只需要共享操作序列,非常方便。二是 Model 中的 Mutation 操作全部走 reducer,这让 undo/redo、保存快照、协同广播三件事合而为一。三是 SVG 为主、Canvas 为辅的渲染架构,Oracle 我一直觉得稳健,未来的升级迁移不需要伤筋动骨。
现在这个 diagram-design 项目已经稳定支撑了几个业务面板,从最初只有矩形和连线,到后面能放图表、图片、分组、注释层,整体架构还保持着最初的干净边界。做这种工具最大的乐趣正在于:每修一个 bug 你学到的知识,在下一次项目里能直接复用。