You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于react-dnd实现可拖拽树形视图组件的技术问询

我来帮你一步步搞定这个基于react-dnd的可拖拽树形视图!之前我也踩过react-sortable-tree定制的坑,确实太臃肿了,自己用react-dnd搭反而灵活得多,刚好你已经有了基础组件,咱们来完善它~


第一步:先搞定react-dnd的基础配置

首先确保你已经安装了依赖:

npm install react-dnd react-dnd-html5-backend

然后在树形视图的父组件(或者根组件)里用DndProvider包裹,这是react-dnd的核心容器:

import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import TreeView from './TreeView';

function App() {
  return (
    <DndProvider backend={HTML5Backend}>
      <TreeView />
    </DndProvider>
  );
}
第二步:完善Node组件的拖拽/放置能力

你提到已经有了Node组件的雏形,咱们把它改造成支持拖拽源(Drag Source)和放置目标(Drop Target)的组件。这里我用函数组件+hooks的写法(比类组件更简洁),如果你习惯类组件可以转成高阶组件形式:

import { useDrag, useDrop } from 'react-dnd';
import PropTypes from 'prop-types';

// 定义拖拽类型常量,避免拼写错误
const ITEM_TYPE = 'TREE_NODE';

function Node({ data, depth, isGroup, onMove, isExpanded, toggleExpand, children }) {
  // 配置拖拽源:让节点可拖拽
  const [{ isDragging }, connectDragSource] = useDrag({
    type: ITEM_TYPE,
    item: { id: data.id, parentId: data.parentId }, // 传递节点唯一标识和父节点ID
    collect: (monitor) => ({
      isDragging: monitor.isDragging(),
    }),
  });

  // 配置放置目标:让节点可以接收拖拽的元素
  const [{ isOver, canDrop }, connectDropTarget] = useDrop({
    accept: ITEM_TYPE,
    drop: (item, monitor) => {
      // 根据鼠标位置判断放置逻辑:前/后/作为子节点
      const dropPosition = monitor.getClientOffset();
      const nodeElement = monitor.getDropTargetElement();
      const rect = nodeElement.getBoundingClientRect();
      const middleY = rect.top + rect.height / 2;

      let position = 'after';
      if (dropPosition.y < middleY) {
        position = 'before';
      } else if (isGroup) {
        position = 'child';
      }

      onMove(item.id, data.id, position);
    },
    collect: (monitor) => ({
      isOver: monitor.isOver(),
      canDrop: monitor.canDrop(),
    }),
  });

  // 拖拽/放置时的视觉样式
  const nodeStyle = {
    marginLeft: `${depth * 20}px`,
    opacity: isDragging ? 0.5 : 1,
    cursor: 'move',
    backgroundColor: isOver && canDrop ? '#e0f7fa' : 'transparent',
    padding: '8px',
    borderRadius: '4px',
    margin: '4px 0',
    transition: 'background-color 0.2s',
  };

  // 连接拖拽和放置目标到节点容器
  const connectedNode = connectDragSource(connectDropTarget(
    <div style={nodeStyle}>
      {/* 节点内容:名称+展开折叠按钮(组节点专用) */}
      {isGroup && (
        <button onClick={toggleExpand} style={{ marginRight: '8px', border: 'none', background: 'transparent' }}>
          {isExpanded ? '▼' : '▶'}
        </button>
      )}
      <span>{data.name}</span>
      {/* 仅展开时显示子节点 */}
      {isExpanded && children}
    </div>
  ));

  return connectedNode;
}

Node.propTypes = {
  data: PropTypes.shape({
    id: PropTypes.string.isRequired,
    name: PropTypes.string.isRequired,
    parentId: PropTypes.string,
  }).isRequired,
  depth: PropTypes.number.isRequired,
  isGroup: PropTypes.bool.isRequired,
  onMove: PropTypes.func.isRequired,
  isExpanded: PropTypes.bool,
  toggleExpand: PropTypes.func,
  children: PropTypes.node,
};

Node.defaultProps = {
  isExpanded: true,
};

export default Node;

如果坚持用类组件,可以改用DragSource和DropTarget高阶组件,核心逻辑是一样的,只是写法不同。

第三步:树形结构的状态管理与递归渲染

接下来需要一个父组件来管理树形数据的状态,处理节点移动逻辑,并递归渲染所有节点:

import { useState, useCallback } from 'react';
import Node from './Node';
import React from 'react';

// 示例树形数据(扁平数组存储,方便查找和修改)
const initialTreeData = [
  { id: '1', name: '产品组', parentId: null, isGroup: true },
  { id: '2', name: 'PC端产品', parentId: '1', isGroup: false },
  { id: '3', name: '移动端产品', parentId: '1', isGroup: false },
  { id: '4', name: '技术组', parentId: null, isGroup: true },
  { id: '5', name: '前端开发', parentId: '4', isGroup: false },
  { id: '6', name: '后端开发', parentId: '4', isGroup: false },
];

// 辅助函数:判断目标节点是否是源节点的后代(避免循环引用)
const isDescendant = (sourceId, targetId, treeData) => {
  let current = targetId;
  while (current) {
    const node = treeData.find(n => n.id === current);
    if (!node) break;
    if (node.parentId === sourceId) return true;
    current = node.parentId;
  }
  return false;
};

function TreeView() {
  const [treeData, setTreeData] = useState(initialTreeData);
  // 记录组节点的展开状态
  const [expandedGroups, setExpandedGroups] = useState(new Set(['1', '4']));

  // 处理节点移动的核心函数
  const handleMoveNode = useCallback((sourceId, targetId, position) => {
    const sourceNode = treeData.find(n => n.id === sourceId);
    const targetNode = treeData.find(n => n.id === targetId);
    if (!sourceNode || !targetNode) return;

    // 禁止拖放到自己的后代节点里,避免循环引用
    if (isDescendant(sourceId, targetId, treeData)) return;

    let updatedData = [...treeData];
    const sourceIndex = updatedData.indexOf(sourceNode);

    switch (position) {
      case 'before':
        // 同层级,放在目标节点前面
        sourceNode.parentId = targetNode.parentId;
        updatedData.splice(sourceIndex, 1);
        const targetBeforeIndex = updatedData.indexOf(targetNode);
        updatedData.splice(targetBeforeIndex, 0, sourceNode);
        break;
      case 'after':
        // 同层级,放在目标节点后面
        sourceNode.parentId = targetNode.parentId;
        updatedData.splice(sourceIndex, 1);
        const targetAfterIndex = updatedData.indexOf(targetNode);
        updatedData.splice(targetAfterIndex + 1, 0, sourceNode);
        break;
      case 'child':
        // 作为目标节点的子节点
        sourceNode.parentId = targetId;
        updatedData.splice(sourceIndex, 1);
        updatedData.push(sourceNode);
        break;
      default:
        break;
    }

    setTreeData(updatedData);
  }, [treeData]);

  // 切换组节点的展开状态
  const toggleExpand = useCallback((groupId) => {
    setExpandedGroups(prev => {
      const newSet = new Set(prev);
      if (newSet.has(groupId)) {
        newSet.delete(groupId);
      } else {
        newSet.add(groupId);
      }
      return newSet;
    });
  }, []);

  // 递归渲染节点
  const renderNodes = useCallback((parentId = null, depth = 0) => {
    const children = treeData.filter(n => n.parentId === parentId);
    return children.map(node => (
      <Node
        key={node.id}
        data={node}
        depth={depth}
        isGroup={node.isGroup}
        onMove={handleMoveNode}
        isExpanded={expandedGroups.has(node.id)}
        toggleExpand={() => toggleExpand(node.id)}
      >
        {node.isGroup && renderNodes(node.id, depth + 1)}
      </Node>
    ));
  }, [treeData, expandedGroups, handleMoveNode, toggleExpand]);

  return (
    <div style={{ padding: '20px', maxWidth: '600px', margin: '0 auto' }}>
      <h3>可拖拽树形视图</h3>
      {renderNodes()}
    </div>
  );
}

export default TreeView;
第四步:优化与边界处理
  1. 性能优化:用React.memo包裹Node组件,避免不必要的重渲染:
export default React.memo(Node);
  1. 拖拽视觉增强:可以在拖拽时添加一个占位元素,或者给可放置区域添加更明显的高亮样式;
  2. 数据持久化:如果需要保存树形结构,可以在handleMoveNode后将数据同步到后端或本地存储;
  3. 自定义拖拽预览:可以用useDrag的preview选项自定义拖拽时的预览元素,默认会克隆当前节点。

内容的提问来源于stack exchange,提问作者Atmaks

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.21 08:33:36