实现可交互的泳道图组件(React)

咱们要做啥

泳道图这东西大家应该都见过,就是把流程图按角色分成好几行,每行是一个"泳道",能清楚看到谁在什么时候干了啥。像公司里梳理业务流程、设计系统架构的时候特别好用。

咱们今天就用 React 从零撸一个,功能包括:

  • 拖拽节点移动位置
  • 画连接线
  • 编辑文字
  • 撤销重做
  • 缩放和导出

先把数据结构想清楚

这个是最重要的,数据结构设计好了后面都好办。泳道图其实就是三样东西:泳道、节点、连接线。

复制代码
// 整体数据长这样
const data = {
  lanes: [...],      // 泳道们
  nodes: [...],      // 节点们  
  connections: [...] // 连接们
}

// 泳道 - 就是一行
{
  id: 'lane_1',
  name: '前端',
  color: '#E8F4FD',  // 背景色
  order: 0           // 排序用
}

// 节点 - 泳道里的一个个方块
{
  id: 'node_1',
  laneId: 'lane_1',      // 属于哪个泳道
  text: '用户点击按钮',   // 显示的文字
  type: 'process',       // 类型:start/end/process/decision/database
  x: 100,                // 横向位置
  width: 110,
  height: 55
}

// 连接线 - 节点之间的箭头
{
  id: 'conn_1',
  from: 'node_1',    // 从哪来
  to: 'node_2',      // 到哪去
  label: '成功'      // 线上的标签(可选)
}

这么设计有个好处:AI 可以直接生成这个数据格式,你描述一下业务流程,AI 吐出 JSON,泳道图就出来了。

状态管理用 Zustand

为啥选 Zustand?简单呗,没那么多花里胡哨的模板代码。

复制代码
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

export const useSwimlaneStore = create(
  persist(
    (set, get) => ({
      // 数据
      data: DEFAULT_DATA,
      selectedNodeId: null,
      
      // 添加节点
      addNode: (laneId, text, type, x) => {
        const newNode = {
          id: generateId(),
          laneId, text, type, x,
          width: 110, height: 55
        };
        set(state => ({
          data: { ...state.data, nodes: [...state.data.nodes, newNode] }
        }));
      },
      
      // 移动节点
      updateNodePosition: (nodeId, x) => {
        set(state => ({
          data: {
            ...state.data,
            nodes: state.data.nodes.map(n => 
              n.id === nodeId ? { ...n, x } : n
            )
          }
        }));
      },
      
      // 添加连接
      addConnection: (from, to) => {
        // 先看看是不是已经连过了
        const exists = get().data.connections.some(c => 
          c.from === from && c.to === to
        );
        if (exists) return;
        
        set(state => ({
          data: {
            ...state.data,
            connections: [...state.data.connections, { id: generateId(), from, to }]
          }
        }));
      }
    }),
    { name: 'swimlane-storage' }  // 自动存 localStorage
  )
);

persist 中间件,数据自动存到 localStorage,刷新页面不会丢。

渲染泳道和节点

泳道就是一行一行的 div

复制代码
const renderLane = (lane, index) => (
  <div className={styles.lane} style={{ backgroundColor: lane.color }}>
    {/* 左边的标题栏 */}
    <div className={styles.laneHeader}>
      <span>{lane.name}</span>
      <Button onClick={() => addNode(lane.id)}>添加节点</Button>
    </div>
    
    {/* 右边的节点区域 */}
    <div className={styles.laneContent}>
      {nodes.filter(n => n.laneId === lane.id).map(renderNode)}
    </div>
  </div>
);

节点是绝对定位的

节点在泳道里是 position: absolute,通过 left 控制横向位置:

复制代码
const renderNode = (node) => {
  const isSelected = selectedNodeId === node.id;
  
  return (
    <div
      className={`${styles.node} ${isSelected ? styles.nodeSelected : ''}`}
      style={{
        left: node.x,
        top: (laneHeight - node.height) / 2,  // 垂直居中
        width: node.width,
        height: node.height
      }}
      onMouseDown={handleDrag}
      onClick={() => selectNode(node.id)}
    >
      <span>{node.text}</span>
      
      {/* 右边的小圆点,用来创建连接 */}
      <div 
        className={styles.portRight}
        onClick={(e) => startConnection(e, node.id)}
      />
    </div>
  );
};

画连接线(重点)

连接线用 SVG 画,贝塞尔曲线,看起来更流畅。

复制代码
const renderConnections = () => {
  return connections.map(conn => {
    const fromNode = getNode(conn.from);
    const toNode = getNode(conn.to);
    
    // 算出起点终点坐标
    const fromX = fromNode.x + fromNode.width;  // 起点在节点右边
    const fromY = getLaneY(fromNode.laneId);    // 泳道中间
    const toX = toNode.x;                       // 终点在节点左边
    const toY = getLaneY(toNode.laneId);
    
    // 贝塞尔曲线的控制点偏移量
    const offset = Math.min(Math.abs(toX - fromX) / 2, 80);
    
    // SVG 路径
    const path = `M ${fromX} ${fromY} 
                  C ${fromX + offset} ${fromY}, 
                    ${toX - offset} ${toY}, 
                    ${toX} ${toY}`;
    
    return (
      <path 
        d={path} 
        stroke="#9e9e9e" 
        strokeWidth={1.5}
        markerEnd="url(#arrow)"  // 箭头
      />
    );
  });
};

曲线的效果就是节点之间不是直愣愣的线,而是有个自然的弧度。

拖拽功能

拖拽其实就三步:mousedown 记录起始位置,mousemove 更新位置,mouseup 结束。

复制代码
// 开始拖
const handleMouseDown = (e, nodeId) => {
  const rect = e.currentTarget.getBoundingClientRect();
  setDragging({
    nodeId,
    offsetX: e.clientX - rect.left  // 鼠标点在节点上的偏移
  });
};

// 拖动中
useEffect(() => {
  const handleMouseMove = (e) => {
    if (!dragging) return;
    
    const canvasRect = canvasRef.current.getBoundingClientRect();
    const newX = e.clientX - canvasRect.left - dragging.offsetX;
    
    updateNodePosition(dragging.nodeId, newX);
  };
  
  document.addEventListener('mousemove', handleMouseMove);
  return () => document.removeEventListener('mousemove', handleMouseMove);
}, [dragging]);

// 结束拖
const handleMouseUp = () => {
  setDragging(null);
  saveToHistory();  // 保存历史,方便撤销
};

注意事件要绑在 document 上,不然拖快了会"掉"。

创建连接

点击节点右边的小圆点开始,再点另一个节点结束:

复制代码
// 点击右边端口,开始画线
const startConnection = (e, nodeId) => {
  e.stopPropagation();
  setIsConnecting(true);
  setConnectionStart(nodeId);
};

// 点到另一个节点,完成连接
const endConnection = (e, nodeId) => {
  if (!isConnecting || connectionStart === nodeId) return;
  
  addConnection(connectionStart, nodeId);
  setIsConnecting(false);
  setConnectionStart(null);
};

// 按 ESC 取消
useEffect(() => {
  const handleKeyDown = (e) => {
    if (e.key === 'Escape' && isConnecting) {
      setIsConnecting(false);
      setConnectionStart(null);
    }
  };
  document.addEventListener('keydown', handleKeyDown);
  return () => document.removeEventListener('keydown', handleKeyDown);
}, [isConnecting]);

撤销重做

这个其实就是一个历史记录数组,每次操作都 push 一份快照:

复制代码
// 状态
history: [],
historyIndex: -1,

// 操作前保存快照
saveToHistory: () => {
  const { data, history, historyIndex } = get();
  const newHistory = [...history.slice(0, historyIndex + 1), JSON.parse(JSON.stringify(data))];
  set({ history: newHistory, historyIndex: newHistory.length - 1 });
},

// 撤销 = 回到上一个快照
undo: () => {
  const { history, historyIndex } = get();
  if (historyIndex > 0) {
    set({ data: history[historyIndex - 1], historyIndex: historyIndex - 1 });
  }
},

// 重做 = 去到下一个快照
redo: () => {
  const { history, historyIndex } = get();
  if (historyIndex < history.length - 1) {
    set({ data: history[historyIndex + 1], historyIndex: historyIndex + 1 });
  }
}

快捷键 Ctrl+Z 撤销,Ctrl+Shift+Z 重做。

样式的小技巧

几个让界面更好看的小细节:

  1. 节点用渐变背景,不同类型不同颜色

  2. hover 时节点往上浮一点transform: translateY(-2px)

  3. 选中状态用蓝色边框 + 阴影

  4. 连接线要细一点(1.5px),灰色比黑色好看

  5. 泳道头部固定宽度,内容区可以滚动

    .node {
    border-radius: 10px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
    transition: all 0.2s;

    &:hover {
    transform: translateY(-2px);
    box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
    }
    }

    .nodeSelected {
    border: 2px solid #1890ff;
    box-shadow: 0 0 0 3px rgba(24, 144, 255, 0.2);
    }

导出功能

导出 JSON 就是把数据序列化一下:

复制代码
const exportJSON = () => {
  const json = JSON.stringify(data, null, 2);
  const blob = new Blob([json], { type: 'application/json' });
  const url = URL.createObjectURL(blob);
  
  const a = document.createElement('a');
  a.href = url;
  a.download = `swimlane-${Date.now()}.json`;
  a.click();
};

导出 SVG 就是把画布上的 SVG 元素序列化:

复制代码
const exportSVG = () => {
  const svgData = new XMLSerializer().serializeToString(svgRef.current);
  const blob = new Blob([svgData], { type: 'image/svg+xml' });
  // ... 同上
};

最后

整个组件核心就是:

  1. 数据结构设计好
  2. 节点用绝对定位
  3. 连线用 SVG 贝塞尔曲线
  4. 事件处理注意绑定到 document
相关推荐
optimistic_chen1 小时前
【Vue3 入门】掌握这些才能优雅上手
前端·javascript·vue.js·前端框架·visual studio code
JEECG低代码平台1 小时前
终端里的AI搭档:我用Claude Code提效的实战心得
前端·人工智能·chrome
HelloReader2 小时前
Flutter ChangeNotifier用 ViewModel 管理应用状态(九)
前端
用户4099322502122 小时前
Vue 3 静态与动态 Props 如何传递?TypeScript 类型约束有何必要?
前端·vue.js·后端
程序员库里2 小时前
TipTap简介
前端·javascript·面试
关中老四2 小时前
【js/web甘特图插件MZGantt】如何使用外部弹框添加和修改任务(updRows方法使用说明)
前端·javascript·甘特图·甘特图插件
大雷神2 小时前
HarmonyOS APP<玩转React>开源教程四:状态管理基础
react.js·开源·harmonyos
nunumaymax2 小时前
css实现元素和文字从右向左排列
前端·css