自定义实现React拖拽组件

555 阅读5分钟

一. React-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;
    };