一. React-sortablejs拖拽组件介绍
-
1.1 下载与使用
-
github地址:github.com/SortableJS/…
-
下载: yarn add react-sortablejs sortablejs yarn add -D @types/sortablejs
- 注意:react-sortablejs库依赖sortablejs
-
使用:使用ReactSortable标签包围对象数组,并将数组和修改数组的方法作为该标签的属性
-
import React, { FC, useState } from "react"; import { ReactSortable } from "react-sortablejs"; interface ItemType { id: number; // id属性不能少 name: string; } export const BasicFunction: FC = (props) => { const [list, setList] = useState<ItemType[]>([ { id: 1, name: "aaa" }, { id: 2, name: "bbb" }, ]); return ( <ReactSortable list={list} setList={setList}> {list.map((item) => ( <div key={item.id}>{item.name}</div> ))} </ReactSortable> ); };
-
1.2 组件缺点
-
该组件只支持对象数组的拖拽,不支持数组中的元素不是对象(详见源码中关于组件属性props的接口定义)
-
export class ReactSortable<T extends ItemInterface> extends Component<ReactSortableProps<T>> { // ...... } export interface ReactSortableProps<T> extends ReactSortableOptions { list: T[]; setList: (newState: T[]) => void; tag?: ForwardRefExoticComponent<RefAttributes<any>> | keyof ReactHTML; }
-
-
对象数组中的每个对象都必须要有id属性(详见源码中关于对象数组中的对象接口定义)
-
export class ReactSortable<T extends ItemInterface> {} export interface ItemInterface { id: string | number; // id属性不能少 selected?: boolean; chosen?: boolean; filtered?: boolean; [property: string]: any; }
-
-
被拖拽的对象会被添加额外属性:在拖拽过程中,被拖拽的对象会被新增chosen: false的属性
-
举例:如下图拖拽所示,对象数组发生了如下变化,可能会造成数据的污染
-
二. 自定义实现React拖拽组件
2.1 JS中多种距离的介绍
-
区分与应用:以点击事件为例 onClick={(event) => console.log(event)}
- 事件对象e:event
- 被点击元素obj:event.target
2.2 处理拖拽组件props中的children
-
处理原因:传给该自定义组件的children元素,需要被添加额外属性,不能直接使用
-
<DiyReactSortable tag='ul' list={list} setList={setList} className='parent-ul'> {list.map((item, index) => <li className='parent-ul-li' key={index}>{item}</li>)} </DiyReactSortable>
-
-
边界考虑:当props.children子元素只有一个时,取消拖拽(即不需要添加额外属性),直接使用原来的children
2.2.1 React.Children方法:
-
props.children属性:表示组件的所有子节点,有三种不同类型
- 若当前组件没有子节点,它就是
undefined - 若有一个子节点,数据类型是
object - 若有多个子节点,数据类型就是
array
- 若当前组件没有子节点,它就是
-
React.Children方法: 用来处理子节点,其中
React.Children.map可遍历子节点-
<DiyList> <span>hello</span> <span>hello</span> </DiyList> // 返回两个子节点的数组 <DiyList></DiyList> // 返回undefined <DiyList>null</DiyList> // 返回null
-
-
使用方法:
-
const sonList = Children.map(props.children as ReactElement<any>[], (child, index) => { // ... // return ... })
-
2.2.2 React.cloneElement方法:
-
作用:给子节点props.children添加新属性
- 克隆原来的元素,返回一个新的 React 元素;
- 保留原始元素的 props,同时可以添加新的 props,两者进行合并
-
使用方法:
- 参数1:子节点
- 参数2:要添加的新属性props
-
React.cloneElement(child, { 'data-id': index, 'className': classNames('sort-list-item', childClassName), 'onMouseDown': handleDown, });
2.2.3 给所有子元素添加指定类名和data-id属性
-
指定类名:例如'sort-list-item',便于后续获取所有子元素距离顶部的偏移量数组
-
data-id属性:值为index,便于后续获取被拖拽子元素在数组中的索引
- 通过e.target.dataset.属性可以获取定义为“data-”+“属性”的标签属性
2.2.4 代码实现
// 重构所有子元素
const getChildren = useMemo(() => {
const sonList = Children.map(children as ReactElement<any>[], (child, index) => {
// 克隆元素并给其设置标签属性
const childClassName = child.props.className
return cloneElement(child, {
'data-id': index, // 通过e.target.dataset.id能够获取子元素的index
'className': classNames('sort-list-item', childClassName),
'onMouseDown': handleDown,
});
})
// 返回对象数组(对象为子元素的相关信息):若只有一个子元素,则不需要拖拽,直接返回原本的子元素
return sonList.length > 1 ? sonList : children;
}, [children])
// 没有子内容直接返回null
if (!children) return null;
return createElement(
tag,
{},
getChildren
)
2.3 绑定鼠标按下事件onmousedown
-
记录被拖拽元素的初始索引:拖拽完成后进行数组的重新排序时会用到(e.target.dataset.id)
-
设置盒子样式:绝对定位、鼠标光标变成'move'、zIndex设置一个较大值防止被其他元素遮挡
-
计算盒子相对于鼠标点击位置的偏移量:防止在拖拽过程中鼠标指针偏移
-
公式:鼠标点击位置距离可视区左/上侧距离 - 被点击元素距离可视区左/上侧距离
- 左侧偏移量:e.clientX - e.target.offsetLeft
- 上侧偏移量:e.clientY - e.target.offsetTop
-
-
代码实现
// 鼠标按下事件
const handleDown = (e: any) => {
// 1. 记录被点击元素的索引
const selectedIndex = e.target.dataset.id
// 2. 设置被点击元素的样式
e.target.style.cursor = 'move'
e.target.style.position = 'absolute'
e.target.style.zIndex = 100
// 3. 计算被点击元素的偏移量
const offsetLeft = e.clientX - e.target.offsetLeft;
const offsetTop = e.clientY - e.target.offsetTop;
// 4. 开始拖拽元素
document.onmousemove = function (event) {
// ...TODU
};
// 5. 停止拖拽
document.onmouseup = function () {
// ...TODU
document.onmousemove = null;
document.onmouseup = null;
};
}
2.4 绑定鼠标移动事件onmousemove
- 作用:计算盒子(绝对定位下)在拖拽时的位置(left与top)
-
计算公式:
- 盒子左侧距离 = 鼠标点击位置的左侧距离(event.clientX) - 盒子左侧偏移量(offsetLeft)
- 盒子上侧距离 = 鼠标点击位置的上侧距离(event.clientY) - 盒子上侧偏移量(offsetTop)
-
代码实现:
-
// 开始拖拽元素 document.onmousemove = function (event) { const left = event.clientX - offsetLeft; const top = event.clientY - offsetTop; e.target.style.left = left + "px"; e.target.style.top = top + "px"; };
-
2.5 绑定鼠标抬起事件onmouseup
-
计算被拖拽元素在数组中的索引变化
-
获取所有子元素的节点集合(非数组):document.getElementsByClassName('sort-list-item')
- 该自定义组件内定每个子元素有一个class为“sort-list-item”
-
将上述节点集合转化为数组:Array.prototype.slice.call(sonList)
- 该方法可以将有length属性的对象转换为数组
-
记录被拖拽元素距离顶部的距离:topList[selectedIndex]
-
将数组升序,找出上述元素在有序数组中的索引即为拖拽排序后的新索引
-
具体代码:
// 计算被拖拽元素在数组中的新索引 const getNewIndex = (selectedIndex: number) => { // 计算所有子元素距离顶部的距离(offsetTop) const sonList = document.getElementsByClassName('sort-list-item') const topList = Array.prototype.slice.call(sonList).map(item => item.offsetTop) const lastTop = topList[selectedIndex] topList.sort((a, b) => a - b) const index = topList.findIndex(item => item === lastTop) return index }
-
-
根据新旧索引修改数组
-
暂存被拖拽的对象:用temp参数临时保存该元素
-
根据旧索引删除该元素:list.splice(selectedIndex, 1)
-
根据新索引插入该元素:list.splice(newIndex, 0, temp)
- 注意:下述函数中的list与setList均为函数式组件的props参数
-
代码实现
// 在index.tsx函数式组件内部(先删除再插入) const changeList = (selectedIndex: number, newIndex: number): void => { const temp = list[selectedIndex] list.splice(selectedIndex, 1) list.splice(newIndex, 0, temp) // 数组任意位置插入某个元素 setList([...list]) }
-
-
设置盒子样式:取消绝对定位、left和top值 ==> 鼠标光标修改 ==> zIndex变小
-
代码实现
// 停止拖拽 document.onmouseup = function () { // 1. 计算被拖拽元素在数组中的索引变化 const newIndex = getNewIndex(selectedIndex) // 2. 根据新旧索引变换list并set修改 changeList(selectedIndex, newIndex); // 3. 去除拖拽时的样式 e.target.style.position = 'relative' e.target.style.left = 'inherit'; e.target.style.top = 'inherit'; e.target.style.cursor = 'pointer' e.target.style.zIndex = 0 document.onmousemove = null; document.onmouseup = null; };