背景:客服流程图为什么越画越慢?
做智能客服的同学都懂,对话流程一旦超过 50 个节点,状态爆炸就像雪球一样滚起来:
- 分支嵌套过深,一个“转人工”节点后面可能挂着 20 层条件判断;
- 产品临时改一句文案,整条链路要重新拖拽对齐;
- 线上出现“死循环”路径,排查全靠肉眼,定位一次至少 30 分钟。
旧项目里我们用 jsPlumb 硬画,结果动态锚点全靠position: absolute硬算,浏览器一缩放连线就“飘”了;GoJS 功能全,但商用授权费+闭源让老板皱眉。最终我们把目光投向 antv x6:开源、MIT、React 友好,还能自己撸布局算法,于是决定用“效率提升”当唯一 KPI 重画一遍。
技术选型:为什么最后留下 antv x6
| 维度 | jsPlumb | GoJS | antv x6 |
|---|---|---|---|
| 动态锚点 | 手动算,无向量封装 | 内置,不可改 | 开放getAnchorPoint,可注入向量运算 |
| 自定义连线 | 样式受限 | 强,但闭源 | SVG/React 组件随便画 |
| 序列化 | 自己维护 JSON | 私有格式 | 原生toJSON()/fromJSON(),可插 schema |
| 许可证 | MIT | 商用收费 | MIT |
| 社区 | 基本不更新 | 官方论坛 | 钉钉、语雀都在用,issue 回复快 |
一句话:x6 把“布局算法”和“渲染层”拆开,刚好让我们把“客服场景”的脏活累活自己消化,又不至于从零造轮子。
核心实现:React + TypeScript 搭架子
1. 项目骨架
pnpm create vite@latest cs-flow --template react-ts cd cs-flow pnpm add @antv/x6 @antv/x6-react-shape目录约定:
├─ src/ │ ├─ components/ // 业务节点 React 组件 │ ├─ hooks/ // useGraph、useCmd │ ├─ workers/ // .ts 文件,会被 Vite 打包成 blob │ └─ schema/ // JSON Schema 版本管理2. 动态锚点计算(带向量注释)
客服节点有 4 条边,但锚点不能死板地固定在四角,否则连线会穿过节点本体。我们让锚点沿边缘法向量外移 8 px,实现“贴边”效果。
// anchor.ts export interface PortMeta { id: string; group: 'in' | 'out'; angle: number; // 0=top,90=right,180=bottom,270=left } /** 向量旋转 */ const rotate = (v: [number, number], deg: number): [number, number] => { const rad = (deg * Math.PI) / 180; const [x, y] = v; return [x * Math.cos(rad) - y * Math.sin(rad), x * Math.sin(rad) + y * Math.cos(rad)]; }; /** 计算动态锚点 */ export const getAnchor = ( nodeBBox: { x: number; y: number; width: number; height: number }, port: PortMeta ): { position: [number, number]; angle: number } => { // 1. 取中心到边缘中点的向量 const center: [number, number] = [nodeBBox.x + nodeBBox.width / 2, nodeBBox.y + nodeBBox.height / 2]; const edgeVec: [number, number] = port.angle === 0 ? [0, -nodeBBox.height / 2] : port.angle === 90 ? [nodeBBox.width / 2, 0] : port.angle === 180 ? [0, nodeBBox.height / 2] : [-nodeBBox.width / 2, 0]; // 2. 外移 8px,避免贴脸 const norm: [number, number] = rotate(edgeVec, 0); // edgeVec 本身就是法向量 const offset: [number, number] = [norm[0] / vecLength(norm) * 8, norm[1] / vecLength(norm) * 8]; const result: [number, number] = [center[0] + edgeVec[0] + offset[0], center[1] + edgeVec[1] + offset[1]]; return { position: result, angle: port.angle }; }; function vecLength(v: [number, number]) { return Math.hypot(v[0], v[1]); }单元测试要点(vitest):
it('should offset 8px outside', () => { const bbox = { x: 0, y: 0, width: 100, height: 60 }; const port = { id: 'p1', group: 'out' as const, angle: 0 }; const { position } = getAnchor(bbox, port); expect(position[1]).toBeCloseTo(-38); // -30-8 });3. JSON Schema 设计——让产品也能向后兼容
{ "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "version": { "type": "string", "const": "1.2.0" }, "nodes": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string" }, "type": { "enum": ["message", "branch", "action"] }, "data": { "type": "object" } }, "required": ["id", "dagLevel"] } }, "edges": { "type": "array", "items": { "type": "object", "properties": { "source": { "type": "string" }, "target": { "type": "string" }, "condition": { "type": "string" } } } } } }升级策略:只增字段,不删不改;反序列化时用zod做 safe parse,未知字段全部strip,保证旧图能打开。
性能优化:让 1000 个节点也能滑着玩
1. WebWorker 分流拓扑计算
布局算法(DAG 分层 + 交叉最小化)是纯计算,放主线程会卡 UI。我们把它丢进 WebWorker:
// workers/layout.ts self.onmessage = (e) => { const { nodes, edges } = e.data; const dag = buildDag(nodes, edges); // 拓扑排序 const layers = assignLayer(dag); // 分层 const { x, y } = reduceCrossing(layers); // 交叉最小化 self.postMessage({ x, y }); };主线程调用:
const worker = new Worker(new URL('../workers/layout.ts', import.meta.url), { type: 'module' }); worker.postMessage({ nodes, edges }); worker.onmessage = (e) => graph.fromJSON(applyPosition(graph.toJSON(), e.data));实测 1200 节点,主线程耗时从 900 ms 降到 120 ms,用户几乎感受不到卡顿。
2. 虚拟滚动——只渲染视口
x6 提供scroller插件,但节点太多时 DOM 依旧爆炸。思路:
- 把画布拆成 200×200 的网格,建立 QuadTree 索引;
- 监听
graph:scroll,计算可视矩形; - 对完全不在矩形的节点执行
cell.setVisible(false),并移出 DOM; - 滚动停止后再
setVisible(true)批量恢复。
实现后,DOM 数量从 1∶1 降到 1∶5,内存占用下降 60%。
避坑指南:血泪踩出来的三句话
1. 内存泄漏——Detached DOM 监控
Chrome DevTools → Memory → Take snapshot → 搜索Detached,如果节点数随操作递增,基本有泄漏。常见原因:
- 自定义 React 节点没在
componentWillUnmount解绑graph.on('event'); - 注册全局命令后未
dispose()。
修复模板:
useEffect(() => { const handler = (args: any) => {}; graph.on('cell:change:*', handler); return () => graph.off('cell:change:*', handler); // 一定配对 }, [graph]);2. 撤销/重做——命令模式最省心
x6 自带History插件,但客服节点里还包着表单,要连 React State 一起回滚。做法:
- 把“业务数据”也当
prop塞进cell.setData(); - 自定义
Command时同步写setData,让undo()直接setData旧值; - 对表单 onChange 不立即写历史,而是
debounce 300 ms后批量execute('update-data'),避免每个字母都占一个栈。
延伸思考:向低代码平台再走一步
客服流程图跑通后,我们顺手把编辑器抽成@cs/flow-designer包,做到:
- 节点物料 = React 组件 + schema 描述,发布到私有 npm;
- 出码模块把 x6 JSON 转成微信小程序
wxs语法,跑在客服小程序端; - 拖拽面板、属性配置、权限管控全部插件化,其他业务线(审批、工单)直接引用。
这样“图”成了低代码的通用 DSL,而 x6 只是渲染层之一,未来替换成 Flutter 桌面端也能复用同一套 JSON。
写在最后
整套方案上线三个月,产品同学已经能在 10 分钟内搭完 80 节点的复杂对话树,开发再没收到“帮我挪一下节点”的工单。对我来说,最大收获不是渲染快了 3 倍,而是终于把“流程图”从需求黑洞变成了可维护、可单测、可版本管理的普通前端模块。如果你也在被客服流程折磨,不妨把 x6 捡起来试试,记得先把锚点算准,再开 WebWorker,剩下的就是愉快拖拽了。