前言
前面文章中我写过 react-smooth-dnd 的拖拽,它是基于 React DnD 库实现,将 React DnD 重新封装,可以直接使用它来进行排序,排序的结果会直接返回,而 React DnD 所有的数据处理都需要自己来完成。但是它也有它的缺点,比如:当嵌套多层后向上、向下拖拽,如果上面或下面的容器高度不能包含拖拽容器的高度便无法放入上层或下层容器;拖拽边界会出现闪烁;多层嵌套拖拽时返回的顺序不同,有时会先返回增加的事件,有时会先返回删除的事件。
既然 react-smooth-dnd 是基于 React DnD 的封装,那么使用 React Dnd 会更加灵活,我们可以使用 React DnD 来定制项目中所需要的拖拽的形式,比如:当下层容器向上移动 hover 到上层容器时将下层容器放置到上层容器中,或者当下层容器向上移动到指定位置时再将下层容器放置到指定位置处等。
React Dnd 可以解决 react-smooth-dnd 所带来的问题。但是它也有自己的问题,当嵌套多层时,每一层容器上既有 useDrag 又有 useDrop 事件时会从外向内触发,比如:多层 a, b, c,当拖动 c 时,会先触发 a 的事件,随后是 b 的事件,最后才是 c 的事件,这种情况使用官方的 monitor.isOver({ shallow: true }) 也没有缓解,只能在每一层容器的下面增加一个容器来解决嵌套的问题。
那到底什么是 React Dnd 呢?让我们一起来了解一下吧。
什么是 React DnD ?
React DnD 的英文是 Drag and Drop for React。
React DnD 是 React 和 Redux 的核心作者 Dan Abramov 创造的一组 React 高阶组件,可以在保持组件分离的前提下帮助构建复杂的拖放接口。
React DnD 库有 14.5k 的星,可以放心使用。
React DnD 的基本概念
Backends
React DnD 抽象了后端的概念,我们可以使用 HTML5 拖拽后端,也可以自定义 touch、mouse 事件模拟的后端实现,后端主要用来抹平浏览器差异,处理 DOM 事件,同时把 DOM 事件转换为 React DnD 内部的 redux action。
Item
React DnD 基于数据驱动,当拖放发生时,它用一个数据对象来描述当前的元素,比如 { cardId: 25 }。
Type
类型是唯一标识应用程序中整个项目类别的字符串(或符号),类似于 redux 里面的 actions types 枚举常量。
Monitors
拖放操作都是有状态的,React DnD 通过 Monitor 来存储这些状态并且提供查询。
Connectors
Backend 关注 DOM 事件,组件关注拖放状态,connector 可以连接组件和 Backend ,可以让 Backend 获取到 DOM。
useDrag
用于将当前组件用作拖动源的钩子。
import { useDrag } from 'react-dnd'
function DraggableComponent(props) {
const [collectedProps, drag] = useDrag({
item: { id, type }
})
return <div ref={drag}>...</div>
}
useDrop
使用当前组件作为放置目标的钩子。
function myDropTarget(props) {
const [collectedProps, drop] = useDrop({
accept
})
return <div ref={drop}>Drop Target</div>
}
useDragLayer
用于将当前组件用作拖动层的钩子。
import { useDragLayer } from 'react-dnd'
function DragLayerComponent(props) {
const collectedProps = useDragLayer(spec)
return <div>...</div>
}
现在我们来使用 React DnD 实现三层的拖拽
示例如下:
一、在 index.js 文件中,实现了组件和 Backend 的连接,可以让 Backend 获取到 DOM。
import React from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import Container from './container';
import DragLayer from './dragLayer';
export default () => {
return (
<div>
<DndProvider backend={HTML5Backend}>
{/* 拖拽容器 */}
<Container />
{/* 正在拖拽的项 */}
<DragLayer />
</DndProvider>
</div>
);
};
二、在 container.js 文件中,定义了拖放的容器,处理数据的方法。
import React, { useState } from 'react';
import classnames from 'classnames';
import Card from './card';
import { ITEMS, DATA_EMPTY } from './constants';
import { onGetIndex, onCalcPos, onUpdate } from './util';
import styles from './index.less';
export default () => {
const [cards, setCards] = useState(ITEMS);
// 找到拖拽的项,返回选中项和索引
const findCard = fieldName => {
const { card, index } = onGetIndex(fieldName, cards);
return {
card,
index,
};
};
/**
* 移动
* @param {string} fieldName 拖拽的项
* @param {array} atIndex 释放的项的索引
*/
const moveCard = (fieldName, atIndex, dropItem) => {
// 正在拖拽的项
const { card, index } = findCard(fieldName);
// 要放置的项的信息
const { lastFieldName, fieldName: droppedFieldName } = dropItem;
let placeIndex = atIndex;
let isAdd = false;
// 要放置的是空的项,增加处理逻辑
if (droppedFieldName?.includes(DATA_EMPTY)) {
const { index: dropIndex } = findCard(lastFieldName);
placeIndex = dropIndex;
isAdd = true;
}
if (placeIndex === index || index?.length === 0) {
return;
}
// 计算要放置和移除的索引
const key = onCalcPos(placeIndex, index, dropItem, cards, card, isAdd);
// 根据上面算出的索引处理源数据
const result = onUpdate(cards, key, card, dropItem);
// 更新 state ,刷新页面
setCards(result);
};
// 列表渲染
const onDomRender = (data, depth) => {
// 现在只支持 3 层数据
if (depth > 3) {
return;
}
return data?.map((item, index) => {
return (
<div
className={classnames({
[styles.container]: depth === 1,
[styles.group]: depth !== 1,
})}
key={`${item?.fieldName}-r`}
>
<Card
key={`${item?.fieldName}-c`}
fieldName={item?.fieldName}
label={item?.label}
depth={depth}
noBorder={item?.children?.length === 0 && depth !== 3}
isLast={
item?.children?.length === 0 &&
index === data?.length - 1 &&
depth !== 1
}
hasChildren={item?.children?.length > 0}
moveCard={moveCard}
findCard={findCard}
/>
{/* 当有多层数据时,递归渲染列表数据 */}
{item?.children?.length > 0 && onDomRender(item?.children, depth + 1)}
{/* 空的项用来处理向此级容器内部拖拽 */}
{item?.children?.length === 0 && (
<Card
key={`${DATA_EMPTY}-${item?.fieldName}`}
fieldName={`${item?.fieldName}-${DATA_EMPTY}`}
label={`${item?.fieldName}-${DATA_EMPTY}`}
depth={depth}
lastFieldName={item?.fieldName}
moveCard={moveCard}
findCard={findCard}
/>
)}
</div>
);
});
};
return <>{onDomRender(cards, 1, [])}</>;
};
数据处理使用了 immutability-helper 库的方法。在使用 $splice 时需要注意,数据必须是数组,且有要修改的数据,否则会报错。
import update from 'immutability-helper';
export const onUpdate = (cards, key, card) => {
const { atGroup, atSalary, atField, group, salary, field } = key;
const add = update(
cards,
typeof atSalary !== 'undefined'
? {
[atGroup]: {
children:
typeof atField !== 'undefined'
? {
[atSalary]: {
children: {
$splice: [[atField, 0, card]],
},
},
}
: {
$splice: [[atSalary, 0, card]],
},
},
}
: typeof atGroup !== 'undefined'
? { $splice: [[atGroup, 0, card]] }
: {},
);
const result = update(
add,
typeof salary !== 'undefined'
? {
[group]: {
children:
typeof field !== 'undefined'
? {
[salary]: {
children: {
$splice: [[field, 1]],
},
},
}
: {
$splice: [[salary, 1]],
},
},
}
: typeof group !== 'undefined'
? { $splice: [[group, 1]] }
: {},
);
return result;
};
三、在 card.js 文件中,定义了拖放项,使用 useDrag 和 useDrop 包裹,调用父组件传过来的 findCard,moveCard 方法来处理拖拽。
import React, { useEffect } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import classnames from 'classnames';
import { getEmptyImage } from 'react-dnd-html5-backend';
import { ItemTypes, DATA_EMPTY } from './constants';
import styles from './index.less';
export default ({
fieldName,
label,
depth,
moveCard,
findCard,
noBorder,
lastFieldName,
isLast,
hasChildren,
}) => {
const { index: originalIndex, card } = findCard(fieldName);
let lastDraggedFieldName = '';
const [{ isDragging }, drag, preview] = useDrag({
item: {
type: ItemTypes.CARD,
fieldName,
label,
card,
depth,
originalIndex,
isLast,
hasChildren,
},
collect: monitor => ({
isDragging: monitor.isDragging(),
}),
});
useEffect(() => {
// 使用自定义的拖拽 DOM
preview(getEmptyImage(), { captureDraggingState: true });
}, []);
const [, drop] = useDrop({
accept: ItemTypes.CARD,
hover({ fieldName: draggedFieldName, depth: draggedDepth }, monitor) {
const { y: offsetY } = monitor.getDifferenceFromInitialOffset();
if (!fieldName || !draggedFieldName) {
return;
}
if (
draggedFieldName !== fieldName &&
draggedFieldName !== lastDraggedFieldName &&
!fieldName?.includes(draggedFieldName)
) {
lastDraggedFieldName = draggedFieldName;
const { index: overIndex } = findCard(fieldName);
moveCard(draggedFieldName, overIndex, {
fieldName,
depth,
draggedDepth,
draggedFieldName,
offsetY,
lastFieldName,
});
}
},
});
const opacity = isDragging ? 0 : 1;
const paddingLeft = depth ? `${depth}rem` : '1rem';
return (
<div
key={fieldName}
ref={node => drag(drop(node))}
className={classnames(styles.element, {
[styles.empty]: fieldName?.includes(DATA_EMPTY),
[styles.noBorder]:
noBorder || (!fieldName?.includes(DATA_EMPTY) && depth === 3),
[styles.eleBox]: hasChildren,
})}
style={{ opacity, paddingLeft }}
>
{label}
</div>
);
};
四、在 dragLayer.js 文件中, 定义了拖拽时显示的 DOM 结构。为什么不使用系统自带的呢?因为定义多层容器嵌套时,使用的是同层的结构,所以在拖拽父容器时,子容器没有随着一起拖动,需要在这里定义一个包含子容器的父容器的结构来覆盖原来的结构,以使视觉看起来像是拖动了整个父容器。
import React from 'react';
import { useDragLayer } from 'react-dnd';
import classnames from 'classnames';
import CardLayer from './cardLayer';
import { getFixedStyles, getItemStyles } from './layerUtil';
import { DATA_EMPTY } from './constants';
import styles from './index.less';
export default () => {
const {
isDragging,
dragItem,
initialOffset,
currentOffset,
differenceOffset,
} = useDragLayer(monitor => ({
dragItem: monitor.getItem(),
initialOffset: monitor.getInitialSourceClientOffset(),
currentOffset: monitor.getSourceClientOffset(),
isDragging: monitor.isDragging(),
differenceOffset: monitor.getDifferenceFromInitialOffset(),
}));
if (!isDragging) {
return null;
}
// 列表渲染
const onDomRender = (data, depth, show) => {
if (depth > 3) {
return;
}
return data?.map(item => {
return (
<div
className={classnames({
[styles.container]: depth === 1,
[styles.group]: depth !== 1,
})}
key={`${item?.fieldName}-${show}-layer-c`}
>
<CardLayer
key={`${item?.fieldName}-${show}-layer-c`}
label={item?.label}
show={show}
noBorder={item?.children?.length === 0 && depth !== 3}
depth={depth}
isLast={item?.isLast}
hasChildren={item?.hasChildren}
/>
{item?.children?.length > 0 &&
onDomRender(item?.children, depth + 1, show)}
{item?.children?.length === 0 && (
<CardLayer
key={`${item?.fieldName}-${show}-layer-${DATA_EMPTY}`}
label={DATA_EMPTY}
show={show}
depth={depth}
isLast={item?.isLast}
hasChildren={item?.hasChildren}
/>
)}
</div>
);
});
};
return (
<div>
{/* 拖拽后一个假的结构,用来覆盖未拖动的项 */}
<div
className={classnames(styles.mask, styles.maskLayer, {
[styles.offsetToBottom]: differenceOffset?.y > 0,
})}
>
<div
className={classnames(styles.maskContainer, {
[styles.maskFrame]: dragItem?.depth !== 1 && !dragItem?.isLast,
[styles.dragEle]: dragItem?.depth !== 1 && dragItem?.isLast,
})}
style={getFixedStyles(
initialOffset,
currentOffset,
differenceOffset,
dragItem?.depth,
dragItem?.isLast,
)}
>
<CardLayer
key={`${dragItem?.fieldName}-mask-c`}
label={dragItem?.label}
show={false}
isLast={dragItem?.isLast}
hasChildren={dragItem?.hasChildren}
/>
{dragItem?.card?.children?.length > 0 &&
onDomRender(dragItem?.card?.children, dragItem?.depth + 1, false)}
{dragItem?.card?.children?.length === 0 && (
<CardLayer
key={`${dragItem?.fieldName}-mask-${DATA_EMPTY}`}
label={DATA_EMPTY}
show={false}
isLast={dragItem?.isLast}
hasChildren={dragItem?.hasChildren}
/>
)}
</div>
</div>
{/* 正在拖拽的项 */}
<div className={classnames(styles.mask, styles.dragLayer, {})}>
<div
className={classnames(styles.dragEle, {
[styles.container]: dragItem?.depth === 1,
[styles.group]: dragItem?.depth !== 1,
})}
style={getItemStyles(currentOffset)}
>
<CardLayer
key={`${dragItem?.fieldName}-layer-c`}
label={dragItem?.label}
show={true}
noBorder={
dragItem?.card?.children?.length === 0 && dragItem?.depth !== 3
}
depth={dragItem?.depth}
isLast={dragItem?.isLast}
hasChildren={dragItem?.hasChildren}
/>
{dragItem?.card?.children?.length > 0 &&
onDomRender(dragItem?.card?.children, dragItem?.depth + 1, true)}
{dragItem?.card?.children?.length === 0 && (
<CardLayer
key={`${dragItem?.fieldName}-layer-${DATA_EMPTY}`}
label={DATA_EMPTY}
show={true}
depth={dragItem?.depth}
isLast={dragItem?.isLast}
hasChildren={dragItem?.hasChildren}
/>
)}
</div>
</div>
</div>
);
};
五、在 cardLayer.js 文件中,定义了拖拽时显示的 DOM 项。
import React from 'react';
import classnames from 'classnames';
import { DATA_EMPTY } from './constants';
import styles from './index.less';
export default ({
label,
show,
depth,
noBorder,
hasChildren,
}) => {
const color = show ? 'black' : 'white';
const paddingLeft = depth ? `${depth}rem` : '1rem';
return (
<div
className={classnames(styles.element, {
[styles.empty]: label === DATA_EMPTY,
[styles.noBorder]:
!show ||
(show && noBorder) ||
(show && label !== DATA_EMPTY && depth === 3),
[styles.eleBox]: hasChildren,
})}
style={{ color, paddingLeft }}
>
{label}
</div>
);
};
现在我们已经完成了一个三层的嵌套,可以愉快地进行拖拽了。