06 关键能力设计 - 基于最简模型进行核心能力拓展

316 阅读10分钟

引言

在前述章节中,我们构建了一个最简易的流程图库模型,涵盖了画布(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();

效果图如下:

image.png

路径计算

为了提高连线的可读性,可以实现自动布局和路径计算功能,如避免连线交叉、采用曲线或折线等。

曲线路径实例

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);
  }
}

效果如下:

2024-12-24 17.16.09.gif

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);
});

效果展示

2024-12-24 17.25.32.gif

右键菜单示例

<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>

效果描述

  1. 双击编辑: 用户双击节点时,弹出输入框,允许修改节点标签
  2. 右键菜单: 用户右键点击节点时,显示自定义菜单,提供删除和编辑选项

2024-12-24 17.33.22.gif

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);
  }
}

性能优化

  1. 减少 DOM 操作:批量更新节点和连线,减少频繁的 DOM 操作,提高渲染效率
  2. 使用虚拟 DOM:引入虚拟 DOM 技术,如 React 的虚拟 DOM,优化渲染过程
  3. 分层渲染:将节点和连线分层渲染那,减少重绘区域,提高性能

示例:批量更新连线

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)提升代码的可维护性和扩展性。

那就继续期待我们下一篇内容吧,我们将继续带大家深入探索。