PlantUML 与交互式图:react-flow / Vue Flow / vis.js 当 PlantUML 不够用时
PlantUML 出 SVG / PNG / PDF,没有交互——不能拖、不能高亮、不能动态改。这是「文档」而不是「工具」——很多前端项目想要后者。这一篇整理 PlantUML 与主流交互式图库的搭配实战。
PlantUML 的定位
PlantUML 输出 静态:
- ✅ SVG(嵌入文档)
- ✅ PNG(截图)
- ✅ PDF(打印)
- ❌ 不能拖动节点
- ❌ 不能点击高亮
- ❌ 不能实时编辑改图
如果你需要「拖拽画图」「图节点交互点击展开」等能力——PlantUML 不够用。需要前端图库。
当下主流的交互式图库
| 库 |
维护方 |
关系 |
复杂度 |
| react-flow |
xyflow/React |
节点 + 边 + handle |
低 → 中 |
| Vue Flow |
@vue-flow |
同上 (Vue 版) |
低 → 中 |
| vis.js |
visjs.org |
时间线 / 网络图 |
中 |
| Cytoscape.js |
cytoscape.org |
复杂 network / 关系图 |
中 → 高 |
| D3 |
D3 Observable |
数据驱动 SVG |
高 |
react-flow(最常用)
安装
最小 demo
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| import ReactFlow, { Background, Controls, MiniMap } from 'reactflow'; import 'reactflow/dist/style.css';
const nodes = [ { id: '1', position: { x: 100, y: 100 }, data: { label: 'Frontend' } }, { id: '2', position: { x: 300, y: 200 }, data: { label: 'Backend' } } ];
const edges = [ { id: 'e1-2', source: '1', target: '2' } ];
function MyDiagram() { return ( <div style={{ width: '100%', height: '600px' }}> <ReactFlow nodes={nodes} edges={edges} fitView> <Background /> <Controls /> <MiniMap /> </ReactFlow> </div> ); }
|
优势
- React-native API——天然融入 React 生态
- 节点可拖、可编辑、可高亮
- handle 系统——节点之间能控制连线端点
- 迷你地图+Controls内置
- 动画路径——边能动画
劣势
- 状态全在 React state——大型图(>100 节点)重渲染慢
- 节点/边类型得自己定义或选社区预设
- 复杂 layout(如 ELK)靠外部算法
Vue Flow(react-flow 的 Vue 版本)
安装
1
| npm install @vue-flow/core
|
用法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| <script setup> import { VueFlow } from '@vue-flow/core' import '@vue-flow/core/dist/style.css'
const nodes = [ { id: '1', position: { x: 100, y: 100 }, data: { label: 'Frontend' } } ] const edges = [ { id: 'e1-2', source: '1', target: '2' } ] </script>
<template> <div style="width: 100%; height: 600px"> <VueFlow :nodes="nodes" :edges="edges" /> </div> </template>
|
Vue Flow API 几乎和 react-flow 同名同行为,只是把 React 的 <JSX/> 换成 Vue <template/>。我们这里不深入 Vue Flow 后端。
vis.js(时间线 / 网络)
安装
1
| npm install vis-network vis-timeline vis-data
|
时序图(或 PlantUML sequence 替代)
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import { Timeline, DataSet } from 'vis-timeline/standalone';
const items = new DataSet([ { id: 1, content: '设计', start: '2026-01-01' }, { id: 2, content: '开发', start: '2026-02-01' }, { id: 3, content: '上线', start: '2026-04-01' } ]);
const options = { stack: true, orientation: { axis: 'top', item: 'top' } };
new Timeline(container, items, options);
|
网络图(替换 PlantUML component)
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import { Network } from 'vis-network/standalone';
const nodes = [ { id: 1, label: 'Frontend' }, { id: 2, label: 'Backend' } ];
const edges = [ { from: 1, to: 2, arrows: 'to' } ];
const data = { nodes: new DataSet(nodes), edges: new DataSet(edges) };
new Network(container, data, {});
|
优势
- 时间线场景无敌——PlantUML 没有时间线
- 网络图简单直接——不写一行 state
- 可画大图(>500 节点)
劣势
- 看起来「老派」(2014 年项目)
- React / Vue 包不像 react-flow 那么 native
- 文档散乱
Cytoscape.js(关系图谱 / 复杂图)
适合什么
- 知识图谱(KG)
- 生物信息学网络
- 大图分析(>1000 节点)
- 需要复杂选区算法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import cytoscape from 'cytoscape';
const cy = cytoscape({ container: document.getElementById('cy'), elements: [ { data: { id: 'a', label: 'Node A' } }, { data: { id: 'b', label: 'Node B' } }, { data: { id: 'ab', source: 'a', target: 'b' } } ], style: [ { selector: 'node', style: { 'background-color': '#666', label: 'data(label)' } }, { selector: 'edge', style: { 'width': 3, 'line-color': '#ccc' } } ], layout: { name: 'cose' } });
|
优势
- 图算法完整——cose / fcose / circle / breadthfirst / grid 等
- selectors——CSS 风格选择节点/边
- 事件系统——
tap / mouseover etc.
- selectors 联动——实时改样式
劣势
- 学习曲线陡
- 不是「React-first」——要包一层 wrapper
D3(最自由)
何时选 D3
- 需要完全控制渲染
- 想要非传统图(如弧弦图 / 桑基图 / 力导向)
- 已有 D3 全家桶(d3-scale、d3-geo 等等)
何时不选
- 单纯画节点+边——react-flow 更轻
- 团队不愿写「数据 → SVG」逻辑——cytoscape/vis.js 更快
实战:PlantUML → react-flow 转换
我们项目(puml.online)自己有 PlantUML 编辑器,当我们想做一个「点击节点高亮对应图区域」功能时,需要把 PlantUML 转 react-flow 数据。
解析 PlantUML
PlantUML 输出 SVG——用 jsdom 解析 SVG,从中抽取节点 / 边:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| import { JSDOM } from 'jsdom';
async function pumlSvgToFlow(svgText) { const dom = new JSDOM(svgText); const doc = dom.window.document; const nodes = []; const edges = []; doc.querySelectorAll('.node, g.node, polygon, ellipse').forEach(el => { const id = el.id; const transform = el.getAttribute('transform') || ''; const m = transform.match(/translate\(([\d.-]+),([\d.-]+)\)/); const position = m ? { x: +m[1], y: +m[2] } : { x: 0, y: 0 }; const label = el.querySelector('text')?.textContent || id; nodes.push({ id, type: 'default', position, data: { label } }); }); doc.querySelectorAll('path').forEach(el => { const d = el.getAttribute('d'); if (d && (d.includes('L') || d.includes('C'))) { edges.push({ id: el.id || crypto.randomUUID(), source: 'unknown', target: 'unknown' }); } }); return { nodes, edges }; }
|
实际 PlantUML SVG 不直接挂 source/target 信息——你需要 PlantUUM pid 输出(-pipe -tlint 或 -checkonly 输出 AST)来做反向映射。
更好的方法:PlantUML server 返回 JSON AST
1 2
| curl -X POST "http://localhost:8080/json" \ --data-urlencode "diagram=@startuml ... @enduml"
|
返回:
1 2 3 4 5 6 7 8 9
| { "diagram_type": "sequence", "participants": [ { "name": "Alice", "type": "actor" } ], "messages": [ { "from": "Alice", "to": "Bob", "label": "hello" } ] }
|
缺点:官方 plantuml-server 的 /json 路由不稳定,很多版本没实现。需要 check 你的版本。
替代方案:手写转换器
最稳的做法是自己写一个 PlantUML 文本 → react-flow 数据的转换器:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| import { Edge, Node } from 'reactflow';
export interface ParsedDiagram { type: 'sequence' | 'class' | 'state' | 'component' | 'deployment'; nodes: Node[]; edges: Edge[]; }
class PUMLParser { parse(code: string): ParsedDiagram { if (code.includes('@startuml')) { return this.parseClass(code); } throw new Error('Unsupported type'); } private parseClass(code: string): ParsedDiagram { const lines = code.split('\n'); const nodes: Node[] = []; const edges: Edge[] = []; lines.forEach((line, i) => { const classMatch = line.match(/^class\s+(\w+)\s*\{/); if (classMatch) { nodes.push({ id: classMatch[1], position: { x: 0, y: i * 100 }, data: { label: classMatch[1] } }); } const relMatch = line.match(/(\w+)\s+(--\|>|--\*|-->|o--|--)\s+(\w+)/); if (relMatch) { const [, src, , dst] = relMatch; edges.push({ id: `${src}-${dst}-${i}`, source: src, target: dst }); } }); return { type: 'class', nodes, edges }; } }
|
通用、不依赖 PlantUML 服务端。复杂语法(嵌套 package / generics)覆盖率 60%——不够时手动 edit。
实战:交互式 PlantUML 编辑器 demo
这是「边写 puml 边看 react-flow」工作流:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| import { useState, useEffect } from 'react'; import ReactFlow from 'reactflow'; import { PUMLParser } from './puml-parser';
function InteractiveDiagram() { const [code, setCode] = useState('@startuml\nclass A\nclass B\nA --> B\n@enduml'); const [diagram, setDiagram] = useState(new PUMLParser().parse(code)); useEffect(() => { setDiagram(new PUMLParser().parse(code)); }, [code]); return ( <div style={{ display: 'flex', height: '100vh' }}> <textarea value={code} onChange={e => setCode(e.target.value)} style={{ width: '40%', fontFamily: 'monospace' }} /> <div style={{ flex: 1 }}> <ReactFlow nodes={diagram.nodes} edges={diagram.edges} fitView nodesDraggable onNodeClick={(_, node) => alert(`Clicked: ${node.data.label}`)} /> </div> </div> ); }
|
为什么这个 demo 有用:
- 编辑 puml 文本 → react-flow 自动重渲染
- 节点可拖(你可以「手动调布局」)
- 点击节点触发业务逻辑
决策矩阵
| 场景 |
推荐 |
| 「画图给读者看」(博客 / 文档) |
PlantUML → SVG |
| 「图能拖、能改、能点击」 |
react-flow / Vue Flow |
| 时间线 / Gantt |
vis-timeline |
| 复杂 KG (>1000 节点) |
Cytoscape.js |
| 桑基图 / 弧弦图 / 等非标准图 |
D3 |
| 「文档 + 「少些交互」 |
PlantUML + post-processing |
PlantUML + react-flow 双栈实战
「文档需要 PlantUML 静态图,工具需要 react-flow 交互图」——一内容两套发布。
1 2 3 4 5 6 7
| project/ ├── docs/diagrams/ (puml source) │ ├── auth.puml │ └── order.puml └── app/diagrams/ (react-flow data) ├── auth.json (parsed) └── order.json
|
scripts/parse-puml.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import fs from 'node:fs'; import path from 'node:path'; import { PUMLParser } from '../parser/puml';
const dir = 'docs/diagrams'; const outDir = 'app/diagrams'; fs.mkdirSync(outDir, { recursive: true });
for (const file of fs.readdirSync(dir)) { if (!file.endsWith('.puml')) continue; const code = fs.readFileSync(path.join(dir, file), 'utf-8'); const diagram = new PUMLParser().parse(code); fs.writeFileSync( path.join(outDir, file.replace('.puml', '.json')), JSON.stringify(diagram, null, 2) ); }
|
CI 跑:
1 2
| - name: Parse PUML → React Flow run: node scripts/parse-puml.ts
|
总结:选择时机
- 只画静态图(文档、博客、PPT) —— PlantUML
- 能拖能改的画布 —— react-flow / Vue Flow
- 时间线 / Gantt —— vis-timeline
- 大图算法分析(1000+ 节点) —— Cytoscape.js
- 非标准图(sankey 等) —— D3
- 两者都需要 —— PlantUML 当 source of truth + 解析得到前端图数据
小结
- PlantUML 是静态 DSL——不负责交互
- react-flow / Vue Flow 是「拖动」和「点击」的「表」
- PlantUML SVG → react-flow 转换不容易。靠 PlantUUM server 返回的 JSON AST(不稳定)或自己写 parser
- 现实选择:「文档」用 PlantUML、「工具」用 react-flow、两者用同一个 source 同步
下一步