前言
前面文章中我寫過 react-smooth-dnd 的拖拽匈挖,它是基于 React DnD 庫實現(xiàn),將 React DnD 重新封裝雌贱,可以直接使用它來進行排序蜓萄,排序的結(jié)果會直接返回,而 React DnD 所有的數(shù)據(jù)處理都需要自己來完成鞠眉。但是它也有它的缺點薯鼠,比如:當嵌套多層后向上、向下拖拽械蹋,如果上面或下面的容器高度不能包含拖拽容器的高度便無法放入上層或下層容器出皇;拖拽邊界會出現(xiàn)閃爍;多層嵌套拖拽時返回的順序不同哗戈,有時會先返回增加的事件恶迈,有時會先返回刪除的事件。
既然 react-smooth-dnd 是基于 React DnD 的封裝谱醇,那么使用 React Dnd 會更加靈活暇仲,我們可以使用 React DnD 來定制項目中所需要的拖拽的形式,比如:當下層容器向上移動 hover 到上層容器時將下層容器放置到上層容器中副渴,或者當下層容器向上移動到指定位置時再將下層容器放置到指定位置處等奈附。
React Dnd 可以解決 react-smooth-dnd 所帶來的問題。但是它也有自己的問題煮剧,當嵌套多層時斥滤,每一層容器上既有 useDrag 又有 useDrop 事件時會從外向內(nèi)觸發(fā),比如:多層 a, b, c勉盅,當拖動 c 時佑颇,會先觸發(fā) a 的事件,隨后是 b 的事件草娜,最后才是 c 的事件挑胸,這種情況使用官方的 monitor.isOver({ shallow: true }) 也沒有緩解,只能在每一層容器的下面增加一個容器來解決嵌套的問題宰闰。
那到底什么是 React Dnd 呢茬贵?讓我們一起來了解一下吧。
什么是 React DnD 移袍?
React DnD 的英文是 Drag and Drop for React解藻。
React DnD 是 React 和 Redux 的核心作者 Dan Abramov 創(chuàng)造的一組 React 高階組件,可以在保持組件分離的前提下幫助構(gòu)建復雜的拖放接口葡盗。
React DnD 庫有 14.5k 的星螟左,可以放心使用。
React DnD 的基本概念
Backends
React DnD 抽象了后端的概念,我們可以使用 HTML5 拖拽后端胶背,也可以自定義 touch虫啥、mouse 事件模擬的后端實現(xiàn),后端主要用來抹平瀏覽器差異奄妨,處理 DOM 事件涂籽,同時把 DOM 事件轉(zhuǎn)換為 React DnD 內(nèi)部的 redux action。
Item
React DnD 基于數(shù)據(jù)驅(qū)動砸抛,當拖放發(fā)生時评雌,它用一個數(shù)據(jù)對象來描述當前的元素,比如 { cardId: 25 }直焙。
Type
類型是唯一標識應用程序中整個項目類別的字符串(或符號)景东,類似于 redux 里面的 actions types 枚舉常量。
Monitors
拖放操作都是有狀態(tài)的奔誓,React DnD 通過 Monitor 來存儲這些狀態(tài)并且提供查詢斤吐。
Connectors
Backend 關(guān)注 DOM 事件,組件關(guān)注拖放狀態(tài)厨喂,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>
}
現(xiàn)在我們來使用 React DnD 實現(xiàn)三層的拖拽
示例如下:
一斜纪、在 index.js 文件中贫母,實現(xiàn)了組件和 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 文件中,定義了拖放的容器因块,處理數(shù)據(jù)的方法橘原。
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);
// 根據(jù)上面算出的索引處理源數(shù)據(jù)
const result = onUpdate(cards, key, card, dropItem);
// 更新 state 靠柑,刷新頁面
setCards(result);
};
// 列表渲染
const onDomRender = (data, depth) => {
// 現(xiàn)在只支持 3 層數(shù)據(jù)
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}
/>
{/* 當有多層數(shù)據(jù)時寨辩,遞歸渲染列表數(shù)據(jù) */}
{item?.children?.length > 0 && onDomRender(item?.children, depth + 1)}
{/* 空的項用來處理向此級容器內(nèi)部拖拽 */}
{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, [])}</>;
};
數(shù)據(jù)處理使用了 immutability-helper 庫的方法吓懈。在使用 $splice 時需要注意,數(shù)據(jù)必須是數(shù)組靡狞,且有要修改的數(shù)據(jù)耻警,否則會報錯。
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 包裹,調(diào)用父組件傳過來的 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 結(jié)構(gòu)荡含。為什么不使用系統(tǒng)自帶的呢?因為定義多層容器嵌套時届垫,使用的是同層的結(jié)構(gòu)释液,所以在拖拽父容器時,子容器沒有隨著一起拖動装处,需要在這里定義一個包含子容器的父容器的結(jié)構(gòu)來覆蓋原來的結(jié)構(gòu),以使視覺看起來像是拖動了整個父容器。
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>
{/* 拖拽后一個假的結(jié)構(gòu)蟀架,用來覆蓋未拖動的項 */}
<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>
);
};
現(xiàn)在我們已經(jīng)完成了一個三層的嵌套躺盛,可以愉快地進行拖拽了。