前端图形学← 返回文章列表

流程图自定义连线算法与吸附计算:基于 React Flow 的 SVG 自定义 Edge 渲染

从起点终点与端口朝向推导贝塞尔控制点,结合 React Flow 的 EdgeLabelRenderer 实现带交互按钮的自定义连线。

LJ
李建辉·前端全干工程师

· 8 分钟

本页目录展开 / 收起

读完你能做什么

  • 从起点、终点和方向向量推导贝塞尔曲线控制点。
  • 把几何计算与 React Flow 的 Edge 渲染组件分开实现和测试。
  • 在节点拖动时保持路径更新,并为标签、删除按钮留出交互层。

实现时先保证“同一组坐标总能得到同一路径”,再接入拖拽与交互。这样几何错误和 React 状态错误不会混在一起排查。

做流程图或拓扑图时,连线(Edge)往往比节点本身更考验细节:两个节点错位排列时,连线怎么拐弯才自然?节点拖动时连线会不会滞后?更麻烦的是,如果想在连线中间加一个“删除按钮”或“分支条件输入框”,直接在 SVG 里面放 HTML 标签很容易碰到事件穿透和缩放计算问题。这篇文章梳理三次贝塞尔曲线控制点的计算,并用 React Flow 实现带交互的自定义连线。


连线算法核心:贝塞尔曲线控制点计算

在二维平面中,从源节点坐标 $(x_1, y_1)$ 到目标节点坐标 $(x_2, y_2)$,平滑的 S 型或弧形连线通常采用三次贝塞尔曲线:

TEXT
三次贝塞尔公式:
  B(t) = (1-t)³P₀ + 3(1-t)²tP₁ + 3(1-t)t²P₂ + t³P₃,  t ∈ [0, 1]

SVG 路径指令:
  M sourceX sourceY C controlX1 controlY1, controlX2 controlY2, targetX targetY
TEXT
贝塞尔控制点拓扑图:
  (Source: P₀) ──────► [Control Point 1: P₁]
                                │
                                ▼
  (Target: P₃) ◄────── [Control Point 2: P₂]
  • 水平流向:$P_1 = (x_1 + \Delta x / 2, y_1)$,$P_2 = (x_2 - \Delta x / 2, y_2)$;
  • 垂直流向:$P_1 = (x_1, y_1 + \Delta y / 2)$,$P_2 = (x_2, y_2 - \Delta y / 2)$。

核心实现:React Flow 自定义连线组件

通过实现一个带有“删除按钮”和“条件标签”的 Custom Edge:

TSX
// components/edges/CustomFlowEdge.tsx
'use client';
 
import React from 'react';
import {
  type EdgeProps,
  getBezierPath,
  EdgeLabelRenderer,
  BaseEdge,
} from '@xyflow/react';
 
export function CustomFlowEdge({
  id,
  sourceX,
  sourceY,
  targetX,
  targetY,
  sourcePosition,
  targetPosition,
  style = {},
  markerEnd,
  data,
}: EdgeProps) {
  // 1. 计算平滑贝塞尔 SVG Path 与中点坐标
  const [edgePath, labelX, labelY] = getBezierPath({
    sourceX,
    sourceY,
    sourcePosition,
    targetX,
    targetY,
    targetPosition,
  });
 
  return (
    <>
      {/* 基础 SVG 连线渲染 */}
      <BaseEdge
        path={edgePath}
        markerEnd={markerEnd}
        style={{
          strokeWidth: 2,
          stroke: '#3b82f6',
          ...style,
        }}
      />
 
      {/* 使用 EdgeLabelRenderer 渲染脱离 SVG 坐标系的绝对定位 DOM 按钮 */}
      <EdgeLabelRenderer>
        <div
          style={{
            position: 'absolute',
            transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
            pointerEvents: 'all',
          }}
          className="nodrag nopan flex items-center gap-1 bg-white px-2 py-1 border rounded-md shadow-sm text-xs"
        >
          <span>{data?.label || '流转条件'}</span>
          <button
            onClick={(e) => {
              e.stopPropagation();
              data?.onDelete?.(id);
            }}
            className="w-4 h-4 rounded-full bg-red-100 text-red-600 hover:bg-red-200 flex items-center justify-center text-[10px]"
          >
            ×
          </button>
        </div>
      </EdgeLabelRenderer>
    </>
  );
}

注册与在 React Flow 中使用

TSX
import { ReactFlow, type EdgeTypes } from '@xyflow/react';
import { CustomFlowEdge } from './components/edges/CustomFlowEdge';
 
// 必须在组件外定义 edgeTypes,避免重复渲染
const edgeTypes: EdgeTypes = {
  custom: CustomFlowEdge,
};
 
export function FlowEditor() {
  return (
    <div style={{ width: '100vw', height: '100vh' }}>
      <ReactFlow
        nodes={initialNodes}
        edges={initialEdges}
        edgeTypes={edgeTypes}
        fitView
      />
    </div>
  );
}

官方资料

实现要点

  • SVG 与 HTML 交互隔离:连线本身用 SVG <path> 绘制性能最高,但连线上的按钮或输入框推荐使用 EdgeLabelRenderer 渲染为绝对定位的 HTML DOM,这样既能正常接收鼠标点击事件,又不会受 SVG 内部 foreignObject 的兼容性限制。
  • 避免在组件体内创建 edgeTypes:React Flow 要求 edgeTypes 对象定义在组件外部或通过 useMemo 缓存。如果在父组件内每次 render 都声明一个新字面量,会导致画布上所有连线在每次状态变动时全部被销毁重建。

评论