引言
在前述章节中,我们构建了一个最简易的流程图库模型,涵盖了画布(Canvas)、节点(Nodes)、连线(Edges)等基本构成要素,并实现了基础的渲染和交互功能。然而,要使流程图库具备更高的实用性和灵活性,仅凭最简模型仍显不足。因此,本节将基于最简模型,逐步拆解和完善库的核心能力,提升其功能性和用户体验。
核心能力拆解
为了系统的提升流程图库的功能,我们将核心能力拆解为以下几个模块:
- 数据视图模型分离:如何设计灵活的数据结构,确保流程图数据(节点、连线)与视图分离;
- 节点高效管理:如何支持节点的增删改查,节点的样式和内容自定义
- 动态连线管理:如何实现连线的动态调整、自动布局和路径计算等功能
- 统一事件中心:缺少统一的事件管理系统,当前支持的交互太少,如何支持更多的交互操作(比如双击编辑、右键菜单等)
- 优化图形渲染:从简单的 DOM 渲染过度到使用现代前端库的虚拟 DOM,提高性能并增强视觉效果
实现解析
接下来,我们将详细讲解各核心功能模块的设计思路和实现步骤,并分析如何处理性能优化和用户体验改进。
1. 数据视图模型分离
设计思路
为了实现数据与视图的分离,我们需要设计一个灵活且可扩展的数据结构,用于存储流程图的节点和连线信息。数据模型应支持一下特点:
- 可拓展性: 允许未来新增更多属性和节点类型
- 灵活性: 支持动态增删改查操作
- 一致性: 确保数据与视图的同步更新
实现步骤
正如前面章节所示,我们采用面向对象的方式,定义 FlowChart、Node 和 Edge 三个核心类,分别管理整体流程图、节点和连线的数据。
export interface NodeData {
id: string;
x: number;
y: number;
label: string;
type: string;
style: CSSStyleDeclaration;
}
export interface EdgeData {
id: string;
source: Node;
target: Node;
sourceAnchorIndex: number;
targetAnchorIndex: number;
}
export interface FlowChartData {
nodes: NodeData[];
edges: EdgeData[];
}
数据示例
const flowchartData = {
nodes: [
{
id: "node1",
x: 100,
y: 100,
label: "开始",
type: "start",
style: { color: "#2196F3" },
},
{
id: "node2",
x: 300,
y: 100,
label: "步骤1",
type: "default",
style: { color: "#4CAF50" },
},
],
edges: [
{
id: "edge1",
source: "node1",
target: "node2",
type: "default",
sourceAnchorIndex: 1,
targetAnchorIndex: 3,
},
],
};
2. 节点高效管理
设计思路
节点管理模块负责处理节点的增删改查操作,并支持节点的样式和内容自定义。我们需要提供以下功能:
- 添加节点: 在制定位置创建新节点
- 删除节点: 移除节点及其关联连线
- 更新节点: 修改节点的位置、内容和样式
- 查询节点: 根据 ID 或其它属性检索节点
实现步骤
主要在FlowChart类中集成节点管理功能,并在Node类中实现节点的渲染和交互。核心代码如下:
export class FlowChart {
// ...
addNode(id: string, x: number, y: number, label: string, type?: string): BaseNode {
const node = new BaseNode(id, x, y, label, type);
node.render(this.canvas.container);
this.nodes.push(node);
// ...省略一些无关的事件回调方法
return node;
}
removeNode(nodeId: string) {
const node = this.getNodeById(nodeId);
if (node) {
// 移除DOM元素
this.canvas.container.removeChild(node.element);
// 移除数据
this.nodes = this.nodes.filter(n => n.id !== nodeId);
// 移除关联连线
this.edges = this.edges.filter(edge => {
if (edge.source.id === nodeId || edge.target.id === nodeId) {
this.canvas.svg.removeChild(edge.element);
return false;
}
return true;
});
}
}
updateNode(nodeId: string, newData: NodeData) {
const node = this.getNodeById(nodeId);
if (node) {
if (newData.x !== undefined && newData.y !== undefined) {
node.updatePosition(newData.x, newData.y);
}
if (newData.label !== undefined) {
node.updateLabel(newData.label);
}
if (newData.style !== undefined) {
node.updateStyle(newData.style);
}
}
}
getNodeById(id: string) {
return this.nodes.find(node => node.id === id);
}
// ... 省略其它无关方法
}
3. 动态连线管理
设计思路
连线管理模块负责处理连线的创建、删除、动态调整以及自动布局等功能。关键在于确保连线能够准确反映节点之间的关系,并在节点移动时自动更新连线的位置。
实现步骤
在FlowChart类中集成连线管理功能,并在Edge类中实现连线的渲染和动态调整。核心代码如下:
// FlowChart 类中的连线管理方法
class FlowChart {
// ...之前的代码
addEdge(id: string, sourceNode: BaseNode, targetNode: BaseNode, type?: string) {
const edge = new BaseEdge(id, sourceNode, targetNode, type);
edge.render(this.canvas.svg);
this.edges.push(edge);
// Update edge positions when nodes move
sourceNode.setOnMove(() => edge.updatePosition());
targetNode.setOnMove(() => edge.updatePosition());
return edge;
}
removeEdge(edgeId) {
const edge = this.getEdgeById(edgeId);
if (edge) {
this.canvas.svg.removeChild(edge.element);
this.edges = this.edges.filter(e => e.id !== edgeId);
}
}
getEdgeById(edgeId) {
return this.edges.find(edge => edge.id === edgeId);
}
// 更多连线管理方法...
}
实例代码:
const flowChart = new FlowChart(canvasElement, flowchartData);
const node1 = flowChart.addNode("node4", 100, 100, "开始", "start");
const node2 = flowChart.addNode("node5", 300, 100, "步骤1", "default");
const node3 = flowChart.addNode("node6", 500, 100, "结束", "end");
// demo5 -> 添加不同类型的连线
// 添加不同类型的连线
const edge1 = flowChart.addEdge("edge1", node1, node2, "default");
const edge2 = flowChart.addEdge("edge2", node2, node3, "dashed");
const edge3 = flowChart.addEdge("edge3", node1, node3, "bold");
flowChart.render();
效果图如下:
路径计算
为了提高连线的可读性,可以实现自动布局和路径计算功能,如避免连线交叉、采用曲线或折线等。
曲线路径实例
export class BaseEdge {
// ...
render(svgContainer: SVGSVGElement) {
// this.element = document.createElementNS('http://www.w3.org/2000/svg', 'line');
this.element = document.createElementNS('http://www.w3.org/2000/svg', 'path'); // 增加
// ...
this.element.setAttribute('fill', 'none'); // REMIND: 增加 - 绘制 path 时设置
// ...
}
updatePosition() {
const anchorSize = 15; // Assuming the anchor size is 10x10 pixels
const sourceAnchorPos = this.source.getAnchorPosition(1); // Assuming right center for source
const targetAnchorPos = this.target.getAnchorPosition(0); // Assuming left center for target
const containerRect = this.source.element.parentElement!.getBoundingClientRect();
const x1 = sourceAnchorPos.x - containerRect.left + anchorSize / 2;
const y1 = sourceAnchorPos.y - containerRect.top + anchorSize / 2;
const x2 = targetAnchorPos.x - containerRect.left + anchorSize / 2;
const y2 = targetAnchorPos.y - containerRect.top + anchorSize / 2;
// 计算曲线控制点
const dx = x2 - x1;
const dy = y2 - y1;
const ctrlX = x1 + dx / 2;
const ctrlY = y1;
const pathData = `M${x1},${y1} Q${ctrlX},${ctrlY} ${x2},${y2}`;
this.element.setAttribute('d', pathData);
}
}
效果如下:
4. 统一事件中心
设计思路
为了增强流程图库的交互性,我们需要建立一个统一的事件管理系统,支持更多的交互操作,如双击编辑、右键菜单等。时间系统应具备以下特点:
- 统一管理: 集中处理各种事件,简化事件绑定和触发
- 可扩展性: 允许用户自定义和扩展事件类型和处理逻辑
- 高效性: 避免时间冲突和性能瓶颈
实现步骤
我们可以在 FlowChart 类中集成事件系统,通过事件监听器和回调函数,处理用户的各种操作。
首先,我们实现一个事件系统,通过定义 EventEmitter 类,如下:
type Listener = (...args: any[]) => void;
class EventEmitter {
private events: { [key: string]: Listener[] };
constructor() {
this.events = {};
}
on(event: string, listener: Listener): void {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(listener);
}
off(event: string, listener: Listener): void {
if (!this.events[event]) return;
this.events[event] = this.events[event].filter(l => l !== listener);
}
emit(event: string, ...args: any[]): void {
if (!this.events[event]) return;
this.events[event].forEach(listener => listener(...args));
}
}
紧接着我们更新 FlowChart 类以使用 EventEmitter,代码如下所示:
export class FlowChart extends EventEmitter {
// ...
constructor(container: HTMLElement, data: FlowChartData) {
super();
this.canvas = new Canvas(container, 800, 600);
// 绑定画布点击事件,用于取消选择
this.canvas.container.addEventListener('click', () => {
this.selectedNode = null;
this.connecting = false;
this.nodes.forEach(node => node.element.classList.remove('selected'));
this.emit('canvasClick');
})
this.loadData(data);
}
addNode(id: string, x: number, y: number, label: string, type?: string): BaseNode {
const node = new BaseNode(id, x, y, label, type);
node.render(this.canvas.container);
this.nodes.push(node);
// 设置节点点击事件
node.setOnClick((clickedNode: BaseNode | undefined) => {
if (clickedNode) {
if (this.connecting && this.selectedNode && this.selectedNode !== clickedNode) {
this.addEdge(`edge_${this.selectedNode.id}_${clickedNode.id}`, this.selectedNode, clickedNode, 'default');
// 抛出 edgeCreated 事件
this.emit('edgeCreated', this.selectedNode, clickedNode);
this.selectedNode.element.classList.remove('selected');
this.selectedNode = null;
this.connecting = false;
} else {
this.selectedNode = clickedNode;
this.connecting = true;
this.nodes.forEach(n => n.element.classList.remove('selected'));
clickedNode.element.classList.add('selected');
// 抛出节点选中事件
this.emit('nodeSelected', clickedNode);
}
}
})
// 设置节点右键菜单事件
node.element.addEventListener('dblclick', (e) => {
e.stopPropagation();
this.emit('nodeDbClicked', node);
// TODO: 在此处可弹出编辑框,后续供用户修改节点内容
});
node.element.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
this.emit('nodeRightClicked', node, e.clientX, e.clientY);
// 可在此处显示自定义右键菜单
})
return node;
}
// ...
}
使用事件系统
const flowchartData = {
nodes: [
{ id: "node1", x: 100, y: 100, label: "开始", type: "start" },
{ id: "node2", x: 300, y: 100, label: "步骤1", type: "default" },
{ id: "node3", x: 500, y: 100, label: "结束", type: "end" },
],
edges: [
{ id: "edge1", source: "node1", target: "node2", type: "default" },
{ id: "edge2", source: "node2", target: "node3", type: "dashed" },
],
};
const flowChart = new FlowChart(canvasElement, flowchartData);
flowChart.on("nodeSelected", (node) => {
console.log(`节点 ${node.id} 被选中`);
});
// 监听连线创建事件
flowChart.on("edgeCreated", (source, target) => {
console.log(`连线从 ${source.id} 到 ${target.id} 创建成功`);
});
// 监听节点双击事件
flowChart.on("nodeDblClicked", (node) => {
const newLabel = prompt("请输入新的节点标签:", node.label);
if (newLabel) {
node.updateLabel(newLabel);
console.log(`节点 ${node.id} 的标签更新为 ${newLabel}`);
}
});
// 监听节点右键菜单事件
flowChart.on("nodeRightClicked", (node, x, y) => {
// 显示自定义右键菜单
showContextMenu(node, x, y);
});
效果展示
右键菜单示例
<script>
function showContextMenu(node, x, y) {
// 创建菜单元素
const menu = document.createElement("div");
menu.className = "context-menu";
menu.style.position = "absolute";
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
menu.style.background = "#fff";
menu.style.border = "1px solid #ccc";
menu.style.padding = "10px";
menu.style.boxShadow = "0 2px 10px rgba(0,0,0,0.2)";
menu.innerHTML = `
<button id="delete-node">删除节点</button>
<button id="edit-node">编辑节点</button>
`;
document.body.appendChild(menu);
// 绑定菜单按钮事件
document.getElementById("delete-node").addEventListener("click", () => {
flowChart.removeNode(node.id);
document.body.removeChild(menu);
console.log(`节点 ${node.id} 被删除`);
});
document.getElementById("edit-node").addEventListener("click", () => {
const newLabel = prompt("请输入新的节点标签:", node.label);
if (newLabel) {
node.updateLabel(newLabel);
console.log(`节点 ${node.id} 的标签更新为 ${newLabel}`);
}
document.body.removeChild(menu);
});
// 点击其他地方关闭菜单
document.addEventListener("click", function handler() {
if (menu.parentElement) {
document.body.removeChild(menu);
}
document.removeEventListener("click", handler);
});
}
</script>
<style>
/* 右键菜单 CSS 样式 */
.context-menu button {
display: block;
width: 100%;
padding: 5px 10px;
background: none;
border: none;
text-align: left;
cursor: pointer;
}
.context-menu button:hover {
background-color: #f0f0f0;
}
</style>
效果描述
- 双击编辑: 用户双击节点时,弹出输入框,允许修改节点标签
- 右键菜单: 用户右键点击节点时,显示自定义菜单,提供删除和编辑选项
5. 优化图形渲染
设计思路
为了提升流程图库的性能和视觉效果,我们需要从简单的DOM渲染过渡到更高效的渲染方式,如Canvas或SVG。因为我们的 LogicFlow 也是基于 SVG 作为节点和边渲染方式,且其对矢量图形的支持和良好的交互性,所以我们下面使用 SVG 进行一些节点的渲染,以便帮助更多同学理解 SVG 渲染节点的实践。
实现步骤
在最简模型中,我们已经部分采用了SVG进行连线的渲染。接下来,我们将进一步优化图形渲染,确保节点和连线的高效渲染和交互。
// 画布类 - Canvas
class Canvas {
constructor(element, width, height) {
this.element = element;
this.width = width;
this.height = height;
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
this.svg.setAttribute('width', width);
this.svg.setAttribute('height', height);
this.svg.style.position = 'absolute';
this.svg.style.top = '0';
this.svg.style.left = '0';
this.svg.style.pointerEvents = 'none'; // 让SVG不阻挡下方元素的鼠标事件
this.element.appendChild(this.svg);
}
draw() {
// 可添加画布的基础渲染逻辑,如背景网格
}
addEdge(edgeElement) {
this.svg.appendChild(edgeElement);
}
}
性能优化
- 减少 DOM 操作:批量更新节点和连线,减少频繁的 DOM 操作,提高渲染效率
- 使用虚拟 DOM:引入虚拟 DOM 技术,如 React 的虚拟 DOM,优化渲染过程
- 分层渲染:将节点和连线分层渲染那,减少重绘区域,提高性能
示例:批量更新连线
class FlowChart {
constructor(container) {
this.canvas = new Canvas(container, 800, 600);
this.nodes = [];
this.edges = [];
this.selectedNode = null;
this.connecting = false;
this.batchUpdateEdges = false;
this.pendingEdgeUpdates = [];
// 绑定画布点击事件,用于取消选择
this.canvas.element.addEventListener('click', () => {
this.selectedNode = null;
this.connecting = false;
this.nodes.forEach(node => node.element.classList.remove('selected'));
this.emit('canvasClick');
});
}
addNode(id, x, y, label, type = 'default', style = {}) {
const node = new Node(id, x, y, label, type, style);
node.render(this.canvas.element);
this.nodes.push(node);
return node;
}
addEdge(id, sourceNode, targetNode, type = 'default') {
const edge = new Edge(id, sourceNode, targetNode, type);
edge.render(this.canvas.svg);
this.edges.push(edge);
// 当节点移动时,标记连线需要更新
sourceNode.setOnMove(() => this.markEdgeForUpdate(edge));
targetNode.setOnMove(() => this.markEdgeForUpdate(edge));
return edge;
}
markEdgeForUpdate(edge) {
if (!this.batchUpdateEdges) {
this.batchUpdateEdges = true;
requestAnimationFrame(() => this.processEdgeUpdates());
}
if (!this.pendingEdgeUpdates.includes(edge)) {
this.pendingEdgeUpdates.push(edge);
}
}
processEdgeUpdates() {
this.pendingEdgeUpdates.forEach(edge => edge.updatePosition());
this.pendingEdgeUpdates = [];
this.batchUpdateEdges = false;
}
// 更多方法...
}
效果描述
通过批量更新连线位置,减少了因节点频繁移动而导致的连线频繁重绘,提高了整体渲染性能。
总结
在本节中,我们基于最简模型,逐步拆解和实现了流程图库的核心能力,包括数据模型、节点管理、连线管理、事件系统和图形渲染等模块。通过合理的设计和优化,实现了流程图库的功能性和灵活性的提升。
尽管本节已经实现了流程图库的核心能力,但为了打造一个更为完善和实用的工具,后面会继续在下面这些方向继续优化和拓展:
- 插件系统:设计插件机制,支持用户自定义功能扩展(如导入导出、自动布局、模板支持等)。
- 可配置能力:增加配置项,允许用户自定义画布、节点和连线的样式、行为。
- 性能优化:包括虚拟滚动、大量节点的渲染优化、减少重绘和重排等。
- 多端支持:考虑如何兼容移动端,增加手势操作等。
- 架构设计:设计清晰的架构层次,如渲染层、数据层、事件层的分离。
- 引入设计模式(如MVC、MVVM)提升代码的可维护性和扩展性。
那就继续期待我们下一篇内容吧,我们将继续带大家深入探索。