本文档描述了一个 React 组件,该组件实现了可编辑和可拖拽的树形大纲功能。它利用 @dnd-kit 实现拖放功能,并允许自定义最大层级以及每个层级的子节点属性 Key。文章将包含关键部分的源码片段以帮助理解。
功能特性
- 可拖拽节点: 可以在同一层级内对节点进行重新排序,或将其移动成为其他节点的子节点。
- 内容可编辑: 每个节点都有一个标题和描述,可以内联编辑。
- 增删改查操作 (CRUD): 新增、编辑、删除节点。
- 最大层级控制: 限制子节点的最大嵌套层级。
- 自定义子节点属性 Key: 为树的每个层级指定不同的子节点数组属性名。
- 拖拽浮层 (Drag Overlay): 显示正在拖拽的项目的可视化预览。
核心技术
- React: 用于构建用户界面组件。
@dnd-kit/core: 提供基础的上下文 (DndContext)、传感器和拖拽浮层。@dnd-kit/sortable: 提供创建可排序列表的工具,如SortableContext和useSortableHook。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 (主组件)
这是树形大纲的容器组件,管理整体状态和拖放上下文。
DndContext和SortableContext: 设置拖放环境。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 是核心,负责在拖拽结束后更新树的结构。
- 获取信息: 获取拖拽项 (
active) 和目标项 (over) 的信息。 - 有效性检查:
- 确保
over项存在且与active项不同。 - 使用
flattenedItems查找active和over的完整数据,包括其路径。 - 防止无效拖放: 检查是否将父节点拖放到其自身的子孙节点中(通过比较路径)。检查是否会超出
maxDepth。
- 确保
- 状态更新 (使用 Immer):
- 移除节点: 在
produce的draft状态中,根据active.id找到被拖拽的节点,并从其原始父节点的子列表中移除。需要区分根节点和非根节点。 - 插入节点:
- 确定目标位置。这可能涉及判断是同级排序还是跨父级移动。
- 同级重新排序: 如果
active和over原本在同一父节点下,则将active节点插入到over节点指示的新位置。 - 跨父级移动/成为子节点 (简化): 当前的简化逻辑主要是将
active节点作为over节点的同级插入(如果over是根节点,则插入到根列表)。一个更完善的实现需要区分用户是想将active放在over的“上方”、“下方”还是“内部”(成为其子节点)。这通常需要更复杂的碰撞检测或在TreeNode上设置不同的放置区域。 maxDepth检查: 在插入前,需要确保新位置不会导致active节点及其所有子孙节点超过maxDepth。如果会,则应取消移动或调整。
- 移除节点: 在
- 调用
onChange: 将更新后的树数据传递出去。
重要提示: 树形结构的拖放逻辑,尤其是涉及父子关系变更和深度限制时,非常复杂。上述 onDragEnd 实现是一个基础版本,主要侧重于同级重排和简单的父子关系调整。对于生产环境,可能需要更精细的逻辑来处理所有边界情况和用户意图(例如,通过在节点上显示不同的放置指示器来区分是排序还是设为子节点)。
未来可增强功能
- 高级父子关系调整: 实现更精确的放置目标(例如,可视化提示“放入”、“之前”或“之后”)。
- 键盘导航与操作。
- 节点折叠/展开。
- 连接线。
- 大规模数据下的性能优化 (虚拟化)。
- TypeScript 类型支持。
此组件为构建交互式树形大纲提供了一个良好的起点。