我最早接触 diagram-design 这个词,是在一个要做内嵌流程图编辑器的项目里。当时市面上能直接用的绘图方案不少,但真落到自己的业务场景——要定制节点样式、要对接已有数据模型、要控制交互行为——总有一种隔靴搔痒的感觉。后来我索性自己动手,从数据结构开始,到画布渲染、交互、导出,完整做了一套图表设计实现方案。这篇就聊聊我在做 diagram-design 时踩过的坑、想明白的事,以及一套可以直接参考的落地路径。
如果你正在做可视化平台、低代码搭建器,或者想在业务系统里嵌入一个“能改得动”的架构图/流程图编辑器,这篇内容应该能帮你省掉不少弯路。里面没有太多玄乎的理论,更多的是我在实际开发中验证过的取舍思路和实现细节。
1. 先想明白一件事:图表设计工具,核心不是“画图”
1.1 用户要的不是那张图,而是“改得动”的图
做图表设计,最容易掉进去的坑,就是把它当成“输出一张静态图片”的工具。实际上,用户真正要的是一个可交互、可编辑、可序列化的图形数据容器。一张图放在画布上,如果拖不动、点不中、连不了线,那它就只是一张死了的图,价值大打折扣。
我在项目里很早就定了一个原则:图是数据的投影。画布上每个节点、每条连线,背后都必须对应一份可读写的数据。用户用鼠标改了图,数据要跟着变;业务系统改了数据,图要能自动刷新。这样,图表工具才能嵌入到真实业务流程里,而不是一个孤立的画板。
这个设计决定,直接影响了后续的数据结构定义、渲染策略和交互机制。很多功能做起来顺不顺手,回头看你,都是从这里分岔出去的。
1.2 你设计的是一套“交互规则”,不是几个API
把图表设计拆开看,可以分成三层:
- 数据层:节点、边、端口、分组,以及它们的关系。
- 渲染层:如何把这些数据画到屏幕上,并处理缩放平移。
- 交互层:用户怎么用鼠标/触控去编辑这张图。
大多数半途而废的图表项目,都是死在第三层。因为“画出一个节点”很容易,但“让用户精准地选中节点、拖动节点、从端口拉出一条线、线还能自动绕开其他节点”就是另一回事了。每一件事背后,都有一套交互规则要定。
我在设计交互规则时,会习惯性地把自己当成使用者去走一遍流程:我要新建节点,我要连线,我要框选,我要修改属性,我要撤销——这些都是最基础的高频操作。把高频操作做到顺滑,比堆一堆炫酷但少用的功能更重要。
1.3 节点、连线、端口:一张图的“语法单位”
图表跟自然语言一样,有自己的“语法单位”。在我这套方案里,最基本的三类元素是:
- 节点(Node):表达一个独立实体,比如流程里的一个步骤、架构里的一个服务。
- 端口(Port):节点上用来连接线的锚点,决定线从哪里出、到哪里进。
- 连线(Edge):表达关系,连接两个端口。
端口这个设计,很多人一开始会忽略。但实际做连线的时候你会发现,如果没有端口,线就只能连到节点边缘的任意位置,看起来乱,数据也不好维护。有了端口,每条线都能明确知道自己挂在哪个锚点上,计算路由起点终点也有了依据。
分组(Group)也算一个常用的语法单位,用来表达容器关系,比如一个子网、一个模块域。它本质上也是一个节点,只是可以包含其他节点。
2. 技术选型解析:SVG还是Canvas,别急着站队
2.1 两种渲染方案的真实差距
做图表设计,第一个绕不开的问题就是:用SVG渲染还是Canvas渲染。我在这个项目里两种方式都实测过,简单说说它们的真实差距。
SVG的优势在于:
- 每个图形都是DOM元素,事件绑定和样式控制很直接。
- 天然支持CSS,能很方便地做悬停、选中等状态样式。
- 对开发调试友好,浏览器开发者工具里直接看结构。
但它的致命伤是:节点数量上去之后,DOM节点数爆炸,性能肉眼可见地下降。我实测过,超过几百个带阴影和滤镜的节点,拖动画布时就会开始掉帧。
Canvas的优势则相反:
- 渲染性能高,几千个节点刷一遍完全没问题。
- 没有DOM节点,内存占用小。
- 用离屏Canvas能做很多预渲染优化。
但Canvas的问题也很明显:所有图形都只是一堆像素,你得自己实现“拾取”——也就是判断鼠标点在了哪个图形上。事件处理、状态管理,全部得自己造轮子。
有意思的是,我调研过一些成熟方案,发现头部产品清一色走了Canvas路线,原因很简单:它们的图复杂度远超普通场景,DOM撑不住。但如果你做的图通常只有几十个节点,SVG完全够用,而且开发效率高得多。
2.2 我最终采用的混合方案
在这个项目里,我最终采用的是混合方案:默认用Canvas渲染画布主体,但把一些低频、需要高交互的辅助元素(比如选中态的边框、编辑态的输入框)用SVG覆盖层来实现。
这样做的原因很实际:
- 主体用Canvas,保证大规模图层的渲染性能和滚动流畅度。
- 选中高亮、文本编辑这类高频交互元素用SVG,省去在Canvas里自己绘制光标、处理输入框的麻烦。
- 导出图片时走Canvas,导出矢量图时走SVG,两边各取所长。
代价是两套坐标系统要统一换算,好在换算逻辑不算复杂,后面会详细说。如果你不需要支持超大规模图,直接用SVG会省很多事;但我个人经验是,项目的复杂度往往会超出预期,一开始就留好性能余量,后面才不会狼狈。
2.3 图形库选型:自研还是用轮子
做图表设计,到底要不要自研渲染层?我的结论是:不要全自研,也不要全依赖现成库。
完整自研渲染层,意味着你要处理图形绘制、坐标变换、命中检测、遮挡裁剪、动画更新……这套东西工作量不小,而且很容易写出一堆自己都没想到的bug。反过来,完全依赖某个重型绘图框架,你会发现定制一个节点的内部结构,都要看它的API脸色,遇到框架不支持的需求只能绕。
比较合理的路径是:选一个轻量级的Canvas绘图库作为底层渲染能力,自己站在上面做图表语义层。这样你控制的只是“图怎么组织、交互怎么响应”,而“把圆角矩形画出来”“把文字排好”这种脏活累活,交给成熟库就好。
我做这个项目用的就是这种组合:一个基础的Canvas绘图库负责绘制原语,一套自研的Graph引擎负责数据结构、布局和交互规则。实践下来,开发效率和可控性都非常理想。
3. 从数据结构到交互闭环:diagram-design 的核心实现
3.1 图数据模型:Node、Port、Edge、Group
整个图表设计的骨架,是数据模型。我推荐用最经典的JSON序列化结构,因为它在存储、传输、调试上都友好。
节点(Node)的核心字段大概是这样的:
{ id: 'node-1', type: 'service', // 节点类型,决定渲染样式 x: 120, y: 80, // 画布坐标 width: 160, height: 60, zIndex: 1, // 层级 ports: [ { id: 'port-1', x: 0, y: 0.5, align: 'left' }, // 相对位置用比例表达 { id: 'port-2', x: 1, y: 0.5, align: 'right' } ], data: { name: '用户服务', status: 'running' }, // 业务扩展字段 style: { fill: '#e8f4fd', stroke: '#409eff' } }这里的端口坐标用的是相对位置,也就是百分比。为什么不用绝对坐标?因为节点一拖动、一缩放,绝对坐标就要跟着改,而相对位置无论节点怎么动,端口都能自动落在正确的位置上。这是个很小的细节,但能省掉一整套坐标同步逻辑。
连线(Edge)的核心字段:
{ id: 'edge-1', source: 'port-1', target: 'port-2', type: 'polyline', // 折线 / 直线 / 贝塞尔 points: [{ x: 0, y: 0 }, { x: 100, y: 100 }], // 路由点缓存 data: { label: '调用' }, style: { stroke: '#666', width: 2 } }points是路由计算的结果缓存,它不影响数据本身,但影响渲染效率。每次节点移动,连线的路由需要重算,但不一定要立刻写回存储——这是性能优化的一个关键点。
分组(Group)的实现很有意思,它本质上是个“能装节点”的节点:
{ id: 'group-1', type: 'group', x: 40, y: 40, width: 400, height: 300, children: ['node-1', 'node-2'] }拖动分组时,子节点要跟着移动。这个逻辑实现起来不复杂,但要注意分组的嵌套深度别搞太深,不然坐标换算会很痛苦。我一般限制最多三层嵌套,再深就不推荐了。
3.2 画布视口变换:缩放、平移、坐标换算
如果说数据模型是图表设计的心脏,那视口变换就是它的血管。所有渲染、交互、命中检测,都依赖一套统一的坐标换算逻辑。
先拆解两个坐标系:
- 世界坐标(World Coordinate):数据里存的坐标,代表图在“真实空间”里的位置。
- 屏幕坐标(Screen Coordinate):实际显示在页面上的像素位置。
两者通过视口(Viewport)关联。视口有两个关键参数:scale(缩放倍数)和 offset(偏移量)。
换算公式很简单:
// 世界坐标 -> 屏幕坐标 screenX = worldX * scale + offsetX screenY = worldY * scale + offsetY // 屏幕坐标 -> 世界坐标 worldX = (screenX - offsetX) / scale worldY = (screenY - offsetY) / scale看着简单吧?但真正写代码的时候,80%的bug都出在这两行公式的正负号上。
还有一个高频场景:缩放要以鼠标为中心。也就是鼠标指哪,缩放后鼠标指向的位置不能跑。实现思路是:缩放前后,鼠标指向的那个世界坐标点,在屏幕上的位置要保持不变。
function zoomAt(viewport, scaleBy, mouseScreenPoint) { const worldBefore = screenToWorld(mouseScreenPoint, viewport); viewport.scale *= scaleBy; // clamp,防止无限缩放 viewport.scale = Math.min(Math.max(viewport.scale, 0.1), 10); const worldAfter = screenToWorld(mouseScreenPoint, viewport); // 补偿偏移,让缩放后鼠标指向的点保持不动 viewport.offsetX += (worldAfter.x - worldBefore.x) * viewport.scale; viewport.offsetY += (worldAfter.y - worldBefore.y) * viewport.scale; }这套公式,我建议写完整理到项目里,后面基本所有平移缩放都用它,一次调通,再没出过漂移问题。
3.3 连线路由:让边沿正确路径连接
连线是图表设计里最容易被低估的部分。很多人以为就是画一条线连接两个端口,真上手才发现,要处理的问题不少:
- 线从端口出发的方向对不对。
- 两个节点之间有没有遮挡,线需不需要绕行。
- 节点拖动过程中,线是实时变化还是延迟更新。
- 折线的拐点怎么算才不显得乱。
我的做法是:先做直连,再做基于端口朝向的折线路由。
直连很简单,两个端口各取一个方向向量,用贝塞尔曲线插值出平滑路径。
折线路由的思路是:
- 根据端口朝向确定出线方向(出口朝右就从右边出)。
- 如果目标端口在出口方向的另一侧,先水平走一段,再垂直走一段,最后水平/垂直进入目标端口。
- 路径中间的拐点,用一个小圆弧过渡,避免直角太生硬。
这个算法不复杂,但视觉上效果很好。唯一要注意的是,两节点重叠时路由会失效,这时候我通常会直接画一条斜线,并把节点本身置为高亮提示用户。
还有一个实操细节:路由结果要缓存。节点拖动过程中,路由计算会高频触发,如果不缓存,直接放在渲染循环里算,大图会明显卡顿。我通常在拖动开始时缓存初始路由,拖动中用节流控制重算频率(比如每秒最多10次),松手后再算最终结果。
3.4 交互操作的完整链路
图表设计的交互操作,看着是鼠标点一点、拖一拖,背后其实是一条完整的处理链路。我总结的链路是:
事件捕获 → 坐标换算 → 命中检测 → 状态更新 → 重绘
事件捕获,我用的是Pointer Events,而不是传统的Mouse + Touch两套。Pointer Events的好处是鼠标、触摸、笔统一处理,一套代码全兼容。
命中检测,在Canvas方案里是核心难点。我用的方案是空间索引:把节点按位置组织成四叉树,鼠标点过来时,先查四叉树拿到候选节点,再做精确的矩形/圆形相交判断。这样命中检测的时间复杂度从O(N)降到O(log N),几千个节点也能保持流畅。
命中检测还要区分优先级:端口要优先于节点被命中,因为连线操作时你要“抓住”的是端口而不是节点本身。选中的规则是:点端口就是连接线,点节点就是选中节点。
状态更新环节,我采用了一个轻量级的命令模式。每次操作生成一个Command对象,包含execute和undo两个方法。这样撤销重做就是命令栈的压入弹出,而不是愚蠢地存一整个快照。这个设计对于大图特别重要,后面在坑里细说。
3.5 渲染性能优化的三板斧
图表设计的渲染性能,直接决定产品给人的“高级感”还是“廉价感”。我自己总结的三板斧是:局部重绘、视口裁剪、离屏缓存。
局部重绘:不要每次交互都重绘整个画布。我维护了一个dirty区域列表,只有被标记的区域才需要重绘。比如拖动一个节点,只有它原来的位置和现在的位置这两块区域需要重画。
视口裁剪:每个Canvas都对应一个可见的世界坐标范围,超出这个范围的节点根本不进入绘制循环。这个裁剪在数据量大时效果立竿见影,一次遍历就能筛掉一大半节点。
离屏缓存:对于静态的背景网格、固定不动的辅助元素,我提前画到一个离屏Canvas上,主画布重绘时直接贴上这个位图,省掉了每次画网格的开销。
还有一个细节,是给快速滚动场景用的:我监听了滚轮的频率,如果用户高速滚动,就先渲染一个低分辨率的离屏位图,让画面“跟手”,等滚动停下来再渲染高清画面。视觉上几乎没有感知差异,但性能体感会好非常多。
4. 踩坑实录:开发过程中最头痛的8个问题
4.1 首帧卡顿,问题出在渲染时机
项目初期,画布打开后总要先白屏几百毫秒才能看到图。排查下来发现,问题不在绘制本身,而在数据准备阶段:我在主线程上做了一大堆布局运算和样式解析,阻塞了渲染。
解决思路很直接:把数据准备分成“渲染必需的”和“渲染可选的”。必需的(节点位置、尺寸、端口坐标)同步准备好,可选的(阴影效果、复杂的装饰元素)等首帧渲染完成后再补。首帧从白屏变成了秒开,体感提升非常明显。
这个优化的核心思路是:用户能看到的质量问题,比技术上追求完美更重要。宁可先展示一个简洁的图,也不要让用户干等一个精美的图。
4.2 高DPI屏幕下文字发虚
这个坑几乎必踩。Canvas默认的分辨率跟CSS像素一样,但在高DPI屏幕上,物理像素是CSS像素的两倍甚至三倍。如果你不处理,画出来的图会感觉“边缘模糊、文字发虚”。
解决方案也很标准:按照devicePixelRatio放大Canvas的物理尺寸,再用CSS把它缩小回逻辑尺寸。
const dpr = window.devicePixelRatio || 1; canvas.width = cssWidth * dpr; canvas.height = cssHeight * dpr; canvas.style.width = cssWidth + 'px'; canvas.style.height = cssHeight + 'px'; ctx.scale(dpr, dpr);这段代码写起来就几行,但很多人不知道要在Canvas初始化时做。我第一次做的时候就是没做,还排查了半天为什么线条总是糊的。
4.3 缩放漂移,视角总是跑偏
前面提过缩放以鼠标为中心的公式,我第一次实现时写反了一个正负号,结果缩放的时候视角一直在“跑”,非常折磨人。
排查这个问题的技巧是:把逻辑拆成两步来验证。先固定scale不变,只改offset,看平移是否跟手;再固定offset不变,只改scale,看缩放中心是否对准鼠标。两边都验证通过后,再组合起来测。这个分步验证法,帮我很快定位到了问题来源。
4.4 撤销重做内存爆掉
最初做撤销功能时,我用的是快照方案:每执行一个操作,就把整个图的数据深拷贝一份存到历史栈里。结果图一大,操作几次之后内存就蹭蹭往上涨,浏览器直接崩溃。
后来改成命令模式,每个命令只存“这次操作改了什么”,而不是“这次操作之后长什么样”。比如移动节点,命令里只存节点id和前后坐标差值;新增节点,命令里存插入的位置和节点数据。撤销时逆向执行一遍,重做时正向执行一遍,内存占用降到了原来的百分之一。
如果你的业务需求相对简单,快照方案也可以用,但我建议从一开始就做命令模式,后面维护成本低得多。
4.5 连线对不齐,端口计算有偏差
连线的端点在视觉上老是对不齐端口中心,排查下来发现是我用绝对坐标去算端口位置导致的:节点移动后端口坐标没同步更新,画线时拿到的还是旧坐标。
改成前面说的相对位置方案后,这个问题就消失了。因为每次渲染时端口坐标都从节点坐标实时算出来,不可能出现“端口实际在A处,画线却假设它在B处”的情况。
4.6 拖动大量节点时,路由计算卡顿
当一个分组里有几十个节点,拖动分组时,所有连线都要重新计算路由,加上Canvas重绘,一度卡到没法操作。
我的优化方案是分层处理:
- 拖动过程中,连线路由不重算,保持上一次的路由结果,只把线的端点跟着新坐标平移。
- 松手瞬间,再统一重算一次所有连线的路由。
- 路由计算放到requestIdleCallback里,利用浏览器空闲时间处理,不让它阻塞主线程。
这样体感上几乎无感,但性能节省了九成以上。
4.7 导出图片时字体变了
导出图片时发现,导出的PNG里文字用的字体跟画布上不一样。原因是Canvas绘制文本时,字体只认当前CSS上下文里的字体,导出上下文里没有加载同样的字体,就回退到了默认字体。
解决方法是:在导出前,先用fetch加载目标字体文件,再用FontFace API注册到文档中,确保Canvas在绘制文本时能拿到正确字体。还有一些细节,比如中文字体文件很大,导出的异步加载时间会变长,需要给用户加载提示。
4.8 属性面板修改数据,画布不刷新
做完数据面板后,经常出现改了下拉框,画布上节点样式没变的情况。排查发现是没有在数据修改后触发重绘。
后来我定了一个规则:所有对图数据的修改,必须通过统一的方法(比如updateNode、updateEdge)走,这些方法内部会标记dirty区域并触发重绘。禁止直接在外层改对象属性。这个规则虽然约束了自由度,但保证了数据一致性和渲染一致性,项目越到后面越省心。
5. 实操验证:从零搭建一个可用Demo
5.1 最小可行版本要包含什么
聊了这么多,拿不出一个可落地的路径等于白聊。我整理一份最小可行版本的清单,你照着搭一遍,就能跑通图表设计的基本闭环:
- 数据结构定义(Node、Port、Edge、Group)。
- 视口对象(scale、offset,以及坐标换算函数)。
- 画布组件(初始化Canvas、绑定事件、启动渲染循环)。
- 绘制函数(画网格、画节点、画连线)。
- 命中检测(四叉树或线性遍历)。
- 两个交互操作(拖动节点、点击选中)。
- 撤销重做(命令模式的最小实现)。
不要一上来就做工具栏、属性面板、自动布局,先把这7步跑通。我经历过太多次“想做大全套,结果卡在基础交互”的情况,渐进式搭积木比一步到位稳定得多。
5.2 关键代码片段拆解
这里给出一个核心的渲染与交互骨架,用的JavaScript,方便你照着实现:
class Diagram { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.viewport = { scale: 1, offsetX: 0, offsetY: 0 }; this.nodes = []; this.edges = []; this.selection = new Set(); this.commands = []; this.commandIndex = -1; this.initEvents(); } worldToScreen(p) { return { x: p.x * this.viewport.scale + this.viewport.offsetX, y: p.y * this.viewport.scale + this.viewport.offsetY }; } screenToWorld(p) { return { x: (p.x - this.viewport.offsetX) / this.viewport.scale, y: (p.y - this.viewport.offsetY) / this.viewport.scale }; } draw() { const ctx = this.ctx; ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); // 绘制顺序:网格 -> 连线 -> 节点 -> 选中态 this.drawGrid(); this.edges.forEach(e => this.drawEdge(e)); this.nodes.forEach(n => this.drawNode(n)); this.selection.forEach(id => this.drawSelection(id)); } hitTest(point) { // 优先检测端口,再检测节点 for (let i = this.nodes.length - 1; i >= 0; i--) { const node = this.nodes[i]; for (const port of node.ports) { const abs = this.getPortAbsPosition(node, port); if (Math.abs(point.x - abs.x) < 6 && Math.abs(point.y - abs.y) < 6) { return { type: 'port', node, port }; } } if (pointInRect(point, node)) { return { type: 'node', node }; } } return null; } initEvents() { // Pointer Events 统一处理鼠标和触摸 this.canvas.addEventListener('pointerdown', (e) => { const worldPos = this.screenToWorld({ x: e.clientX, y: e.clientY }); const hit = this.hitTest(worldPos); if (hit) { this.selection.clear(); this.selection.add(hit.node.id); this.beginDrag(worldPos); } }); } }这只是一个骨架,但有几点值得说明:坐标换算函数要放最上面,所有的地方都用它,保持语义统一;命中检测从顶层节点开始往下查,保证选中最上面的;绘制顺序尽量稳定,不要让图形一会儿在上一层一会儿在下一层。
5.3 Demo跑通后怎么演进
跑通最小版本后,你会发现自己对这个项目的理解上了一个台阶。接下来再往里加东西,会顺畅很多。我的建议是按这个顺序演进:
- 加连线交互:从端口拉线出来,创建新边。
- 加自动布局:至少支持横向树状布局和纵向分层布局。
- 加分组:让节点能批量装进组里,统一移动。
- 加导出:三件套——JSON、PNG、SVG。
- 加撤销重做,到这时你的命令模式已经搭好了,加操作就只是加命令类而已。
这个演进顺序,每一步都建立在上一步的基础上,不会出现“地基没打牢就盖高楼”的情况。
6. 数据驱动的图表设计扩展
6.1 业务数据到图表数据的映射
图表设计做久了,你会发现一个共性需求:把现有的业务数据变成图。比如运维系统里有一堆服务器和它们之间的调用关系,你想直接生成一张拓扑图。
这块我建议不要写死在渲染层,而是单独做一个映射层。输入是业务数据,输出是图表数据:
- 业务对象映射为Node。
- 对象的唯一标识映射为Node的id。
- 业务对象之间的关系映射为Edge。
- 对象类型映射为Node的type,进而对应不同的样式。
这样业务方只需要提供数据,图表引擎负责布局和渲染。改业务数据结构的时候,也只动映射层,图表层不动,维护成本低很多。
6.2 自动布局:一种够用的简单方案
自动布局是所有图表工具逃避不了的话题。完整的布局算法(力导向、层级布局)实现起来工作量不小,但如果只是想要一个“够用的”效果,其实可以走一个更简单的路线。
对于树形/层级图,我用的是一个递归分层算法:
- 找根节点(入度为0的节点)。
- 从根节点开始BFS,得到每个节点的层级。
- 每一层的节点按顺序分配位置,层内间距固定。
- 把所有节点坐标写回数据模型。
配合端口朝向,效果已经很接近专业的流程图画布。如果你想更进一步,再往里面加速度弹簧模型做微调,但我个人建议先跑通简单版,再按需加复杂度。
6.3 多人协作是下一个大坑
如果项目要继续往前走,多人实时协作是绕不开的话题。它的核心是操作分发的时序管理:一个人拖节点,另一个人同时改节点属性,两个人同时连线,这些操作怎么合并、怎么保证最终一致。
我的建议是:不要自己造轮子,用成熟的CRDT算法库,或者退一步用操作转换(OT)方案。把每个命令(Command)定义成可同步的结构,每个客户端把命令广播出去,收到命令后按序执行并追加到本地命令栈。这恰好是前面命令模式的自然延伸,如果你从一开始就是命令模式的架构,接协作功能会顺很多。
7. 一些数据之外的观察
图表设计这个领域,技术和工具一直在变,但用户的核心诉求没有变:让我快速画一张能看懂、能改、能分享的图。
我自己的演进路线,从最简单的SVG画板,到Canvas高性能渲染,再到数据映射、自动布局、协作架构,每一步都是被真实业务需求推着走的。回过头看,最庆幸的是当初没有急着把所有功能搭完,而是先把数据模型和交互闭环想清楚了。这一套骨架,之后不管加渲染优化、布局算法,还是加协作,都不用推翻重来。
最后分享一个我反复体会到的实操原则:图表设计项目,80%的价值在基础交互,20%的价值在高级功能。把拖动、缩放、命中、连线、撤销这五个基本功做到极致,比加十个高级功能都有用。你可以在任何领域推演这个原则——先把高频路径做顺,再谈差异化功能。
如果你也在做类似的项目,或者正打算入这个方向,建议从一个小Demo开始,按前面那条路径走一遍。跑通的那一刻,你对“图”的理解会比读十篇教程都深。