使用 React 和 dnd-kit 实现可编辑、可拖拽的树形大纲组件

569 阅读8分钟

本文档描述了一个 React 组件,该组件实现了可编辑和可拖拽的树形大纲功能。它利用 @dnd-kit 实现拖放功能,并允许自定义最大层级以及每个层级的子节点属性 Key。文章将包含关键部分的源码片段以帮助理解。

功能特性

  • 可拖拽节点: 可以在同一层级内对节点进行重新排序,或将其移动成为其他节点的子节点。
  • 内容可编辑: 每个节点都有一个标题和描述,可以内联编辑。
  • 增删改查操作 (CRUD): 新增、编辑、删除节点。
  • 最大层级控制: 限制子节点的最大嵌套层级。
  • 自定义子节点属性 Key: 为树的每个层级指定不同的子节点数组属性名。
  • 拖拽浮层 (Drag Overlay): 显示正在拖拽的项目的可视化预览。

核心技术

  • React: 用于构建用户界面组件。
  • @dnd-kit/core: 提供基础的上下文 (DndContext)、传感器和拖拽浮层。
  • @dnd-kit/sortable: 提供创建可排序列表的工具,如 SortableContextuseSortable Hook。
  • uuid: 生成唯一 ID。
  • immer: 简化不可变状态更新。

数据结构

每个节点对象结构如下:

{
  id: 'unique-string-id',
  title: 'Node Title',
  description: 'Node Description',
  // [childrenKey]: [ /* 子节点数组 */ ]
}

childrenKey 根据节点深度和 childrenKeyByLevel 配置动态确定。

组件剖析

1. treeUtils.js (辅助函数)

这些纯函数负责树数据的核心操作,保持组件逻辑的清晰。

flattenTree: 将层级树结构扁平化为一维数组,供 SortableContext 使用。每个扁平化后的项包含 id, depth, parentId, title, 和 path (从根到当前节点的 ID 路径)。

// src/components/TreeOutline/treeUtils.js
import { getChildrenKeyForDepth } from './treeUtils'; // 假设此函数已定义

export const flattenTree = (nodes, depth = 0, parentId = null, childrenKeyByLevel = [], path = []) => {
  let flatList = [];
  // 获取当前深度的子节点 Key
  const childrenKey = getChildrenKeyForDepth(depth, childrenKeyByLevel);

  nodes.forEach(node => {
    flatList.push({ id: node.id, depth, parentId, title: node.title, path: [...path, node.id] });
    if (node[childrenKey] && node[childrenKey].length > 0) {
      flatList = flatList.concat(
        flattenTree(node[childrenKey], depth + 1, node.id, childrenKeyByLevel, [...path, node.id])
      );
    }
  });
  return flatList;
};

findNodeById: 递归地在树中查找具有特定 ID 的节点及其路径。

// src/components/TreeOutline/treeUtils.js
export const findNodeById = (nodes, id, currentDepth, childrenKeyByLevel) => {
  for (const node of nodes) {
    if (node.id === id) return { node, path: [node.id] }; // 找到了节点
    const childrenKey = getChildrenKeyForDepth(currentDepth, childrenKeyByLevel);
    if (node[childrenKey] && node[childrenKey].length > 0) {
      const foundInChild = findNodeById(node[childrenKey], id, currentDepth + 1, childrenKeyByLevel);
      if (foundInChild) {
        // 如果在子节点中找到,构建完整路径
        return { node: foundInChild.node, path: [node.id, ...foundInChild.path] };
      }
    }
  }
  return null; // 未找到
};

CRUD 操作的辅助函数 (以 addChildNodeInTree 为例):

// src/components/TreeOutline/treeUtils.js
// (需配合 immer 在组件中使用,或直接返回新树)
// 此处展示一个概念性的不可变更新版本,实际组件中通常结合 immer
export const addChildNodeInTree = (nodes, parentPath, newNode, depth, childrenKeyByLevel) => {
  if (!parentPath || parentPath.length === 0) { // 添加到根
    return [...nodes, newNode];
  }

  const [currentId, ...restPath] = parentPath;
  return nodes.map(node => {
    if (node.id === currentId) {
      const childrenKey = getChildrenKeyForDepth(depth, childrenKeyByLevel);
      const existingChildren = node[childrenKey] || [];
      if (restPath.length === 0) { // 当前节点是父节点
        return {
          ...node,
          [childrenKey]: [...existingChildren, newNode],
        };
      }
      // 递归到子树
      return {
        ...node,
        [childrenKey]: addChildNodeInTree(existingChildren, restPath, newNode, depth + 1, childrenKeyByLevel),
      };
    }
    return node;
  });
};

2. TreeNode.jsx (单个节点组件)

此组件负责渲染单个树节点,并使其可拖拽。

  • useSortable: 使节点可拖拽,并提供拖拽所需的属性和监听器。
  • 内联编辑: 管理标题和描述的编辑状态。
// src/components/TreeOutline/TreeNode.jsx
import React, { useState } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { getChildrenKeyForDepth } from './treeUtils';

const TreeNode = ({
  node,
  depth,
  onEdit,
  onDelete,
  onAddChild,
  maxDepth,
  childrenKeyByLevel,
  renderSubtree, // 渲染子树的函数
  isDragOverlay = false,
}) => {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id: node.id, data: { type: 'node', depth, node } }); // data 用于拖拽时的上下文

  const [isEditing, setIsEditing] = useState(false);
  // ... (editTitle, editDescription state) ...

  const style = {
    transform: CSS.Transform.toString(transform), // 应用 dnd-kit 的变换
    transition,
    marginLeft: `${depth * 20}px`, // 根据深度进行缩进
    // ... 其他样式 ...
  };

  // ... (编辑状态的 JSX) ...

  return (
    <>
      <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
        {/* 节点内容:标题、描述 */}
        <div>
          <strong>{node.title}</strong>
          <p>{node.description}</p>
        </div>
        {/* 操作按钮:编辑、添加子节点、删除 */}
        {!isDragOverlay && (
          <div>
            <button onClick={() => setIsEditing(true)}>✏️</button>
            {depth < maxDepth - 1 && (
              <button onClick={() => onAddChild(node.id)}>➕</button>
            )}
            <button onClick={() => onDelete(node.id)}>🗑️</button>
          </div>
        )}
      </div>
      {/* 递归渲染子节点 */}
      {!isDragOverlay && node[getChildrenKeyForDepth(depth, childrenKeyByLevel)] &&
        renderSubtree(node[getChildrenKeyForDepth(depth, childrenKeyByLevel)], depth + 1)}
    </>
  );
};

export default TreeNode;

3. TreeOutline.jsx (主组件)

这是树形大纲的容器组件,管理整体状态和拖放上下文。

  • DndContextSortableContext: 设置拖放环境。
  • onDragEnd: 处理拖放结束后的逻辑,更新树结构。
  • CRUD 操作处理函数: 调用 treeUtils 中的函数并更新状态。
// src/components/TreeOutline/TreeOutline.jsx
import React, { useState, useCallback, useMemo } from 'react';
import {
  DndContext,
  closestCenter,
  // ... 其他 dnd-kit 导入 ...
  DragOverlay,
} from '@dnd-kit/core';
import {
  SortableContext,
  verticalListSortingStrategy,
  // arrayMove, // dnd-kit/sortable 中没有直接导出 arrayMove,通常自己实现或用库
} from '@dnd-kit/sortable';
import { produce } from 'immer'; // 用于简化不可变更新
import TreeNode from './TreeNode';
import {
  findNodeById,
  // ... 其他 treeUtils 导入 ...
  flattenTree,
  createNewNode,
  getChildrenKeyForDepth,
  // ... (假设有 removeNodeFromTree 和 insertNodeIntoTree 这样的辅助函数)
} from './treeUtils';

const TreeOutline = ({
  initialData = [],
  onChange,
  maxDepth = 3,
  childrenKeyByLevel = ['children', 'subItems', 'tasks'],
}) => {
  const [treeData, setTreeData] = useState(initialData);
  const [activeId, setActiveId] = useState(null); // 当前拖拽的节点 ID

  // ... (sensors, flattenedItems, activeNodeData 的 useMemo 定义) ...
  const flattenedItems = useMemo(() => flattenTree(treeData, 0, null, childrenKeyByLevel), [treeData, childrenKeyByLevel]);


  const handleStateChange = (newTreeData) => {
    setTreeData(newTreeData);
    if (onChange) onChange(newTreeData);
  };

  const handleAddChild = useCallback((parentId) => {
    const parentInfo = findNodeById(treeData, parentId, 0, childrenKeyByLevel);
    if (!parentInfo) return;
    const parentDepth = parentInfo.path.length - 1;
    if (parentDepth >= maxDepth -1) return; // 检查最大深度

    const newNode = createNewNode();
    const newTree = produce(treeData, draft => { // 使用 Immer
        const { node: targetParent } = findNodeById(draft, parentId, 0, childrenKeyByLevel);
        if (targetParent) {
            const childrenKey = getChildrenKeyForDepth(parentDepth, childrenKeyByLevel);
            if (!targetParent[childrenKey]) targetParent[childrenKey] = [];
            targetParent[childrenKey].push(newNode);
        }
    });
    handleStateChange(newTree);
  }, [treeData, maxDepth, childrenKeyByLevel, handleStateChange]);

  // ... (handleEditNode, handleDeleteNode 实现) ...

  const onDragStart = ({ active }) => setActiveId(active.id);

  const onDragEnd = ({ active, over }) => {
    setActiveId(null);
    if (!over || active.id === over.id) return;

    const activeItemInfo = flattenedItems.find(item => item.id === active.id);
    const overItemInfo = flattenedItems.find(item => item.id === over.id);

    if (!activeItemInfo || !overItemInfo) return;

    // 阻止将父节点拖入其自身的子节点,或超出最大深度
    if (overItemInfo.path.includes(activeItemInfo.id) /*|| (potentialNewDepth > maxDepth -1)*/) {
        console.warn("Invalid drop target.");
        return;
    }

    const newTree = produce(treeData, draft => {
        // 1. 从原位置移除被拖拽的节点 (nodeToMove)
        let nodeToMove;
        const activeNodePath = findNodeById(draft, active.id, 0, childrenKeyByLevel)?.path;
        if (!activeNodePath) return;

        if (activeNodePath.length === 1) { // 根节点
            const index = draft.findIndex(n => n.id === active.id);
            if (index > -1) [nodeToMove] = draft.splice(index, 1);
        } else {
            const parentId = activeNodePath[activeNodePath.length - 2];
            const { node: parentNode } = findNodeById(draft, parentId, 0, childrenKeyByLevel);
            if (parentNode) {
                const parentDepth = activeNodePath.length - 2;
                const childrenKey = getChildrenKeyForDepth(parentDepth, childrenKeyByLevel);
                const index = parentNode[childrenKey].findIndex(n => n.id === active.id);
                if (index > -1) [nodeToMove] = parentNode[childrenKey].splice(index, 1);
            }
        }
        if (!nodeToMove) return;

        // 2. 将 nodeToMove 插入到新位置
        //    简化逻辑:主要处理同级排序。更复杂的父子关系变更需要更精细的判断
        //    (例如,判断是拖到节点“上方”、“下方”还是“内部”)
        const overNodePath = findNodeById(draft, over.id, 0, childrenKeyByLevel)?.path;
        if(!overNodePath) return; // Should not happen if overItemInfo is valid

        const overNodeParentId = overNodePath.length > 1 ? overNodePath[overNodePath.length - 2] : null;
        const activeNodeOriginalParentId = activeNodePath.length > 1 ? activeNodePath[activeNodePath.length-2] : null;

        if (overNodeParentId === activeNodeOriginalParentId) { // 同父级下的重新排序
            const parentList = overNodeParentId
                ? findNodeById(draft, overNodeParentId, 0, childrenKeyByLevel).node[getChildrenKeyForDepth(overNodePath.length - 2, childrenKeyByLevel)]
                : draft;
            const oldIndex = parentList.findIndex(item => item.id === active.id); // 理论上此时已移除,但为了 arrayMove 概念
            const newIndex = parentList.findIndex(item => item.id === over.id);
            // arrayMove(parentList, oldIndex, newIndex); // dnd-kit/sortable 不直接提供 arrayMove
            // 手动实现插入:
            parentList.splice(newIndex, 0, nodeToMove);


        } else { // 移动到不同父级 (或根级别) - 简化为作为 over 项的兄弟
            if (overNodeParentId) {
                const { node: newParentNode } = findNodeById(draft, overNodeParentId, 0, childrenKeyByLevel);
                const childrenKey = getChildrenKeyForDepth(overNodePath.length - 2, childrenKeyByLevel);
                const overIndex = newParentNode[childrenKey].findIndex(item => item.id === over.id);
                newParentNode[childrenKey].splice(overIndex, 0, nodeToMove);
            } else { // 移动到根级别
                const overIndex = draft.findIndex(item => item.id === over.id);
                draft.splice(overIndex, 0, nodeToMove);
            }
        }
         // 注意:这里还需要递归检查并调整 nodeToMove 的子孙节点的深度,以及是否超出 maxDepth
         // 这是一个复杂点,当前简化版可能未完全处理所有边界情况
    });
    handleStateChange(newTree);
  };

  const renderTreeNodes = (nodes, depth) => {
    if (depth >= maxDepth) return null;
    return nodes.map((node) => (
      <TreeNode
        key={node.id}
        node={node}
        depth={depth}
        // ... 其他 props ...
        renderSubtree={renderTreeNodes} // 传递递归渲染函数
      />
    ));
  };

  return (
    <DndContext
      sensors={/* ... sensors ... */}
      collisionDetection={closestCenter}
      onDragStart={onDragStart}
      onDragEnd={onDragEnd}
    >
      <SortableContext items={flattenedItems.map(item => item.id)} strategy={verticalListSortingStrategy}>
        {renderTreeNodes(treeData, 0)}
      </SortableContext>
      <DragOverlay dropAnimation={null}>
        {activeId && /* ... 渲染拖拽浮层中的 TreeNode ... */}
      </DragOverlay>
      <button onClick={/* handleAddRootNode */} style={{ marginTop: '20px' }}>
        添加根节点
      </button>
    </DndContext>
  );
};

export default TreeOutline;

使用方法

// src/App.jsx
import React, { useState } from 'react';
import TreeOutline from './components/TreeOutline/TreeOutline';

const initialTree = [ /* ... 您的初始树数据 ... */ ];

function App() {
  const [treeData, setTreeData] = useState(initialTree);

  const handleTreeChange = (newTree) => {
    console.log('树数据已更新:', newTree);
    setTreeData(newTree);
  };

  return (
    <TreeOutline
      initialData={treeData}
      onChange={handleTreeChange}
      maxDepth={4}
      childrenKeyByLevel={['children', 'subItems', 'tasks']}
    />
  );
}
export default App;

Props for TreeOutline

  • initialData (Array): 初始树数据。
  • onChange (Function): 数据变更时的回调。
  • maxDepth (Number): 最大层级深度(根为0)。
  • childrenKeyByLevel (Array): 定义每个层级的子节点属性名。

拖放逻辑 (onDragEnd) 详解

onDragEnd 是核心,负责在拖拽结束后更新树的结构。

  1. 获取信息: 获取拖拽项 (active) 和目标项 (over) 的信息。
  2. 有效性检查:
    • 确保 over 项存在且与 active 项不同。
    • 使用 flattenedItems 查找 activeover 的完整数据,包括其路径。
    • 防止无效拖放: 检查是否将父节点拖放到其自身的子孙节点中(通过比较路径)。检查是否会超出 maxDepth
  3. 状态更新 (使用 Immer):
    • 移除节点: 在 producedraft 状态中,根据 active.id 找到被拖拽的节点,并从其原始父节点的子列表中移除。需要区分根节点和非根节点。
    • 插入节点:
      • 确定目标位置。这可能涉及判断是同级排序还是跨父级移动。
      • 同级重新排序: 如果 activeover 原本在同一父节点下,则将 active 节点插入到 over 节点指示的新位置。
      • 跨父级移动/成为子节点 (简化): 当前的简化逻辑主要是将 active 节点作为 over 节点的同级插入(如果 over 是根节点,则插入到根列表)。一个更完善的实现需要区分用户是想将 active 放在 over 的“上方”、“下方”还是“内部”(成为其子节点)。这通常需要更复杂的碰撞检测或在 TreeNode 上设置不同的放置区域。
      • maxDepth 检查: 在插入前,需要确保新位置不会导致 active 节点及其所有子孙节点超过 maxDepth。如果会,则应取消移动或调整。
  4. 调用 onChange: 将更新后的树数据传递出去。

重要提示: 树形结构的拖放逻辑,尤其是涉及父子关系变更和深度限制时,非常复杂。上述 onDragEnd 实现是一个基础版本,主要侧重于同级重排和简单的父子关系调整。对于生产环境,可能需要更精细的逻辑来处理所有边界情况和用户意图(例如,通过在节点上显示不同的放置指示器来区分是排序还是设为子节点)。

未来可增强功能

  • 高级父子关系调整: 实现更精确的放置目标(例如,可视化提示“放入”、“之前”或“之后”)。
  • 键盘导航与操作。
  • 节点折叠/展开。
  • 连接线。
  • 大规模数据下的性能优化 (虚拟化)。
  • TypeScript 类型支持。

此组件为构建交互式树形大纲提供了一个良好的起点。