需求场景
- 支持树形结构表格的拖拽排序,支持同级移动以及多级嵌套。
- 实现同级向上、同级向下、放置到子集的排序功能,识别嵌套的不合法目标。

技术方案
antd的官方案例 案例链接 推荐了dnd
这个库来实现一层表格的拖拽功能,所以我们选用react-dnd
这个库,来实现这个多级的拖拽排序。react-dnd链接。
我们使用自定义tabel行的方式,来操作行数据,使用useDrop
和useDrag
来实现对于table行的拖拽以及放置功能。
typescript
<Table
components={{
body: {
row: DragRow,//DragRow.tsx
},
}}
{...antTableProps}
/>
主要关注点如下:
- 起始行和目标行数据id的收集。
- 判断拖拽元素在目标行中的位置,来展示不同的交互效果。
- 判断目标行所在位置的合法性,比如禁止父级元素向子集元素拖拽。
- 修改放置之后的数据。
拖拽排序实现
- 使用
react-dnd
需要自定义table的row,用来方便传递属性,需要自定义编写DragRow
组件来接管row。把table组件使用DndProvider
包裹,import { HTML5Backend } from 'react-dnd-html5-backend';
PC端传入这个backend
,连接适配器,我们不需要关心拖拽时候元素本身的交互细节了, - 通过回调函数的方式,使用antd table的
onRow
api,把moveRow
函数挂载到对应的行上。 - 拿到拖拽节点的
id
,松手节点的id
,放置类型(同级向上、同级向下、放置到子集),编写sortDataByMove(data,dragId,dropId,dropOverType)
函数。 - 更新操作之后的数据,传入table。
核心代码实现如下
这个组件主要用来获取拖拽时候对于元素位置,以及操作类型的判断,比如当拖拽到目标行垂直中心点上下40%的时候,判定为插入到children,否则判断插入到上方/下方。
typescript
import React, { useRef } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import './index.less';
const type = 'DragRow';
export type DropOverType = 'upward' | 'downward' | 'inside';
export interface DragRowProps extends React.HTMLAttributes<HTMLTableRowElement> {
id: string; //目标id
moveRow: (dragId: string, dropId: string, type?: DropOverType) => void;
disableDrop?: boolean;
}
const DragRow: React.FC<DragRowProps> = ({
id,
moveRow,
className,
style,
disableDrop,
...restProps
}) => {
const ref = useRef<HTMLTableRowElement>(null);
const [dropOverType, setDropOverType] = React.useState<DropOverType>();
const [{ isOver }, drop] = useDrop({
accept: type,
hover: (item, monitor) => {
const clientOffset = monitor.getClientOffset();
const hoverBoundingRect = ref.current?.getBoundingClientRect();
if (clientOffset && hoverBoundingRect) {
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
const hoverPercentage = (hoverClientY - hoverMiddleY) / hoverMiddleY;
const getClassName = (hoverPercentage: number) => {
const offsetValue = 0.4;
if (hoverPercentage > offsetValue) {
return 'downward';
} else if (hoverPercentage < -offsetValue) {
return 'upward';
} else {
return 'inside';
}
};
const dropClassName = getClassName(hoverPercentage);
setDropOverType(dropClassName);
}
},
collect: (monitor) => {
return {
isOver: monitor.isOver(),
};
},
drop: (item: { id: string }) => {
const dragId = item.id;
const targetId = id;
moveRow(dragId, targetId, dropOverType);
},
});
const [_, drag] = useDrag({
type,
item: { id },
collect: (monitor) => {
return {
isDragging: monitor.isDragging(),
};
},
});
const canDrop = !disableDrop;
if (canDrop) {
drop(drag(ref));
}
return (
<tr
ref={ref}
className={`${className}${isOver ? ` drop-over-${dropOverType}` : ''}`}//目标节点样式
style={{
cursor: canDrop ? 'move' : 'auto',
...style,
}}
{...restProps}
/>
);
};
export default DragRow;
针对不同插入类型,展示不同的css样式提示。
less
.drag-overlay {
background: rgba(255, 255, 255, 0.8);
border: 1px dashed #1890ff;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.drop-over-downward td {
border-bottom: 2px dashed #1890ff !important;
margin-bottom: 10px !important;
::after {
content: '向下插入';
font-size: 12px;
position: absolute;
top: 100%;
left: 0;
right: 0;
height: 2px;
}
}
tr.drop-over-upward td {
border-top: 2px dashed #d1d9e0;
::after {
content: '向上插入';
font-size: 12px;
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
}
}
tr.drop-over-inside td {
background-color: #1890ff20;
}
这里主要实现了已知拖拽节点的id,目标节点的id之后,对数据按照要求进行操作。
typescript
type DataType = {
id?: string;
[key: string]: any;
children?: DataType[];
};
type DropOverType = 'upward' | 'downward' | 'inside';
type ID = string | number;
/**
* 节点位置信息
*/
type NodeLocation = {
node: DataType; // 当前节点
parentArray: DataType[]; // 当前节点所在的数组
parentNode: DataType | null; // 当前节点的父节点(如果是根节点则为null)
index: number; // 当前节点在数组中的索引
parentLocation: NodeLocation | null; // 父节点的位置信息(用于构建祖先链)
};
/**
* 在树形结构中查找节点位置
*/
const findNodeLocation = (
data: DataType[],
id: ID,
parentNode: DataType | null = null,
parentArray: DataType[] = data,
parentLocation: NodeLocation | null = null,
): NodeLocation | null => {
for (let i = 0; i < parentArray.length; i++) {
const node = parentArray[i];
const currentLocation: NodeLocation = {
node,
parentArray,
parentNode,
index: i,
parentLocation,
};
// 找到目标节点
if (node.id === id) {
return currentLocation;
}
// 递归搜索子节点
if (node.children) {
const found = findNodeLocation(node.children, id, node, node.children, currentLocation);
if (found) return found;
}
}
return null;
};
/**
* 检查拖拽节点是否是目标节点的祖先
*/
const isAncestor = (dragLocation: NodeLocation, dropLocation: NodeLocation): boolean => {
let parent = dropLocation.parentLocation;
while (parent) {
if (parent.node === dragLocation.node) {
return true;
}
parent = parent.parentLocation;
}
return false;
};
/**
* 移除空children属性
*/
const removeEmptyChildren = (node: DataType) => {
if (node.children && node.children.length === 0) {
delete node.children;
}
};
/**
*
* @param data 数据源
* @param dragId 拖拽行的id
* @param dropId 目标行的id
* @param dropOverType 操作类型
* @returns
*/
export const sortDataByMove = (
data: readonly DataType[],
dragId: ID,
dropId: ID,
dropOverType: DropOverType,
): DataType[] => {
// 1. 深拷贝原始数据
const newData = JSON.parse(JSON.stringify(data));
// 2. 查找节点位置
const dragLocation = findNodeLocation(newData, dragId);
const dropLocation = findNodeLocation(newData, dropId);
// 3. 检查节点是否存在
if (!dragLocation || !dropLocation) return newData;
// 4. 检查祖先关系(拖拽节点不能是目标节点的祖先)
if (isAncestor(dragLocation, dropLocation)) return newData;
// 5. 从原位置移除拖拽节点
const [dragNode] = dragLocation.parentArray.splice(dragLocation.index, 1);
// 6. 移除后处理原父节点的空children
if (dragLocation.parentNode) {
removeEmptyChildren(dragLocation.parentNode);
}
// 7. 根据放置类型处理
switch (dropOverType) {
case 'inside': {
// 放置到目标节点内部
if (!dropLocation.node.children) {
dropLocation.node.children = [];
}
dropLocation.node.children.push(dragNode);
break;
}
case 'upward':
case 'downward': {
// 调整目标节点索引(当同一层级移动且拖拽节点在目标节点之前时)
let adjustedIndex = dropLocation.index;
if (
dragLocation.parentArray === dropLocation.parentArray &&
dragLocation.index < dropLocation.index
) {
adjustedIndex -= 1;
}
// 计算插入位置
const insertIndex = dropOverType === 'upward' ? adjustedIndex : adjustedIndex + 1;
// 插入到目标位置
dropLocation.parentArray.splice(insertIndex, 0, dragNode);
break;
}
}
return newData;
};
通过以上的组件以及工具函数,最终实现功能:
typescript
import { Table, TableProps } from 'antd';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import DragRow, { DragRowProps, DropOverType } from './DragRow';
import { sortDataByMove } from './utile';
export interface SortableTreeTableProps extends Omit<TableProps, 'components'> {
// 由于涉及到操作数据,rowKey为必填项
rowKey: string | ((record: Record<string, any>) => string);
onDragEnd: (
dragEndData: Record<string, any>,
dragId: string,
dropId: string,
dropOverType: DropOverType,
) => void;
}
export const SortableTreeTable: React.FC<SortableTreeTableProps> = ({
onDragEnd,
...antTableProps
}) => {
const dataSource = [
{
id: '1',
name: '类别-1',
children: [
{
id: '1-1',
name: '类别-1-1',
},
{
id: '1-2',
name: '类别-1-2',
children: [
{
id: '1-2-1',
name: '类别-1-2-1',
},
],
},
],
},
{ id: '2', name: '类别-2' },
{ id: '3', name: '类别-3' },
{ id: '4', name: '类别-4' },
{ id: '5', name: '类别-5' },
{ id: '6', name: '类别-6' },
{ id: '7', name: '类别-7' },
{ id: '8', name: '类别-8' },
{ id: '9', name: '类别-9' },
];
const [dropEndData, setDropEndData] = useState<Record<string, any>[]>(dataSource);
const moveRow = (dragId: string, dropId: string, dropOverType?: DropOverType) => {
if (dragId === dropId || !dropOverType) return;
setDropEndData((d) => {
const newData = sortDataByMove(d, dragId, dropId, dropOverType);
return newData;
});
};
return (
<DndProvider backend={HTML5Backend}>
<Table
dataSource={dropEndData}
pagination={false}
components={{
body: {
row: DragRow,
},
}}
onRow={(record: Record<string, any>, index) => {
const attr: DragRowProps = {
id: String(record?.id ?? index),
moveRow,
};
return attr;
}}
{...antTableProps}
/>
</DndProvider>
);
};