Antd Upload上传后还想要拖拽?行~开干!玩的就是真实

原创 陈夏杨 / 叫叫技术团队

基于 Antd Upload 实现拖拽(兼容低版本)

背景

哎呀!我想要的是边上传边调整位置可以拖拽的那种效果!哪种?就那种。(这句话是不是似曾相识,没错,到这里作为开发还没领悟那就要面壁思过了~哈哈。)话不多说,目前 Antd 的 Upload 组件并未支持拖拽排序功能,社区也没有发现可以借鉴的 demo,于是我们调研后采用 react-dnd 和 react-sortable-hoc 实现"就那种"效果。

技术分析

其实这个需求之前我们已经有一些基于 react-dnd 技术的沉淀,但是都是基于 html 的 dom 元素进行 ref 绑定操作,并没有搭配 Upload 组件。如下 demo

以上是基于 react-dnd 实现的场景拖拽,直接上核心代码

tsx 复制代码
const [, dragPreview] = useDrag({
  collect: (monitor: DragSourceMonitor) => ({
    isDragging: monitor.isDragging()
  }),
  // item 中包含 index 属性,则在 drop 组件 hover 和 drop 是可以根据第一个参数获取到 index 值
  item: { type: 'page', index }
});
const [, drop] = useDrop({
  accept: 'page',
  hover(item: { type: string; index: number }, monitor: any) {
    if (!ref.current) {
      return;
    }
    const dragIndex = item.index;
    const hoverIndex = index;

    // 拖拽元素下标与鼠标悬浮元素下标一致时,不进行操作
    if (dragIndex === hoverIndex) {
      return;
    }
    // 确定屏幕上矩形范围
    const hoverBoundingRect = ref.current!.getBoundingClientRect();
    // 获取中点垂直坐标
    const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
    // 确定鼠标位置
    const clientOffset = monitor.getClientOffset();
    // 获取距顶部距离
    const hoverClientY = (clientOffset as any).y - hoverBoundingRect.top;
    /**
       * 只在鼠标越过一半物品高度时执行移动。
       * 当向下拖动时,仅当光标低于50%时才移动。
       * 当向上拖动时,仅当光标在50%以上时才移动。
       * 可以防止鼠标位于元素一半高度时元素抖动的状况
       */
    // 向下拖动
    if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
      return;
    }
    // 向上拖动
    if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
      return;
    }

    // 执行 move 回调函数
    moveCard(dragIndex, hoverIndex);

    /**
       * 如果拖拽的组件为 Box,则 dragIndex 为 undefined,此时不对 item 的 index 进行修改
       * 如果拖拽的组件为 Card,则将 hoverIndex 赋值给 item 的 index 属性
       */
    if (item.index !== undefined) {
      item.index = hoverIndex;
    }
  }
});
dragPreview(drop(ref));

因为 Upload 组件上传文件是通过自身 fileList api 底层消化处理的,所以处理起来比较麻烦,还好 Antd 4.16.0 版本Upload 提供的 itemRender 解决了这个问题。但是对于低于这个版本的后续也有解决方案。

注意:如果 Antd 用的是最新的版本 5.x.x,其实官网也提供了 集成 dnd-kit 来实现对上传列表拖拽排序

技术选型

市面上可以实现拖拽排序的库有很多,比如 SortableJS、react-dnd、react-beautiful-dnd、react-sortable-hoc 等。 我列了一个表格:

优点 缺点
SortableJS 足够轻量级,而且功能齐全 React 中使用起来并不是太方便,而且它的配置项写起来实在不太符合 React 的思维
react-dnd 库小,贴合 react 拖拽场景 多行拖拽不理想,react-beautiful-dnd 库比较大赖于HTML5 拖放 API,这有一些严重的限制
react-beautiful-dnd 动画效果和细节非常完美
react-sortable-hoc 多行拖拽优势很明显(相比其他库大多依赖于HTML5拖放API ,这有一些严重的限制。例如,如果你需要支持触摸设备,如果你需要锁定拖动到一个轴上,或者想在节点排序时设置动画,事情就会变得很棘手。React-sortablehoc 旨在提供一组简单的 higher-order 组件来填补这些空白。如果您正在寻找一种 dead-simple ,mobile-friendly 的方式来向列表中添加可排序功能,那么您就在正确的位置了。) 列表过长,需要滑动的列表中拖拽时,滑动后位置不匹配会发生偏移

实践中还会有一些踩坑:

  • reat-dnd 在项目中快速拖拽时一直报错,"Invariant Violation: Expected targetIds to be registered. "在他的 issue 中也有很多人反应这个问题,虽然有修复过但是并没有完全修复,在 overStack 中也并没有找到好的解决方案。
  • 这里以我们实践结论,从 react-dnd、react-sortable-hoc 两个库进行讲解

react-dnd

概念

react dnd 是一组 react 高阶组件,使用的时候只需要使用对应的 API 将目标组件进行包裹,即可实现拖动或接受拖动元素的功能。 在拖动的过程中,不需要开发者自己判断拖动状态,只需要在传入的配置对象中各个状态属性中做对应处理即可,因为react-dnd 使用了 redux 管理自身内部的状态。 值得注意的是,react-dnd 并不会改变页面的视图,它只会改变页面元素的数据流向,因此它所提供的拖拽效果并不是很炫酷的,我们可能需要写额外的视图层来完成想要的效果,但是这种拖拽管理方式非常的通用,可以在任何场景下使用,非常适合用来定制。

安装
json 复制代码
npm i react-dnd
核心 API

介绍实现拖拽和数据流转的核心 API ,这里以 hook 为例。

DndProvider

使用 react-dnd 需要最外层元素加 DndProvider ,DndProvider 的本质是一个由 React.createContext 创建一个上下文的容器(组件),用于控制拖拽的行为,数据的共享,类似于 react-redux 的 Provider。

tsx 复制代码
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';

<DndProvider backend={HTML5Backend}>组建模块</DndProvider>;
Backend

react dnd 将 DOM 事件相关的代码独立出来,将拖拽事件转换为 react dnd 内部的 redux action。由于拖拽发生在 H5 的时候是 ondrag,发生在移动设备的时候是由 touch 模拟,react dnd 将这部分单独抽出来,方便后续的扩展,这部分就叫做 backend。它是 dnd 在 DOM 层的实现。

  • react-dnd-html5-backend : 用于控制 html5 事件的 backend
  • react-dnd-touch-backend : 用于控制移动端 touch 事件的 backend
  • react-dnd-test-backend : 用户可以参考自定义 backend
useDrag

让 DOM 实现拖拽能力的构子

tsx 复制代码
import React from 'react';
import { useDrag } from 'react-dnd';

export default function Player() {
  // 第一个返回值是一个对象,主要放一些拖拽物的状态。后面会介绍,先不管
  // 第二个返回值:顾名思义就是一个Ref,只要将它注入到DOM中,该DOM就会变成一个可拖拽的DOM
  const [_, dragRef] = useDrag(
    {
      type: 'Player', // 给拖拽物命名,后面用于分辨该拖拽物是谁,支持string和symbol
      item: { id: 1 } // 拖拽物所携带的数据,让后面一些事件可以拿到数据,已达到交互的目的
    },
    []
  );
  // 注入Ref,现在这个DOM就可以拖拽了
  return <div ref={dragRef} />;
}

返回三个参数
第一个返回值是一个对象 表示关联在拖拽过程中的变量,需要在传入 useDrag 的规范方法的 collect 属性中进行映射绑定,比如:isDraging, canDrag 等
第二个返回值 代表拖拽元素的 ref
第三个返回值 代表拖拽元素拖拽后实际操作到的 dom
传入两个参数

  • 第一个参数,是一个对象,是用于描述了drag 的配置信息,常用属性
type 指定元素的类型,只有类型相同的元素才能进行 drop 操作
item 元素在拖拽过程中,描述该对象的数据,如果指定的是一个方法,则方法会在开始拖拽时调用,并且需要返回一个对象来描述该元素。
end(item, monitor) 拖拽结束的回调函数,item 表示拖拽物的描述数据,monitor 表示一个 DragTargetMonitor 实例
isDragging(monitor) 判断元素是否在拖拽过程中,可以覆盖Monitor对象中的 isDragging方法,monitor 表示一个 DragTargetMonitor 实例
canDrag(monitor) 判断是否可以拖拽的方法,需要返回一个 bool 值,可以覆盖 Monitor 对象中的 canDrag 方法,与 isDragging 同理,monitor 表示一个 DragTargetMonitor 实例
collect 它应该返回一个描述状态的普通对象,然后返回以注入到组件中。它接收两个参数,一个 DragTargetMonitor 实例和拖拽元素描述信息item
  • 第二个参数是一个数组,表示对方法更新的约束,只有当数组中的参数发生改变,才会重新生成方法,基于react 的 useMemo 实现
useDrop

实现拖拽物放置的钩子

tsx 复制代码
import { useDrop } from 'react-dnd';

export const Dustbin = () => {
  const [_, dropRef] = useDrop({
    accept: ['Player'], // 指明该区域允许接收的拖放物。可以是单个,也可以是数组
    // 里面的值就是useDrag所定义的type

    // 当拖拽物在这个拖放区域放下时触发,这个item就是拖拽物的item(拖拽物携带的数据)
    drop: (item) => {}
  });

  // 将ref注入进去,这个DOM就可以处理拖拽物了
  return <div ref={dropRef}></div>;
};

返回两个参数
第一个返回值是一个对象 表示关联在拖拽过程中的变量,需要在传入 useDrag 的规范方法的 collect 属性中进行映射绑定。
第二个返回值 代表拖拽元素的 ref 传入一个参数 用于描述drop的配置信息,常用属性

accept 指定接收元素的类型,只有类型相同的元素才能进行 drop 操作
drop(item, monitor) 有拖拽物放置到元素上触发的回调方法,item 表示拖拽物的描述数据,monitor 表示 DropTargetMonitor 实例,该方法返回一个对象,对象的数据可以由拖拽物的 monitor.getDropResult 方法获得
hover(item, monitor) 当拖住物在上方 hover 时触发,item 表示拖拽物的描述数据,monitor表示 DropTargetMonitor 实例,返回一个 bool 值
canDrop(item, monitor) 判断拖拽物是否可以放置,item 表示拖拽物的描述数据,monitor 表示 DropTargetMonitor 实例,返回一个 bool 值
API 数据流转

react-sortable-hoc

概念

react-sortable-hoc 是一个基于 React 的拖拽排序组件,它可以让你轻松地实现拖拽排序功能。它提供了一系列的 API,可以让你自定义拖拽排序的行为。它支持拖拽排序的单个列表和多个列表,以及拖拽排序的可视化。

安装
shell 复制代码
npm install react-sortable-hoc
引入
typescript 复制代码
import { SortableContainer, SortableElement, arrayMove, SortableHandle } from 'react-sortable-hoc';
核心 API
  • sortableContainer 是所有可排序元素的容器
  • sortableElement 是每个可渲染元素的容器
  • sortableHandle 是定义拖拽手柄的容器
  • arrayMove 主要用于将移动后的数据排列好后返回
SortableContainer HOC
Property Type Default Description
axis String y 项目可以水平、垂直或网格排序。可能值:x、y 或 xy
lockAxis String 如果您愿意,可以在排序时将移动锁定在轴上。这不是 HTML5 拖放所能做到的。可能值:x 或 y。
helperClass String 您可以提供一个要添加到 sortable helper 的类,以向其添加一些样式
transitionDuration Number 300 元素移动位置时转换的持续时间。{ 39d 要禁用 @661 }
keyboardSortingTransitionDuration Number transitionDuration 在键盘排序期间移动辅助对象时转换的持续时间。如果要禁用键盘排序助手的转换,请将其设置为 0。如果未定义,则默认为 transitionDuration 设置的值
keyCodes Array {lift: [32],drop: [32],cancel: [27],up: [38, 37],down: [40, 39]} 一个包含每个 keyboard-accessible 操作的键码数组的对象。
pressDelay Number 0 如果您希望元素只在按下一段时间后才可排序,请更改此属性。mobile 的一个合理的默认值是 200。不能与 distance 属性一起使用。
pressThreshold Number 5 忽略冲压事件之前要容忍的移动像素数。
distance Number 0 如果您希望元素只在被拖动一定数量的像素之后才变得可排序。不能与 pressDelay 属性一起使用。
shouldCancelStart Function Function 此函数在排序开始前调用,可用于在排序开始前以编程方式取消排序。默认情况下,如果事件目标是 input、textarea、select 或 option,它将取消排序。
updateBeforeSortStart Function 在排序开始之前调用此函数。它可以返回一个 promise,允许您在排序开始之前运行异步更新(比如 setState )。function ({ node, index, collection, isKeySorting }, event )
onSortStart Function 开始排序时调用的回调。function({ node, index, collection, isKeySorting }, event ) |
onSortMove Function 当光标移动时在排序期间调用的回调。function ( event )|
onSortOver Function 在向上移动时调用的回调。function ({ index, oldIndex, newIndex, collection, isKeySorting }, e )
onSortEnd Function 排序结束时调用的回调。function ({ oldIndex, newIndex, collection, isKeySorting }, e )
useDragHandle Boolean false 如果您使用的是SortableHandleHOC,请将其设置为true
useWindowAsScrollContainer Boolean false 如果需要,可以将window设置为滚动容器
hideSortableGhost Boolean true 是否 auto-hide 重影元素。默认情况下,为了方便起见,React Sortable List 将自动隐藏当前正在排序的元素。如果要应用自己的样式,请将此设置为 false。
lockToContainerEdges Boolean false 您可以将可排序元素的移动锁定到其父元素 SortableContainer
lockOffset OffsetValue*|[OffsetValue*,OffsetValue*] "50%" 当 lockToContainerEdges 设置为 true 时,这将控制可排序辅助对象与其父对象 SortableContainer 的上/下边缘之间的偏移距离。百分比值相对于当前正在排序的项的高度。如果您希望指定不同的行为来锁定容器的顶部和底部,您还可以传入 array(例如:["0%", "100%"])。
getContainer Function 返回可滚动容器元素的可选函数。此属性默认为 SortableContainer 元素本身或(如果 useWindowAsScrollContainer 为真)窗口。使用此函数指定一个自定义容器对象(例如,这对于与某些第三方组件(如 FlexTable )集成非常有用)。这个函数被传递给一个参数(即 wrappedInstanceReact 元素),它应该返回一个 DOM 元素。
getHelperDimensions Function Function 可选的function ({ node, index, collection }),它应该返回 SortableHelper 的计算维度。有关详细信息,请参见默认实现 |
helperContainer HTMLElement | 函数 document.body 默认情况下,克隆的可排序帮助程序将附加到文档正文。使用此属性可指定要附加到可排序克隆的其他容器。接受 HTMLElement 或返回 HTMLElement 的函数,该函数将在排序开始之前调用
disableAutoscroll Boolean false 拖动时禁用自动滚动
如何使用

直接上demo

tsx 复制代码
import React from 'react';
import { arrayMove, SortableContainer, SortableElement } from 'react-sortable-hoc';

// 需要拖动的元素的容器
const SortableItem = SortableElement((value) => <div>{value}</div>);
// 整个元素排序的容器
const SortableList = SortableContainer((items) => {
  return items.map((value, index) => {
    return <SortableItem key={`item-${index}`} index={index} value={value} />;
  });
});

// 拖动排序组件
class SortableComponnet extends React.Component {
  state = {
    items: ['1', '2', '3']
  };
  onSortEnd = ({ oldIndex, newIndex }) => {
    this.setState(({ items }) => {
      arrayMove(items, oldIndex, newIndex);
    });
  };
  render() {
    return (
      <div>
        <SortableList
          distance={5}
          axis={'xy'}
          items={this.state.items}
          helperClass={style.helperClass}
          onSortEnd={this.onSortEnd}
          />
      </div>
    );
  }
}

export default SortableComponnet;

在上面的示例中,我们使用 SortableContainer 组件容纳了一组可拖拽排序的元素,使用 SortableElement 组件包裹了每个元素,并且实现了 onSortEnd 回调函数,以便在拖拽排序完成后更新状态。

效果展示
踩坑

解决:这种报错的解决方法都是 SortableElement 和 SortableContainer 返回组件时外面都要单独在包一个 html 容器标签, 例子是包了个 <div>

结果导向

Antd 版本 4.16.0及以上

使用 react-dnd 搭配 Upload 上传组件 itemRender api实现

注:这里主要基于 react-dnd 实现,Antd 5.x.x 官网有基于 dnd-kit 来实现对上传列表拖拽排序。

tsx 复制代码
const Box: React.FC<BoxProps> = ({ children, index, className, onClick, moveCard }) => {
  const ref = useRef<HTMLDivElement>(null);

  const [, dragPreview] = useDrag({
    collect: (monitor: DragSourceMonitor) => ({
      isDragging: monitor.isDragging()
    }),
    // item 中包含 index 属性,则在 drop 组件 hover 和 drop 是可以根据第一个参数获取到 index 值
    item: { type: 'page', index }
  });
  const [, drop] = useDrop({
    accept: 'page',
    hover(item: { type: string; index: number }) {
      if (!ref.current) {
        return;
      }
      const dragIndex = item.index;
      const hoverIndex = index;
      // 自定义逻辑处理

      // 执行 move 回调函数
      moveCard(dragIndex, hoverIndex);
    }
  });
  dragPreview(drop(ref));

  return (
    <div ref={ref} className={className} onClick={onClick}>
      {children}
    </div>
  );
};
使用

使用 Antd Upload itemRender

Antd 版本低于 4.16.0

使用 react-sortable-hoc 库实现
  • SortableItem
tsx 复制代码
const SortableItem = SortableElement((params: SortableItemParams) => (
  <div>
    <UploadList
      locale={{ previewFile: '预览图片', removeFile: '删除图片' }}
      showDownloadIcon={false}
      showRemoveIcon={params?.props?.disabled}
      listType={params?.props?.listType}
      onPreview={params.onPreview}
      onRemove={params.onRemove}
      items={[params.item]}
      />
  </div>
));
  • SortableList
tsx 复制代码
const SortableList = SortableContainer((params: SortableListParams) => {
  return (
    <div className='sortableList'>
      {params.items.map((item, index) => (
      <SortableItem
        key={`${item.uid}`}
        index={index}
        item={item}
        props={params.props}
        onPreview={params.onPreview}
        onRemove={params.onRemove}
        />
    ))}
      {/* 这里是上传组件,设置最大限制后超出隐藏 */}
      <Upload {...params.props} showUploadList={false} onChange={params.onChange}>
        {params.props.children}
      </Upload>
    </div>
  );
});
  • DragHoc
tsx 复制代码
const DragHoc: React.FC<Props> = memo(
  ({ onChange: onFileChange, axis, onPreview, onRemove, ...props }) => {
    const fileList = props.fileList || [];
    const onSortEnd = ({ oldIndex, newIndex }: SortEnd) => {
      onFileChange({ fileList: arrayMove(fileList, oldIndex, newIndex) });
    };

    const onChange = ({ fileList: newFileList }: UploadChangeParam) => {
      onFileChange({ fileList: newFileList });
    };

    return (
      <>
        <SortableList
          // 当移动 1 之后再触发排序事件,默认是0,会导致无法触发图片的预览和删除事件
          distance={1}
          items={fileList}
          onSortEnd={onSortEnd}
          axis={axis || 'xy'}
          helperClass='SortableHelper'
          props={props}
          onChange={onChange}
          onRemove={onRemove}
          onPreview={onPreview}
          />
      </>
    );
  }
);
使用方式

可直接替换掉 Upload 组件,props 不变。 如果项目中有已经封装好的 Upload 上传组件,尽量不改变原有逻辑代码前提下,更希望以插件的形式按需加载?方案:

可以剔除掉 SortableList 中 SortableContainer 包裹的 Upload 组件(这一步经过实践是可行的,说 Upload UploadList 都要被 SortableContainer 包裹,否走会重复上传和拖拽失败?目前我是没遇到,重复上传是因为 Upload 组件 showUploadList 拖拽场景下必须是 false )

使用案例:( isDrag 表示需要拖拽场景,继而加载)

tsx 复制代码
{isDrag && (
  <DragHoc
    accept={accept}
    axis={axis}
    showUploadList={{ showRemoveIcon }}
    fileList={fileList}
    onChange={(e) => {
      if (onChange) {
        onChange(e);
      }
    }}
    onPreview={preview}
    onRemove={remove}
    listType={listType}
    />
)}

注:当然也可以用 react-dnd 来实现,只是多行拖拽流畅性较差。感兴趣也可以试试

踩坑
图片按钮点击无效

在 Antd 的 Upload 组件中,图片墙上会有「预览」、「删除」等按钮,但是在 react-sortable-hoc 的逻辑中,只要我点击了图片,就会触发图片的拖拽函数,无法触发图片上的各种按钮,所以需要在 SortableList 上重新设置一下 distance 属性,设置成 1 即可。

官网:

If you'd like elements to only become sortable after being dragged a certain number of pixels. Cannot be used in conjunction with the pressDelay prop.

(如果您希望元素仅在拖动一定数量的像素后才可排序。不能与 pressDelay 道具一起使用。默认为 0)

上传图片一直 uploading

原因:当图片列表发生变化,整个 sortable 容器被删除并重新渲染,导致请求失效。

解决方案:

需要将 SortableItem,SortableList 写在 React.FC 外面,每次组件内部 state 发生变化,不会重新执行 SortableContainer 和 SortableElement 方法,就可以让可排序容器里面的元素自动只更新需要改变的 DOM 元素,而不会整个删除并重新渲染了。

图片的 disabled 状态失效

原因:SortableContainer 包裹的组件对 Upload 图片和上传进行了拆分处理,所以需要单独去控制预览和删除按钮

tsx 复制代码
const SortableItem = SortableElement((params: SortableItemParams) => (
  <div>
    <UploadList
      locale={{ previewFile: '预览图片', removeFile: '删除图片' }}
      showDownloadIcon={false}
      showRemoveIcon={params?.props?.disabled} //这里需要单独控制
      listType={params?.props?.listType}
      onPreview={params.onPreview}
      onRemove={params.onRemove}
      items={[params.item]}
      />
  </div>
));
效果展示

参考文献

相关推荐
学习使我快乐012 小时前
JS进阶 3——深入面向对象、原型
开发语言·前端·javascript
bobostudio19952 小时前
TypeScript 设计模式之【策略模式】
前端·javascript·设计模式·typescript·策略模式
黄尚圈圈3 小时前
Vue 中引入 ECharts 的详细步骤与示例
前端·vue.js·echarts
浮华似水4 小时前
简洁之道 - React Hook Form
前端
正小安6 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
_.Switch7 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光7 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   7 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发
长路 ㅤ   7 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d
Fan_web7 小时前
jQuery——事件委托
开发语言·前端·javascript·css·jquery