React 流程图——使用 JavaScript 在 Canvas 上构建交互式流程图和图表
直接在 HTML5 Canvas 上构建交互式流程图和连接图。拖动节点时,连接箭头会实时跟随。这是工作流构建器、思维导图、组织结构图和可视化编程工具的常见模式。它支持原生 JavaScript、React(通过 react-konva)和 Vue。
操作说明: 拖动任意节点以调整其位置。连接会自动更新。单击“添加节点”添加更多节点。
- Vanilla
- React
- Vue
import Konva from 'konva';
var width = window.innerWidth;
var height = window.innerHeight;
var stage = new Konva.Stage({
container: 'container',
width: width,
height: height,
});
var layer = new Konva.Layer();
stage.add(layer);
var container = document.getElementById('container');
var controls = document.createElement('div');
controls.style.cssText = 'margin-bottom: 8px; display: flex; gap: 8px; align-items: center; font: 13px Arial, sans-serif;';
var addBtn = document.createElement('button');
addBtn.textContent = '+ Add Node';
addBtn.style.cssText = 'padding: 6px 14px; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 600;';
addBtn.onclick = function() {
var x = 100 + Math.random() * (width - 200);
var y = 80 + Math.random() * (height - 160);
var node = makeNode('New Node', '#2196F3', x, y);
node.moveToTop();
};
controls.appendChild(addBtn);
var hint = document.createElement('span');
hint.textContent = 'Drag nodes to reposition. Arrows follow automatically.';
hint.style.cssText = 'color: #888; font-size: 12px;';
controls.appendChild(hint);
container.parentNode.insertBefore(controls, container);
var nodeList = [];
var arrowList = [];
function makeNode(label, color, x, y) {
var group = new Konva.Group({ x: x, y: y, draggable: true });
var rect = new Konva.Rect({
width: 120, height: 50,
fill: color, cornerRadius: 8,
shadowColor: 'rgba(0,0,0,0.15)', shadowBlur: 6, shadowOffsetY: 2,
offsetX: 60, offsetY: 25,
});
var text = new Konva.Text({
text: label, fontSize: 14, fontFamily: 'Arial',
fill: '#fff', width: 120, height: 50,
align: 'center', verticalAlign: 'middle',
offsetX: 60, offsetY: 25,
});
group.add(rect);
group.add(text);
layer.add(group);
var entry = { group: group };
nodeList.push(entry);
group.on('dragmove', updateArrows);
return group;
}
function connect(from, to) {
var arrow = new Konva.Arrow({
points: [from.x(), from.y(), to.x(), to.y()],
pointerLength: 10, pointerWidth: 8,
fill: '#555', stroke: '#555', strokeWidth: 2,
listening: false,
});
layer.add(arrow);
arrow.moveToBottom();
arrowList.push({ shape: arrow, from: from, to: to });
}
function updateArrows() {
for (var i = 0; i < arrowList.length; i++) {
var a = arrowList[i];
a.shape.points([a.from.x(), a.from.y(), a.to.x(), a.to.y()]);
}
layer.batchDraw();
}
var start = makeNode('Start', '#4CAF50', 100, 120);
var procA = makeNode('Process A', '#2196F3', 300, 80);
var decide = makeNode('Decision', '#FF9800', 500, 120);
var procB = makeNode('Process B', '#2196F3', 300, 260);
var end = makeNode('End', '#f44336', 500, 260);
connect(start, procA);
connect(procA, decide);
connect(decide, procB);
connect(decide, end);
connect(procB, end);
import React, { useState, useCallback } from 'react';
import { Stage, Layer, Group, Rect, Text, Arrow } from 'react-konva';
var INITIAL_NODES = [
{ id: 'start', label: 'Start', color: '#4CAF50', x: 100, y: 120 },
{ id: 'procA', label: 'Process A', color: '#2196F3', x: 300, y: 80 },
{ id: 'decide', label: 'Decision', color: '#FF9800', x: 500, y: 120 },
{ id: 'procB', label: 'Process B', color: '#2196F3', x: 300, y: 260 },
{ id: 'end', label: 'End', color: '#f44336', x: 500, y: 260 },
];
var CONNECTIONS = [
{ from: 'start', to: 'procA' },
{ from: 'procA', to: 'decide' },
{ from: 'decide', to: 'procB' },
{ from: 'decide', to: 'end' },
{ from: 'procB', to: 'end' },
];
function FlowNode({ id, label, color, x, y, onDragMove }) {
return (
<Group
x={x}
y={y}
draggable
onDragMove={function (e) {
onDragMove(id, e.target.x(), e.target.y());
}}
>
<Rect
width={120}
height={50}
fill={color}
cornerRadius={8}
shadowColor="rgba(0,0,0,0.15)"
shadowBlur={6}
shadowOffsetY={2}
offsetX={60}
offsetY={25}
/>
<Text
text={label}
fontSize={14}
fontFamily="Arial"
fill="#fff"
width={120}
height={50}
align="center"
verticalAlign="middle"
offsetX={60}
offsetY={25}
/>
</Group>
);
}
var App = function () {
var [nodes, setNodes] = useState(INITIAL_NODES);
var [connections, setConnections] = useState(CONNECTIONS);
var [counter, setCounter] = useState(1);
var handleDragMove = useCallback(function (id, x, y) {
setNodes(function (prev) {
return prev.map(function (n) {
return n.id === id ? Object.assign({}, n, { x: x, y: y }) : n;
});
});
}, []);
var nodeMap = {};
nodes.forEach(function (n) {
nodeMap[n.id] = n;
});
var addNode = function () {
var newId = 'new_' + counter;
setCounter(counter + 1);
setNodes(function (prev) {
return prev.concat({
id: newId,
label: 'New Node',
color: '#2196F3',
x: 100 + Math.random() * 400,
y: 80 + Math.random() * 200,
});
});
};
return (
<div>
<div style={{ marginBottom: 8, display: 'flex', gap: 8, alignItems: 'center', font: '13px Arial, sans-serif' }}>
<button
onClick={addNode}
style={{ padding: '6px 14px', background: '#2196F3', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 13, fontWeight: 600 }}
>
+ Add Node
</button>
<span style={{ color: '#888', fontSize: 12 }}>Drag nodes to reposition. Arrows follow automatically.</span>
</div>
<Stage width={window.innerWidth} height={window.innerHeight}>
<Layer>
{connections.map(function (c, i) {
var fromNode = nodeMap[c.from];
var toNode = nodeMap[c.to];
if (!fromNode || !toNode) return null;
return (
<Arrow
key={'arrow-' + i}
points={[fromNode.x, fromNode.y, toNode.x, toNode.y]}
pointerLength={10}
pointerWidth={8}
fill="#555"
stroke="#555"
strokeWidth={2}
listening={false}
/>
);
})}
{nodes.map(function (n) {
return (
<FlowNode
key={n.id}
id={n.id}
label={n.label}
color={n.color}
x={n.x}
y={n.y}
onDragMove={handleDragMove}
/>
);
})}
</Layer>
</Stage>
</div>
);
};
export default App;
<template>
<div>
<div style="margin-bottom: 8px; display: flex; gap: 8px; align-items: center; font: 13px Arial, sans-serif;">
<button
@click="addNode"
style="padding: 6px 14px; background: #2196F3; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 600;"
>
+ Add Node
</button>
<span style="color: #888; font-size: 12px;">Drag nodes to reposition. Arrows follow automatically.</span>
</div>
<v-stage :config="stageConfig">
<v-layer>
<v-arrow
v-for="(c, i) in arrowConfigs"
:key="'arrow-' + i"
:config="c"
/>
<v-group
v-for="n in nodes"
:key="n.id"
:config="{ x: n.x, y: n.y, draggable: true }"
@dragmove="handleDragMove(n.id, $event)"
>
<v-rect :config="{
width: 120, height: 50,
fill: n.color, cornerRadius: 8,
shadowColor: 'rgba(0,0,0,0.15)', shadowBlur: 6, shadowOffsetY: 2,
offsetX: 60, offsetY: 25,
}" />
<v-text :config="{
text: n.label, fontSize: 14, fontFamily: 'Arial',
fill: '#fff', width: 120, height: 50,
align: 'center', verticalAlign: 'middle',
offsetX: 60, offsetY: 25,
}" />
</v-group>
</v-layer>
</v-stage>
</div>
</template>
<script>
import { ref, computed } from 'vue';
export default {
setup() {
var stageConfig = {
width: window.innerWidth,
height: window.innerHeight,
};
var counter = ref(1);
var nodes = ref([
{ id: 'start', label: 'Start', color: '#4CAF50', x: 100, y: 120 },
{ id: 'procA', label: 'Process A', color: '#2196F3', x: 300, y: 80 },
{ id: 'decide', label: 'Decision', color: '#FF9800', x: 500, y: 120 },
{ id: 'procB', label: 'Process B', color: '#2196F3', x: 300, y: 260 },
{ id: 'end', label: 'End', color: '#f44336', x: 500, y: 260 },
]);
var connections = ref([
{ from: 'start', to: 'procA' },
{ from: 'procA', to: 'decide' },
{ from: 'decide', to: 'procB' },
{ from: 'decide', to: 'end' },
{ from: 'procB', to: 'end' },
]);
var arrowConfigs = computed(function () {
var nodeMap = {};
nodes.value.forEach(function (n) {
nodeMap[n.id] = n;
});
return connections.value.map(function (c) {
var fromNode = nodeMap[c.from];
var toNode = nodeMap[c.to];
if (!fromNode || !toNode) return null;
return {
points: [fromNode.x, fromNode.y, toNode.x, toNode.y],
pointerLength: 10,
pointerWidth: 8,
fill: '#555',
stroke: '#555',
strokeWidth: 2,
listening: false,
};
}).filter(Boolean);
});
function handleDragMove(id, e) {
var target = e.target;
var list = nodes.value;
for (var i = 0; i < list.length; i++) {
if (list[i].id === id) {
list[i].x = target.x();
list[i].y = target.y();
break;
}
}
}
function addNode() {
var newId = 'new_' + counter.value;
counter.value = counter.value + 1;
nodes.value.push({
id: newId,
label: 'New Node',
color: '#2196F3',
x: 100 + Math.random() * 400,
y: 80 + Math.random() * 200,
});
}
return {
stageConfig: stageConfig,
nodes: nodes,
connections: connections,
arrowConfigs: arrowConfigs,
handleDragMove: handleDragMove,
addNode: addNode,
};
},
};
</script>
设计图数据
上面的示例把每个节点保存在一处,并让每条连接只存储两个节点 ID。这种归一化比看上去 更重要:绝不要把节点位置复制到边的数据中,否则每次移动都会留下过时的副本。应该在绘制 时读取当前节点位置。
对于小图,在每个 drag-move 事件中保存位置并重新计算箭头既清晰又足够快。对于大图, 在手势过程中直接移动被拖动的节点,并在拖动结束时提交一次文档变更 — 每个操作一条历史 记录,而不是每帧一条。
本示例连接两个矩形的水平边,这对从左到右的流程图可读性很好。真实的编辑器需要显式的 端口:把端口 ID 存储在边上,并从拥有它的节点计算其绝对位置。在位置稳定后再计算 正交连接线的路由 — 每帧重新计算路由开销很大。
如果你添加平移和缩放,在任何命中或放置计算之前,用舞台变换的逆矩阵转换指针位置,并 让图坐标独立于相机状态。参见相对于指针缩放。
对于大图,只绘制可见节点,用空间索引处理视口和选择查询,把连接线保持在节点图形下方,
并对不接收指针输入的连接线设置 listening: false。参见
性能建议。
端口、边选择、环路规则、自动布局、历史记录和持久化都属于图规则。在构建界面之前,先在 数据模型中定义每一条。