React Flow 节点事件处理实战:鼠标 / 键盘事件全解析(含节点交互代码示例)

发布于:2025-05-18 ⋅ 阅读:(18) ⋅ 点赞:(0)

 本文为《React Agent:从零开始构建 AI 智能体》专栏系列文章。 专栏地址:https://blog.csdn.net/suiyingy/category_12933485.html。项目地址:https://gitee.com/fgai/react-agent(含完整代码示​例与实战源)。完整介绍:https://blog.csdn.net/suiyingy/article/details/146983582。

1 鼠标事件

        React Flow 为节点提供了丰富的鼠标事件,如onClick、onDoubleClick、onMouseEnter、onMouseLeave等。这些事件可以用于实现节点的各种交互功能。onClick事件可以用于打开节点的详细信息面板,onMouseEnter事件可以用于显示节点的提示信息。

const InfoNode = ({ data }) => {
  return (
    <div
      className="react-flow__node-default"
      onClick={() => alert('This is the info of the node')}
      onMouseEnter={() => console.log('Mouse entered the node')}
      onMouseLeave={() => console.log('Mouse left the node')}
    >
      {data.label}
    </div>
  );
};

        alert和console.log都是 JavaScript 中常用的工具,在调试程序时发挥着不同作用。alert是浏览器提供的一个全局函数,它会弹出一个包含指定消息的对话框,此对话框会暂停当前程序的执行,直到用户点击“确定”关闭它。在调试中,它可用于快速确认变量的值或某些代码块是否被执行。不过,由于它会中断程序,若频繁使用会影响用户体验,并且只能展示简单的文本信息。

        console.log则是向浏览器的控制台输出信息。控制台是开发者调试代码的重要工具,它可以显示各种类型的数据,如字符串、数字、对象、数组等。使用console.log不会中断程序执行,所以能在程序运行过程中持续输出信息,方便开发者观察变量的变化和程序的执行流程。

        若要在浏览器中查看console.log输出的信息,不同浏览器的操作方式略有不同,但基本步骤一致。首先,在浏览器中打开包含该代码的网页;然后,打开开发者工具,常见的方法是在网页上右键点击,选择 “检查” 或者 “审查元素”,也可以使用快捷键(通常为 F12);打开开发者工具后,切换到 “控制台”(Console)面板,在这里就能看到console.log输出的信息。运行上面程序后会有如下输出。

图1 鼠标事件

        我们也可对连接点 Handle 设置事件,下面程序可双击修改 Handle 名称。

import React, { useCallback, useContext, useState } from 'react';
import { ReactFlow, Handle, useNodesState, useEdgesState, addEdge } from 'reactflow';
import 'reactflow/dist/style.css';
import { FiDatabase, FiCloud } from 'react-icons/fi';
import { toast, Toaster } from 'react-hot-toast';

// 创建上下文用于节点更新
const NodeUpdateContext = React.createContext();

const CustomNode = ({ id, data, selected }) => {
  const setNodes = useContext(NodeUpdateContext);
  const [editingHandle, setEditingHandle] = useState(null);
  const [tempName, setTempName] = useState('');

  const handleNameChange = (handleId, newName) => {
    setNodes(nds => nds.map(node => 
      node.id === id ? {
        ...node,
        data: {
          ...node.data,
          handleNames: {
            ...node.data.handleNames,
            [handleId]: newName
          }
        }
      } : node
    ));
  };

  const startEdit = (handleId) => {
    setEditingHandle(handleId);
    setTempName(data.handleNames?.[handleId] || '');
  };

  const confirmEdit = () => {
    if (editingHandle) {
      handleNameChange(editingHandle, tempName);
      setEditingHandle(null);
    }
  };

  const renderHandle = (localId, position, type) => {
    const fullId = `${id}-${localId}`;
    const isEditing = editingHandle === fullId;
    const displayName = data.handleNames?.[fullId] || '';

    return (
      <div className={`handle-group handle-${position}`}>
        {isEditing && (
          <input
            type="text"
            value={tempName}
            onChange={(e) => setTempName(e.target.value)}
            onBlur={confirmEdit}
            onKeyPress={(e) => e.key === 'Enter' && confirmEdit()}
            autoFocus
            className="handle-input"
          />
        )}
        <Handle
          id={fullId}
          type={type}
          position={position}
          className={`!bg-${type === 'target' ? 'teal' : 'purple'}-500`}
          onDoubleClick={() => startEdit(fullId)}
          title={displayName}
        />
      </div>
    );
  };

  return (
    <div className={`custom-node ${selected ? 'selected' : ''}`}>
      {renderHandle('target-top', 'top', 'target')}
      
      <div className="node-header">
        <FiCloud className="node-icon" />
        <h3 className="node-title">{data.label}</h3>
      </div>
      <div className="node-body">
        <FiDatabase className="node-icon" />
        <span className="node-info">{data.content}</span>
      </div>

      {renderHandle('source-bottom', 'bottom', 'source')}
      {renderHandle('source-right', 'right', 'source')}
    </div>
  );
};

// 初始节点配置(保持其他内容不变,增加handleNames字段)
const initialNodes = [
  { 
    id: '1', 
    position: { x: 0, y: 0 }, 
    data: { 
      label: '开始节点',
      content: '输入数据源',
      handleNames: {
        '1-target-top': '输入',
        '1-source-bottom': '主输出',
        '1-source-right': '备选输出'
      }
    },
    type: 'custom',
  },
  { 
    id: '2', 
    position: { x: 200, y: 150 }, 
    data: { 
      label: '处理节点',
      content: '数据处理流程',
      handleNames: {
        '2-target-top': '输入',
        '2-source-bottom': '结果输出',
        '2-source-right': '日志输出'
      }
    },
    type: 'custom',
  },
];

const initialEdges = [{ 
  id: 'e1-2', 
  source: '1', 
  target: '2',
  animated: true,
  style: { stroke: '#94a3b8' },
}];

const nodeTypes = {
  custom: CustomNode,
};

// 样式增加连接点标签相关样式
const nodeStyle = `
  .custom-node {
    position: relative;
    background: linear-gradient(145deg, #ffffff, #f1f5f9);
    border-radius: 8px;
    border: 2px solid #cbd5e1;
    box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
    padding: 16px;
    min-width: 200px;
    transition: all 0.2s ease;
  }

  .custom-node.selected {
    border-color: #6366f1;
    box-shadow: 0 4px 15px rgba(99, 102, 241, 0.2);
  }

  .custom-node:hover {
    transform: translateY(-2px);
  }

  .node-header {
    display: flex;
    align-items: center;
    margin-bottom: 12px;
    border-bottom: 1px solid #e2e8f0;
    padding-bottom: 8px;
  }

  .node-title {
    margin: 0;
    font-size: 1.1rem;
    color: #1e293b;
    margin-left: 8px;
  }

  .node-body {
    display: flex;
    align-items: center;
    color: #64748b;
  }

  .node-icon {
    font-size: 1.2rem;
    margin-right: 8px;
    color: #6366f1;
  }

  .node-info {
    font-size: 0.9rem;
  }

  .react-flow__handle {
    width: 24px;
    height: 14px;
    border-radius: 3px;
    border: none;
    cursor: pointer;
    transition: background-color 0.2s;
  }
  .react-flow__handle:hover {
    filter: brightness(1.2);
  }
    /* 按位置调整具体坐标 */
.react-flow__handle[data-position="top"] {
  top: -7px !important;
  left: 50% !important;
  transform: translateX(-50%) !important;
}

.react-flow__handle[data-position="bottom"] {
  bottom: -7px !important;
  left: 50% !important;
  transform: translateX(-50%) !important;
}

.react-flow__handle[data-position="right"] {
  right: -12px !important;
  top: 50% !important;
  transform: translateY(-50%) !important;
}
  .handle-group {
    z-index: 10;
  }
  .handle-input {
    position: absolute;
    width: 80px;
    padding: 2px 4px;
    font-size: 0.8rem;
    border: 1px solid #6366f1;
    border-radius: 4px;
    background: white;
    z-index: 100;
  }
    .handle-top .handle-input {
    bottom: calc(100% + 8px);
    left: 50%;
    transform: translateX(-50%);
  }

  .handle-bottom .handle-input {
    top: calc(100% + 8px);
    left: 50%;
    transform: translateX(-50%);
  }

  .handle-right .handle-input {
    left: calc(100% + 8px);
    top: 50%;
    transform: translateY(-50%);
  }
`;


export default function App() {
  const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
  const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);

  return (
    <NodeUpdateContext.Provider value={setNodes}>
      <div style={{ height: '100vh', background: '#f8fafc' }}>
        <style>{nodeStyle}</style>
        <Toaster position="top-right" />
        <ReactFlow
          nodes={nodes}
          edges={edges}
          onNodesChange={onNodesChange}
          onEdgesChange={onEdgesChange}
          onConnect={useCallback((conn) => 
            setEdges(eds => addEdge({
              ...conn,
              animated: true,
              style: conn.sourceHandle?.endsWith('-right') 
                ? { stroke: '#ec4899' } 
                : { stroke: '#94a3b8' }
            }, eds)), [setEdges])}
          nodeTypes={nodeTypes}
          isValidConnection={useCallback((conn) => 
            conn.source !== conn.target && !edges.some(e => e.target === conn.target), [edges])}
          fitView
          defaultEdgeOptions={{ 
            type: 'smoothstep', 
            animated: true,
            style: { strokeWidth: 2 }
          }}
        />
      </div>
    </NodeUpdateContext.Provider>
  );
}

2 键盘事件

        在某些场景下,需要通过键盘来操作节点,React Flow 支持键盘事件。可以监听onKeyDown、onKeyUp等事件,实现节点的键盘导航、快捷键操作等功能。例如,通过按下特定的快捷键来删除节点:

const InfoNode = ({ data }) => {
    const handleKeyDown = (event) => {
        if (event.key === 'Delete') {
            alert('节点将被删除');
        }
    };

    return (
        <div
            className="react-flow__node-default"
            tabIndex={0}
            onKeyDown={handleKeyDown}
        >
            {data.label}
        </div>
    );
};

3 拖动与连接事件

        与节点的拖动和连接相关的事件也非常重要。onDragStart、onDrag、onDragEnd事件可以用于处理节点拖动过程中的逻辑,如更新节点的位置、限制拖动范围等。onConnect事件则在节点之间建立连接时触发,可以用于验证连接的合法性、更新数据等。需要注意,直接在自定义节点组件上使用 draggable 和原生的 onDragStart 等事件会与 React Flow 的拖拽行为冲突。如果开发者希望为节点添加额外的拖动逻辑,可以利用 React Flow 自身提供的事件(例如 onNodeDragStart、onNodeDrag 和 onNodeDragStop)来实现。

import React, { useCallback } from 'react';
import {
    ReactFlow,
    Handle,
    useNodesState,
    useEdgesState,
    addEdge,
} from 'reactflow';
import 'reactflow/dist/style.css';

// 自定义节点组件
const InfoNode = ({ data }) => {
    return (
        <div className="react-flow__node-default">
            {data.label}
        </div>
    );
};

const nodeTypes = {
    infoNode: InfoNode,
};

// 节点配置
const infoNode = {
    id: 'info-node-1',
    type: 'infoNode',
    data: { label: 'Info Node' },
    position: { x: 250, y: 100 },
};

const initialNodes = [infoNode];
const initialEdges = [];

export default function App() {
    const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
    const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);

    const onConnect = useCallback(
        (params) => setEdges((eds) => addEdge(params, eds)),
        [setEdges]
    );

    const handleNodeDragStart = (event, node) => {
        console.log('Node drag started:', node);
    };

    const handleNodeDrag = (event, node) => {
        console.log('Node dragging:', node);
    };

    const handleNodeDragStop = (event, node) => {
        console.log('Node drag stopped:', node);
    };

    return (
        <div style={{ width: '100vw', height: '100vh' }}>
            <ReactFlow
                nodes={nodes}
                edges={edges}
                nodeTypes={nodeTypes}
                onNodesChange={onNodesChange}
                onEdgesChange={onEdgesChange}
                onConnect={onConnect}
                onNodeDragStart={handleNodeDragStart}
                onNodeDrag={handleNodeDrag}
                onNodeDragStop={handleNodeDragStop}
                fitView
            />
        </div>
    );
}

4 创建自定义事件

        除了使用内置事件,开发者还可以根据项目需求创建自定义事件。在 React 里,自定义事件属于开发者自行定义的事件,它可以让组件之间更好地进行通信和交互。它能够在特定条件达成时触发特定的行为,从而增强组件的灵活性与可复用性。而 useEffect 是 React 提供的一个 Hook,其主要作用是处理副作用操作,像数据获取、订阅、DOM 操作等。它会在组件渲染之后执行,并且可以依据依赖项数组的变化来决定是否重新执行。通过结合自定义事件和 useEffect,我们能够在组件的生命周期内灵活地触发和处理自定义事件,从而实现更为复杂的交互逻辑。

const InfoNode = ({ data }) => {
  const nodeRef = useRef(null);

  useEffect(() => {
    const nodeEl = nodeRef.current;
    if (!nodeEl) return;

    const handleClick = () => alert('This is the info of the node');
    const handleMouseEnter = () => console.log('Mouse entered the node');
    const handleMouseLeave = () => console.log('Mouse left the node');

    nodeEl.addEventListener('click', handleClick);
    nodeEl.addEventListener('mouseenter', handleMouseEnter);
    nodeEl.addEventListener('mouseleave', handleMouseLeave);

    // 清理
    return () => {
      nodeEl.removeEventListener('click', handleClick);
      nodeEl.removeEventListener('mouseenter', handleMouseEnter);
      nodeEl.removeEventListener('mouseleave', handleMouseLeave);
    };
  }, []); // 空依赖数组,确保只在挂载和卸载时执行

  return (
    <div
      ref={nodeRef}
      className="react-flow__node-default"
    >
      {data.label}
    </div>
  );
};

5 事件冒泡与捕获

        开发者在处理多个节点的事件时需要了解事件冒泡和捕获机制。事件冒泡是指事件从最内层的元素开始触发,然后逐级向上传播到外层元素;事件捕获则相反,从最外层元素开始,逐级向内层元素传播。合理利用事件冒泡和捕获能够实现更高效的事件处理。例如,在一个包含多个节点的区域中,我们可以在父元素上监听事件,通过事件属性来判断具体是哪个节点触发了事件,从而减少事件处理函数的重复定义。

import React, { useCallback } from 'react';
import {
  ReactFlow,
  Handle, 
  useNodesState,
  useEdgesState,
  addEdge,
} from 'reactflow';
import 'reactflow/dist/style.css';

// 自定义节点组件
const InfoNode = ({ data }) => {
  const { onNodeClick, onNodeMouseEnter, onNodeMouseLeave, label } = data;
  return (
    <div
      className="react-flow__node-default"
      onClick={() => onNodeClick(label)}
      onMouseEnter={() => onNodeMouseEnter(label)}
      onMouseLeave={() => onNodeMouseLeave(label)}
    >
      {label}
    </div>
  );
};

const nodeTypes = {
  infoNode: InfoNode,
};

// 节点配置
const infoNode1 = {
  id: 'info-node-1',
  type: 'infoNode',
  data: { label: 'Info Node 1' },
  position: { x: 250, y: 100 },
};

const infoNode2 = {
  id: 'info-node-2',
  type: 'infoNode',
  data: { label: 'Info Node 2' },
  position: { x: 500, y: 100 },
};

const initialNodes = [infoNode1, infoNode2];
const initialEdges = [];

export default function App() {
  const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
  const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);

  const onNodeClick = (label) => {
    alert(`This is the info of ${label}`);
  };

  const onNodeMouseEnter = (label) => {
    console.log(`Mouse entered the ${label}`);
  };

  const onNodeMouseLeave = (label) => {
    console.log(`Mouse left the ${label}`);
  };

  const newNodes = nodes.map((node) => ({
    ...node,
    data: {
      ...node.data,
      onNodeClick,
      onNodeMouseEnter,
      onNodeMouseLeave,
    },
  }));

  const onConnect = useCallback(
    (params) => setEdges((eds) => addEdge(params, eds)),
    [setEdges]
  );

  return (
    <div style={{ width: '100vw', height: '100vh' }}>
      <ReactFlow
        nodes={newNodes}
        edges={edges}
        nodeTypes={nodeTypes}
        onNodesChange={onNodesChange}
        onEdgesChange={onEdgesChange}
        onConnect={onConnect}
        fitView
      />
    </div>
  );
}

  立即关注获取最新动态

点击订阅《React Agent 开发专栏》,每周获取智能体开发深度教程。项目代码持续更新至React Agent 开源仓库,欢迎 Star 获取实时更新通知!FGAI 人工智能平台FGAI 人工智能平台


网站公告

今日签到

点亮在社区的每一天
去签到