去年我们团队接手一个内部系统重构时,我最头疼的并不是业务代码,而是怎么把架构图画明白。通用绘图软件看着功能很多,可真要把二十多个节点按层级排布好、连线上不交叉、标注和配色保持一致,手动调一次就是一下午。需求一改,某个模块从左挪到右,整张图的连线又全乱了。折腾到第三版之后,我决定不再忍受,顺手起了个内部代号diagram-design:做一个以数据为核心、画布交互为辅的图表设计器。节点、连线、锚点、分组全部是结构化数据,画布只负责呈现和编辑它们。几个月下来,这套东西已经在团队日常文档、评审材料里跑了起来。这篇复盘就聊聊 diagram-design 从想法到 V1 可用的完整过程,适合想自建图表工具、做前端工具链,或者只是好奇可视化编辑器内部逻辑的人。
其实是想到哪写到哪,尽量把关键的设计决策、翻车现场和补救方式都交代清楚。
1. 项目缘起:被图表工具逼到想自己写一个
开始动工之前,我先把“现在为什么难受”列了个清单,免得做着做着做成一个市面上已有工具的复刻版。
1.1 徒手画图最大的消耗,不是画,是改
如果只是画一张不会再变的静态图,任何通用绘图软件都够用了。真正的痛点全在“改”上。一个二十几个节点的部署拓扑图,新增一个依赖关系,就得手动拖一条线、绕开几个已有节点、再重新对齐一遍附近文字。更别提多人协作后的版本混乱:有人用默认配色、有人改了字体、有人把图例放到了看不见的地方,最后拼进文档时总得重新收拾一遍。这种图哪里是资产,分明是负债。
另一个隐含问题是样式标准。团队文档里的插图,理想状态是统一风格、统一线宽、统一标注方式,但靠人力去维护这套标准,纯属空想。只有把图的样式规则沉淀到代码层面,才能让每张新图一出厂就长得像一家人。这也是我把项目重点放在“数据模型 + 通用渲染”而不是“某一个具体图形”上的原因。布局和样式对研发人员来说是驱动力的,不需要也不应该靠鼠标逐根对齐。
1.2 代码生成式方案的尴尬:图能出,但人不爱改
有经验的工程师很快会想到另一条路:用代码描述图结构,再由工具渲染成图片,要改就改文本、再跑一次命令。这个方案确实解决了“可版本化、样式统一”的问题,但它的缺点同样明显——画好之后不能在图上直接拖一拖、挪一挪;想临时调整一个节点位置,得先去理解别人写的一大段脚本;产品、测试同学想自己微调,门槛能劝退一批人。文本生成适合出图,不适合“编辑图”。
我需要的是两者的折中:底层有严格的图数据模型,方便批量处理和版本管理;画布上又保留直接操作的能力,让人类可以凭直觉调整布局。diagram-design 的定位就这样定下来了:一个以 JSON 模型为事实来源、画布为编辑界面的轻量图表设计器。它既不是又一个通用的画图软件,也不是纯粹的命令行出图工具,而是夹在中间、服务于“需要边想边画”场景的编辑器。
1.3 需求收敛:四句话框定边界
做工具最怕没边界,所以我动笔前先把范围压缩成几句话:
- 支持节点、连线、锚点、分组这四类图元,以及移动、缩放、框选、对齐参考线、网格吸附等画布基本操作。
- 所有图数据可以被序列化为 JSON,也能从 JSON 反序列化回来,保证存档和传输。
- 支持导出为矢量格式和位图格式,满足文档插图、评审投屏这类场景。
- 节点类型可注册、可扩展,团队可以往里面塞自己的业务图元,而不需要改动框架核心。
同时明确不做的东西:V1 不接自动布局,不做复杂流程引擎,不做多人实时协同。自动布局我后来做了一个非常简陋的“按字段等间距排列”当辅助,真正复杂的拓扑布局留给后续版本。先把手工编辑体验打扎实,这个判断在后面帮我们省掉了大量无底洞式开发。边界不是限制,边界是让项目能够收尾的保障。
2. 图数据模型:把“画面”翻译成可读可写的数据
如果只能从这次项目里带走一个理念,那就是:先把图变成数据,再谈画图。这一节是 diagram-design 的地基,也是全文最值得反复看的部分。
2.1 最小完备模型:节点、连线、锚点、分组
图表设计器里的万物,在我的模型里就只有这几样东西。先看节点的核心定义,下面的 TypeScript 类型声明是整套模型的地基:
interface DiagramNode { id: string; type: string; x: number; y: number; width: number; height: number; zIndex: number; parentId?: string; ports: Port[]; data: Record<string, unknown>; }x/y/width/height 是节点在画布坐标里的位置和尺寸,zIndex 控制叠放顺序,type 指向注册表里的节点类型,渲染框架根据它决定画成什么样子。ports 是锚点列表,也就是连线可以挂靠的位置。data 是给业务用的自由字段,比如服务节点里塞 owner、env、metrics 这些信息,框架渲染不到但也替你保留下来,这是后续做数据联动、表格导入导出的关键。parentId 用来实现分组嵌套,子节点存的是相对父节点的坐标,这样拖动父节点时整棵子树自然跟着走。
连线模型长这样:
interface DiagramEdge { id: string; source: { nodeId: string; portId: string }; target: { nodeId: string; portId: string }; style: 'straight' | 'orthogonal' | 'bezier'; points?: Point[]; // 手动覆盖路由 data: Record<string, unknown>; }连线不直接说“从 A 到 B”就完事,而是绑定到具体的节点和端口上。端口锚定在节点边缘,节点移动时连线的起点终点跟着动,框架不需要去猜应该从哪里出发。这里有个我在初版踩过的反模式:一开始连线用的是“从节点 A 中心到节点 B 中心”,然后只要有节点一变大,所有连线全部穿进图形内部,画面辣眼睛。后来才把端口模型补上。所以端口不是锦上添花的功能,它是让连线语义成立的前提。端口定义我推荐用相对节点左上角的坐标,至于上下左右这种方位,渲染层可以从相对坐标推导出来。相对坐标的好处是节点尺寸变化时端口可以跟着边缘走,而不用手动改每一个数值。
2.2 为什么所有变更都要集中到命令里
模型建好之后,最容易犯的错误是到处直接改:某个回调里node.x += 10,另一个回调里edge.style = 'bezier'。改多了,视图更新、撤销重做、历史记录全乱套。diagram-design 从一开始就定了一条死规矩:任何交互操作都只能通过 Command 完成,所有状态变更必须经过 Store 入口。
class DiagramStore { private nodes = new Map<string, DiagramNode>(); private edges = new Map<string, DiagramEdge>(); private listeners = new Set<() => void>(); applyCommand(cmd: Command) { cmd.execute(this); this.listeners.forEach((listener) => listener()); } }视图层只订阅 Store 的变化,收到通知后统一重绘。好处是:撤销重做只需要维护执行过的命令栈;导出只需要序列化 Store 内部的 Map;调试时看到某个状态不对,翻命令历史就能还原现场。代价就是多写一点样板代码,但这点代价在项目大了以后非常值得。如果你只想搭一个能画画的 demo,不搞命令模式也可以,但只要你打算支持撤销、历史面板、多端联动,这套集中管理的模式几乎是必经之路。命令模式的核心不是“模式”本身,而是它强制你思考每一个操作到底改了什么,让数据变更变得可追踪。
2.3 自定义节点类型:注册表模式
对通用绘图软件来说,矩形、椭圆、箭头就够用了。但像我们团队要画服务拓扑、消息流转,需要的图元明显更具体:有的想画成数据库圆柱体,有的想画成带状态灯的服务方块。如果每种样式都写死在渲染器里,项目迟早变成一个大文件堆。解法是注册表模式,让新节点类型“插进来就能用”:
type NodeRendererFactory = (node: DiagramNode) => string; const nodeRegistry = new Map<string, NodeRendererFactory>(); export function registerNodeType(type: string, factory: NodeRendererFactory) { nodeRegistry.set(type, factory); }框架的核心只管位置、尺寸、连线、框选、缩放;具体节点长什么样,由注册进去的工厂函数负责。比如注册一个“消息队列”节点,团队只要写一个函数,根据 node.data 渲染出对应的图标、颜色和标签,然后返回一段可插入的 SVG 标记或 DOM 元素。这样既让业务团队有极高的定制自由,又不污染核心的编辑逻辑。这一块唯一的提醒是:如果工厂函数返回的是字符串形式的 HTML/SVG,导入外部数据时一定要做内容转义,不能直接把用户填的文本塞进标记里渲染,避免 XSS 风险。有了这套模型,后面所有的算法和交互才有了稳定的输入输出。数据稳了,渲染和操作只是它的投影。
3. 连线路由与坐标变换:图表编辑器里最难啃的两块骨头
如果让我排序 diagram-design 里最费脑子的部分,连线路由和坐标变换稳居前两名。很多 bug 的根源最后都指向“坐标系没对齐”。
3.1 一个看似简单的问题:鼠标点到哪儿了
画布可以缩放和偏移,这意味着鼠标在屏幕上点一下,得到的是浏览器窗口里的坐标,但模型里存的是画布坐标系坐标。两者之间夹着一个视口变换:屏幕坐标 = 画布坐标 × 缩放比 + 视口偏移。反过来,要找到“我这一下点到哪个节点”,就得做逆变换:
interface Viewport { scale: number; x: number; y: number; } function screenToCanvas(pt: { x: number; y: number }, viewport: Viewport) { return { x: (pt.x - viewport.x) / viewport.scale, y: (pt.y - viewport.y) / viewport.scale, }; }看着简单,实际项目里特别容易漏。最典型的一个 bug:缩放倍数不等于 1 时,点击命中测试全部偏移,原因是某个事件处理器里直接用屏幕坐标去和模型坐标比较,没有做 screenToCanvas 换算。这个问题只在缩放状态下触发,开发时经常没注意,直到用户放大到 150% 再操作才会冒出来。后来我把规则写成铁律:事件处理器的第一件事,永远是把屏幕坐标转成画布坐标,后续所有逻辑都跑在画布坐标空间里,再不要混着用。这条规则本身没有技术含量,但它能消灭一大类“只在特定缩放比下出现”的幽灵 bug。
3.2 正交连线的路由策略:先延长、再拐弯
连线如果都画成一条直线,二十个节点交叉起来就会乱成一团。最常见的连线风格是正交折线:竖着出线、横着走、竖着进线,和电路图一个观感。diagram-design 的默认路由算法很简单——对大多数场景够用,也容易理解和调试。思路分三步:先从源端口沿端口方向延伸一段固定长度(我习惯用 32px)出线,再从目标端口反方向延伸 32px 进线,最后在两条延长线的末端之间找一个合适的中间拐点把它们连起来。
function buildOrthoPath( start: Point, startDir: Direction, end: Point, endDir: Direction ): string { const out = 32; const p1 = { x: start.x + startDir.x * out, y: start.y + startDir.y * out, }; const p2 = { x: end.x + endDir.x * out, y: end.y + endDir.y * out, }; if (p1.x === p2.x || p1.y === p2.y) { return `M ${start.x} ${start.y} L ${p1.x} ${p1.y} L ${p2.x} ${p2.y} L ${end.x} ${end.y}`; } const midX = (p1.x + p2.x) / 2; const midY = (p1.y + p2.y) / 2; return `M ${start.x} ${start.y} L ${p1.x} ${p1.y} L ${midX} ${p1.y} L ${midX} ${p2.y} L ${p2.x} ${p2.y} L ${end.x} ${end.y}`; }这段代码只是示意,但已经能解释为什么“先延长、再拐弯”能工作:出线方向是明确的,延长出来的线段天然不会贴着节点边缘;中间拐点只在两个垂直边上活动,路径正交性自然成立。比最短路径搜索那种全图避障简单太多,缺点是不会自动绕开挡路的节点。我选择把“绕开障碍物”这件事交给用户:所有连线都可以通过 points 字段被手动拖拽覆盖,用户觉得路径不爽时,可以自己拖中间控制点来微调。完全自动避障的复杂度会指数级上升,而且生成的结果经常不可预测,在 V1 优先级里非常低。这里的原则是:算法只做保证视线持续和方向感的基础路由,特殊需求交给人的直觉去覆盖。
3.3 分组嵌套:从“节点坐标”到“屏幕坐标”的递归累加
有了分组之后,坐标问题又多了一层:子节点存的是相对父节点的坐标,真正画到屏幕上的位置,要把所有祖先节点的坐标一路加上去。
function getNodeScreenPosition(node: DiagramNode, store: DiagramStore): Point { let x = node.x; let y = node.y; let parentId = node.parentId; while (parentId) { const parent = store.getNode(parentId); if (!parent) break; x += parent.x; y += parent.y; parentId = parent.parentId; } return { x, y }; }这个递归函数直接影响命中检测、框选、连线端点和对齐参考线的实现。如果三个地方分别用了三套不同的坐标计算方式,缩放嵌套两层的图时一定会有偏差。我这边的统一约定是:模型里永远存相对坐标;事件、渲染、命中、对齐统一换算成全局画布坐标后再计算;只有少数场景(比如把节点拖进分组时)才把全局坐标换算回新的相对坐标。这套约定一旦固定,分组相关的 bug 少了很多。嵌套分组是一个看起来不难、但处处都在影响整体架构的设计决策,最好在项目早期就定清楚。
4. 渲染层选型:SVG 起步、Canvas 兜底的架构折中
数据模型和路由算法聊完,接下来是“到底用什么画出来”。这个选型决定开发效率和性能上限,值得单独说。
4.1 SVG 与 Canvas,怎么选
| 维度 | SVG | Canvas |
|---|---|---|
| 事件与命中检测 | 浏览器原生,每个图元都是 DOM 节点 | 需要自己做坐标命中检测 |
| 性能拐点 | 上千个图元开始明显卡顿 | 上万图元也能保持流畅 |
| 样式控制 | 直接写 CSS,主题化非常方便 | 全靠绘制代码控制 |
| 二次开发难度 | 低,非常直观 | 高,需要维护自己的绘制逻辑 |
对 diagram-design 的目标场景来说,团队内部画的架构图、拓扑图通常集中在几十到几百个节点之间,SVG 完全够用。SVG 带来最大的红利是事件模型——节点上的点击、hover、拖拽天然有序,不用自己写射线法或者包围盒检测。Canvas 虽然性能上限高,但需要额外实现高分屏适配、文本布局、事件分发和局部重绘,开发量至少翻一倍。所以我的决定是:V1 全部走 SVG,把性能优化留到“真出现几百个节点同时操作卡顿”的那一天再考虑。这个决定让我把有限的精力投在了逻辑层而不是绘制层。
4.2 渲染抽象:把“画什么”和“怎么画”拆开
虽然 V1 用了 SVG,我不想把渲染代码写死在业务逻辑里。我定义了一个中性的渲染指令描述:
type RenderItem = | { shape: 'rect'; x: number; y: number; width: number; height: number; fill: string; stroke?: string } | { shape: 'path'; d: string; stroke: string; strokeWidth: number } | { shape: 'text'; x: number; y: number; content: string; fontSize: number }; interface Renderer { mount(container: HTMLElement): void; draw(items: RenderItem[]): void; destroy(): void; }SVG Renderer 把这个数组翻译成 SVG 元素,未来如果要换 Canvas Renderer,只要把这个数组用画笔实时绘制出来就行,上层业务代码不需要动。这个抽象很薄,成本很低,但它保证了系统不会在一棵 SVG 的树上吊死。后期如果接入 WebGL 做大规模图,同样的 RenderList 仍然能复用。现在回头看,这个决策是项目里性价比最高的一个,因为它花掉的时间极短,却让后续性能方案有了回旋余地。
4.3 渲染细节里的小坑:高分屏、线宽与文字
SVG 虽然省心,有几个细节我还是想单独拿出来说,因为它们都真实踩过。
第一,导出位图时要处理 devicePixelRatio。很多人直接用 SVG 源来画 PNG,把尺寸写上就完事,结果高分屏上导出的图片发虚或模糊。正确做法是导出时按目标 dpr 设置输出尺寸和 scale,让每个物理像素都落到该有的位置。
第二,线宽不能随便缩放。画布整体缩小到 50% 时,边框线也会跟着细了一半,视觉上会忽粗忽细。SVG 里可以用vector-effect="non-scaling-stroke"让描边宽度不随缩放变化,这样缩小时线看起来始终是同一粗细,观感稳定很多。
第三,文字在缩放极值处要特殊处理。缩小到 0.3 倍时,再精致的说明文字也成了马赛克;放大到 3 倍时,文字又可能占满整个节点。我的做法是分级渲染:缩放小于 0.5 时只显示节点名称,大于 2 时显示详情数据。这种“按细节级别渲染”的思路和游戏里的 LOD 很像,能有效保护画布可读性。
第四,局部重绘。别每次操作都对整棵画布做全量重建,我会维护一个 dirty 节点集合,只有变化过的节点及其连线才更新,其余元素保持原样。几十个节点的项目里全量重绘问题不大,到了几百个节点时,全量重建的卡顿感会非常明显。
5. 交互细节的工程实现:拖拽、对齐、撤销重做的取舍
画布类工具一大半的工作量其实在交互手感上。这章的每个细节,单看起来都不难,合在一起决定了一个工具是“能用”还是“好用”。
5.1 拖拽节点时,如何让整棵子树跟着走
拖动一个分组时,组里所有子节点必须跟着动。但这不意味着要把每个子节点的坐标都改一遍——那样命令栈会被撑爆,撤销时也要逐个恢复。正确姿势只有一个:只修改被拖动根节点的坐标,子节点坐标原地不动,因为子节点是相对坐标,父节点一旦移动,它们的屏幕位置自动跟着变。
class MoveNodeCommand implements Command { label = '移动节点'; constructor(private nodeId: string, private dx: number, private dy: number) {} execute(store) { const node = store.getNode(this.nodeId)!; node.x += this.dx; node.y += this.dy; } undo(store) { const node = store.getNode(this.nodeId)!; node.x -= this.dx; node.y -= this.dy; } }视觉上子树整体移动,数据上只改了一个根节点坐标,命令体积恒定。这是“相对坐标 + 递归累加”模型带来的最直接收益。拖动过程中的命中检测则用上一章的递归函数,把所有子节点的屏幕坐标算出来再做相交判断,不能直接拿子节点相对坐标去和鼠标位置比较。这里的关键不是算法有多复杂,而是“改什么、不改什么”的判断要清楚。
5.2 框选、吸附与对齐参考线:手感来自这几个小东西
框选矩形发生在屏幕空间。用户看到的是屏幕,拖动框选时的直觉也是屏幕上的范围,所以框选矩形要转成画布坐标,但相交判断时我会用节点的屏幕坐标去做。这样在不同缩放比例下,框选结果和用户预期保持一致,不会出现“明明圈住了,松开鼠标却少选一个”的违和感。
网格吸附用一句代码就能讲清楚:
function snapValue(v: number, gridSize: number) { return Math.round(v / gridSize) * gridSize; }但注意只在做整图平移等操作时,直接把节点的绝对坐标做 snap;如果拖进分组、即将变成相对坐标时,要先算好父节点的全局位置,再把节点位置换算到父节点的局部坐标系里做 snap,否则子节点可能一进入分组就跳变几像素,看起来非常难受。对齐参考线是另一个增色项。我实现的逻辑是:拖动过程中,遍历其它节点的上下左右四条边,如果目标边缘和某条边的距离小于 5px(屏幕距离),就把目标位置吸附过去,并在画布上画一条临时辅助线。遍历全部节点看似开销大,实际上一个画布里几百个节点完全可以接受。关键是先统一坐标系——所有参与比较的边都换算到全局画布坐标,不然放大缩放后再对齐必然出错。
5.3 为什么撤销重做选命令模式,而不是快照
最简单朴素的撤销方案是“每次操作后保存一份完整图数据快照”,撤销时把快照倒回去。几十个节点时确实能跑,但到了几百个节点,每一次操作都深拷贝整个图,内存和 CPU 都绷不住;而且快照方案很难做“合并”,拖拽过程会产生几十个中间快照,撤销时得疯狂点快捷键才能退一步。命令模式是把每次操作的逆操作保存在栈里:
interface Command { label: string; execute(store: DiagramStore): void; undo(store: DiagramStore): void; }每个用户意图对应一个命令。连续拖拽同一节点时,可以在栈顶合并相邻的 MoveNodeCommand,把多次微小位移合成一次大位移,撤销时一步到位。这个合并逻辑稍微有点技巧:只有当新命令和栈顶命令类型相同、操作对象相同时才合并,同时更新累计位移量。另外我坚持给每个命令打一个 label,后来做历史面板、快捷键提示、键盘可访问性都靠它,这笔账非常划算。
这块还有一个血泪教训:命令里改模型的时候,一定不要再绕过 Store 去直接改初始引用。我一度在某个拖拽优化里为了省事直接修改了节点对象,结果内部状态被污染,历史记录回放全部错位,花了一整天排查。命令模式的意义就是“所有变更留痕”,任何绕过都是自找麻烦。
6. 复盘:那些坑、那些教训和下一步想做的事
项目做到 V1 可用只是开始,真正让我成长最快的是后面几个月里的排错和反思。简单记录几个印象最深的现场。
6.1 四个印象深刻的翻车现场,完整复盘
第一个是缩放点击偏移问题。现象很好描述:画布放大到 150% 以后,点击任何节点都偏到右下方,但缩放为 100% 时一切正常。排查到最终才发现,框选和命中检测两个模块各自实现了自己的坐标换算,其中一个漏了除以 scale。表面原因是忘记换算,深层原因是代码里同时存在“屏幕坐标”“画布坐标”两套体系,没有统一入口。后来我把所有事件入口都套了一层 screenToCanvas,这类问题基本绝迹。
第二个是“数据改了但页面没反应”。有个同事往 node.data 里塞了新字段,期望界面上的标签刷新,结果等了半天没动静。原因很典型:他直接修改了 node.data 里的对象,而 Store 的变更通知只在 applyCommand 时触发,这种旁路修改自然没有通知。这逼我定了一条纪律:任何 UI 可见状态的变化都必须走命令,视图层只相信 Store 通知,不相信对象引用。这条纪律虽然严格,但长期看非常省心。
第三个是双击编辑经常被拖拽打断。双击节点进入文本编辑,但第一次 click 总是触发了一次拖拽,导致输入框刚出现又跳走。根因是“单击”和“拖拽”的判据太粗。我用时间和位移双重阈值解决:按下到松开时间小于 250ms 且位移小于 5px 才算 click,否则算拖拽。这在交互层是个通用解法,凡是“轻点”和“拖动”容易混淆的场景都适用。
第四个是上百个节点首次渲染卡顿。打开一份大图时,一次性生成几百个 SVG 节点,页面明显卡一下。我用了两级优化:先做视口裁剪,只渲染可视区域的节点;再用 requestAnimationFrame 分批渲染,把一次大重绘拆成多帧小更新。实际体感从卡顿变得基本无感。这个坑提醒我,性能问题要提前想,但不用提前做——先留好优化钩子,等实测出现再动手。
6.2 如果让我重来,三件事我会提前做
第一,先定义带版本号的 JSON Schema,而不是先写渲染代码。模型层是地基,地基不稳,后面每层都在还债。版本号是为了将来模型演进的迁移有据可查,否则旧存档一多就废了。
第二,渲染抽象从第一天就要做。哪怕 V1 确定只用 SVG,也把 RenderList 这种中性描述先定义好。后来真切到 Canvas 渲染器时,庆幸还留着这层缓冲。就算不换渲染器,这层抽象也让代码更容易测试和扩展。
第三,把自动化测试重点放在坐标换算和路由算法上。交互类 bug 有七八成最终都出在这两个数学问题上。纯函数式的 screenToCanvas、snapValue、buildOrthoPath 非常适合写单元测试,多写几个用例能省出大量手工点测时间。算法可以迭代,测试可以长期积累,这两块投入的回报率远高于在组件样式上抠细节。
6.3 后续如果想要继续扩展,我会往这几个方向走
自动布局是绕不开的下一步,但我会把它做成“建议布局”而非“强制布局”:一键把当前乱七八糟的节点按节奏排成树形或分层,排完之后用户仍然可以自由拖拽调整,不能被算法绑架。文档的交互式嵌入也很有价值:导出一张静态图只是终点之一,如果插图本身能嵌入文档、允许读者缩放漫游甚至选中某个节点查看数据,价值会大很多。再往后是语义化模型和自然语言生成的结合,让用户直接描述“我要画一个支付链路,包含下单、鉴权、扣款三个模块”,系统生成初步图稿,人在画布上精修。这条路我把数据模型准备好之后,实现成本比从零开始低得多。
最后再讲一件我反复体会最深的事。diagram-design 从开始到 V1 能用的这段时间里,最有价值的决策不是选了 SVG 还是 Canvas、路由算法用了几个拐点,而是把所有东西都变成了数据:节点是数据、连线是数据、操作是命令。有了它,撤销、导出、联动、测试、扩展,全都是水到渠成的事。以后再做类似的工具,我会先画一个最小的数据结构,再写第一行渲染代码,让数据始终当主角,界面永远只做它的投影。对于正在计划自己做绘图工具的朋友,我唯一的建议也是这个:先别急着画矩形,先把你想要表达的图,用数据描述出来。