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

本文为《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 人工智能平台

相关推荐
光影少年14 小时前
React 合成事件机制、和原生事件区别、事件冒泡阻止
前端·react.js·掘金·金石计划
YFF菲菲兔21 小时前
finishConcurrentRender 源码解析
react.js
YFF菲菲兔21 小时前
reconcileChildren 源码解析
react.js
还有多久拿退休金2 天前
Ant Design Tree 搜索定位避坑指南:虚拟滚动下如何实现高亮与精准定位
前端·react.js
北极星日淘2 天前
前端 i18n 中日双语交互 + 翻译客服接口联动方案|日系海淘平台中文友好化开发实战
前端·交互
光影少年2 天前
react 原理与进阶
前端·react.js·掘金·金石计划
饼饼饼2 天前
React19 状态解惑:State 没那么神秘,一文读懂 React 状态不可变原则与 Hooks 底层链表
前端·react.js
花椒技术2 天前
RN 多包热更新实践:更新校验、运行时加载与 Bridge 缓存治理
react native·react.js·harmonyos
UXbot2 天前
帮助企业低门槛开展AI应用开发的平台推荐
前端·低代码·ui·交互·产品经理·原型模式·web app
蓝速科技2 天前
蓝速科技 AI 数字人部署与交互实战指南
人工智能·科技·交互