一个小组件之拖拽组件(Drag and Drop)

4,577 阅读9分钟

前言

拖拽功能在我们的开发过程中经常会遇到,应用拖拽功能的场景也是多种多样的,今天我们就来一步步的实现一个简单、通用的React 拖拽组件,解决元素拖拽的需求。

应用场景

首先,先看看我们实现的拖拽组件的最终的应用效果吧:

拖拽元素调整位置

应用我们封装的拖拽组件可以实现元素之间通过拖拽调整元素的位置、顺序:

dragdrop-move.gif

拖拽元素复制到另一个区域

应用我们封装的拖拽组件还可以实现将元素从一个区域复制到另一个区域:

dragdrop-copy.gif

当然,除了以上展示的两种应用场景外,我们还可以利用这个拖拽组件满足各种各样的拖拽需求。

那么,接下来,我们将重点介绍如何实现这样一个简单、通用的 React 拖拽组件。

拖拽 API

HTML 为我们提供了许多与实现元素拖拽(Drag and Drop)相关的 API,这是实现拖拽组件的基石。

实现一个拖拽组件必须让我们的元素具备两个特性,即 drag 和 drop。

drag 相关

HTML 定义的与 drag 相关事件有:

  • ondrag 当拖拽元素时触发。
  • ondragend 当拖拽操作结束时触发。
  • ondragenter 当拖拽元素到一个可释放目标时触发。
  • ondragexit 当元素变得不再是拖拽操作的选中目标时触发。
  • ondragleave 当拖拽元素离开一个可释放目标时触发。
  • ondragover 当元素被拖到一个可释放目标上时触发(每100毫秒触发一次)。
  • ondragstart 当用户开始拖拽一个元素时触发。

让一个元素具有 Drag 特性,只需要给相应的元素添加 draggable 属性,便可以响应元素上与 drag 相关事件。

drop 相关

而 HTML 定义的与 drop 相关的事件只有一个:

  • ondrop 当元素在可释放目标上被释放时触发。 让一个元素具备 drop 特征,需要在该元素上添加对 ondrop 事件和 ondragover 事件的监听。

dataTransfer 对象

除此之外,与 drag 和 drop 息息相关的还有一个 dataTransfer 对象,这个对象存在于所有的 drag 和 drop 相关的事件对象 DragEvent 中。

设置 dataTransfer 的 dropeffct,可以控制当拖动元素到可 drop 元素上时鼠标呈现的样式。

dropeffct 属性可设置为:

  • move
  • copy
  • link
  • none

在不同的拖拽应用场景,通过给 dropeffct 设置合适的值,可以呈现更好的视觉效果。

相关 API 详情可参考官方文档。

在我们设计的 React 组件中,主要会使用 ondragstart 、ondragend、ondragover、 ondragleave 和 ondrop 事件。

拖拽组件设计

我们设计的 react 拖拽组件,主要由四个部分组成:draggableConnect、droppableConnect、DndComponent 以及 DndManager 。

  • DndManager:管理器,管理拖拽组件的数据、状态和交互。
  • DndComponent:可拖拽元素的容器,是拖拽组件的主体,支持拖拽组件相关的配置。
  • draggableConnect: 为 React 元素添加 drag 相关的属性和事件。
  • droppableConnect: 为 React 元素添加 drop 相关的属性和事件。

DndManager

DndManager 由 DndManagerContext 和 DndManager组件构成。

在拖拽过程中所有 draggable 元素可定义为 source,所有的 droppable 元素可定义为 target。

image.png

DndManagerContext

在创建 DndManagerContext 过程中,我们使用到了 React Context 相关的知识。Context 能为一个组件树提供一个共享的“全局”数据,组件树内的组件都能消费这些“全局”数据,而无需通过 props 逐层传递。(参考 React 官网 Context 的介绍)

DndManagerContext 应该包含对所有 source 和 target 集合的管理,以及对拖拽过程的状态和当前拖拽的元素的管理。所以在DndManagerContext中需要定义 sourceMap 和 targetMap 以及相关的增删方法,定义 result 记录拖拽的过程和修改记录的方法 changeResult 。

export enum EDragResultStatus {
  DRAG = 'DRAG',
  DROP = 'DROP',
  CANCEL = 'CANCEL',
}

export type sourceId = string | null;
export type targetId = string | null;
export type dropMode = DataTransfer['dropEffect'];
export type source = any;
export type target = any;

export type sourceMap = Record<string, source>;
export type targetMap = Record<string, target>;

export interface IResult {
  sourceId: sourceId;
  targetId: targetId;
  status: EDragResultStatus;
  hoverId: targetId;
}

export interface IDndManager {
  dropMode: dropMode;
  sourceMap: sourceMap;
  targetMap: targetMap;
  result?: IResult;
  changeResult?: (result: Partial<IResult>) => void;
  addSource: (sourceId: sourceId, source: source) => void;
  removeSource: (sourceId: sourceId) => void;
  addTarget: (targetId: targetId, target: target) => void;
  removeTarget: (targetId: targetId) => void;
}

首先,我们通过 React.createContext 创建一个 DndManagerContext,并设置初始值:defaultContext。

import React from 'react';
import { IDndManager } from './type';

// Initial value of DndManagerContext
const defaultContext: IDndManager = {
  dropMode: 'move',
  sourceMap: {},
  targetMap: {},
  addSource: console.log,
  removeSource: console.log,
  addTarget: console.log,
  removeTarget: console.log,
};

// Context lets us pass a value deep into the component tree
// without explicitly threading it through every component.
export const DndManagerContext = React.createContext<IDndManager>(defaultContext);

DndManager Component

接着,我们需要创建 DndManager 组件,我们需要在 DndManager 组件中为 DndManagerContext 赋予真正有效的值和方法,并通过 Context.Provider 使得组件树内的任何组件都可以订阅到 DndManagerContext 的内容。

每个 Context 对象都会返回一个 Provider React 组件,它允许消费组件订阅 context 的变化

DndManager Component 接收 dropMode 和 onDragEnd 作为 props,dropMode支持外部设置拖拽的模式,onDragEnd方法当拖拽的状态为 'drop' 时,会执行。

interface Props {
  children: React.ReactNode;
  onDragEnd: (result: IResult) => void;
  dropMode?: dropMode;
}

interface State {
  dropMode: dropMode;
  sourceMap: sourceMap;
  targetMap: targetMap;
  result: IResult;
}

export default class DndManager extends Component<Props, State> {
  // value of Context 
  state: State = {
    dropMode: this.props.dropMode || 'move',
    sourceMap: {},
    targetMap: {},
    result: {
      targetId: null,
      sourceId: null,
      status: null,
      hoverId: null,
    },
  };
  // change the result of dnd
  public changeResult(result: Partial<IResult>) {
    const { onDragEnd } = this.props;
    const newResult = { ...this.state.result, ...result };
    // when the status is 'drop', trigger 'onDragEnd' event
    if (result.status && result.status === DragResultStatusEnum.DROP) {
      onDragEnd(newResult);
    }
    this.setState({ ...this.state, result: newResult });
  }
  // add a source that draggable elemnt
  public addSource(sourceId: sourceId, source: source) {
    ...
  }
  // add a target that droppable elemnt
  public addTarget(targetId: targetId, target: target) {
    ...
  }
  // remove a source 
  public removeSource(sourceId: sourceId) {
    ...
  }
  // remove a target
  public removeTarget(targetId: targetId) {
    ...
  }
  // get the value of dropMode from props and set to the state
  componentWillReceiveProps(nextProps: Props) {
    const { dropMode } = this.state;
    if (dropMode !== nextProps.dropMode) {
      this.setState({
        dropMode: nextProps.dropMode,
      });
    }
  }

  render() {
    const { children } = this.props;

    return (
      // Every Context object comes with a Provider React component
      // that allows consuming components to subscribe to context changes
      <DndManagerContext.Provider
        value={{
          ...this.state,
          addSource: this.addSource.bind(this),
          addTarget: this.addTarget.bind(this),
          removeSource: this.removeSource.bind(this),
          removeTarget: this.removeTarget.bind(this),
          changeResult: this.changeResult.bind(this),
        }}
      >
        {children}
      </DndManagerContext.Provider>
    );
  }
}

DndComponet

DndComponet 是拖拽组件的主体部分,它主要具备三个职责:

  • 通过 React.useContext() 订阅 DndManagerContext。
  • 向 DndManager 上报 source 和 target 的变化。
  • 判断 sourceId 和 targetId,执行 draggableConnect 和 droppableConnect。

export interface IDragDropProps {
  children: React.ReactElement 
  sourceId?: string;
  targetId?: string;
}

const DndComponent = ({ children, sourceId, targetId }: IDragDropProps): React.ReactElement => {
  // get ref
  const dndContainerRef = React.useRef<HTMLElement>();
  // subscribe context 
  const dndManager = React.useContext(DndManagerContext);

  React.useEffect(() => {
    // check targetId and sourceId and then execute dndManager.add
    if (targetId) {
      dndManager.addTarget(targetId, dndContainerRef);
    }
    if (sourceId) {
      dndManager.addSource(sourceId, dndContainerRef);
    }
  }, [children, sourceId, targetId]);
  // when component unmount, execute dndManager.remove
  React.useEffect(() => {
    return () => {
      if (sourceId) dndManager.removeSource(sourceId);
      if (targetId) dndManager.removeTarget(targetId);
    };
  }, []);
  // dropabbleConnect or draggableConnect with children
  return cloneElement(
    dropabbleConnect(
      draggableConnect(
        children,
        sourceId,
        dndManager
      ),
      targetId,
      dndManager
    ),
    { ref: dndContainerRef }
  );
};
export default DndComponent;

draggableConnect

draggableConnect 使用 React.cloneElement() 将目标元素克隆,并为其添加 drag 相关的属性,返回具备 drag 特性的 React 元素。

cloneElement 以 element 元素为样板克隆并返回新的 React 元素。返回元素的 props 是将新的 props 与原始元素的 props 浅层合并后的结果。新的子元素将取代现有的子元素,而来自原始元素的 key 和 ref 将被保留。

export function draggableConnect(
  element: React.ReactElement,
  sourceId: sourceId,
  dndManager: IDndManager
): React.ReactElement {

  ...
  ...
  
  return React.cloneElement(element, {
    draggable: true,
    onDragStart: dragStartHandler,
    onDragEnd: dragEndHandler,
  });
}

添加的属性包括draggable、onDragStart和onDragEnd。

onDragStart事件是拖拽过程的起点,在dragStartHandler中,设置拖拽模式 e.dataTransfer.dropEffect,将 dndManager 的拖拽状态设置为 ‘drag',并且将当前元素的 id 设置为整个拖拽过程的 sourceId。

  const dragStartHandler = (e: React.DragEvent<HTMLDivElement>) => {
    if (dndManager.dropMode) e.dataTransfer.dropEffect = dndManager.dropMode;
    // init status of dnd and set sourceId 
    dndManager?.changeResult({
      status: DragResultStatusEnum.DRAG,
      sourceId: sourceId,
      hoverId: null,
      targetId: null,
    });
  };

onDragEnd事件是整个拖拽过程最后触发的事件,在dragEndHandler中,判断 dndManager.result 的 sourceId 和 targetId,如果都存在则意味元素正常 drop,设置拖拽状态为 ‘drop‘,否则说明拖拽事件取消,设置状态为 ‘cancel’。

  const dragEndHandler = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    // Set different status values by judging the sourceId and targetId
    if (dndManager.result.sourceId && dndManager.result.targetId) {
      dndManager?.changeResult({
        status: DragResultStatusEnum.DROP,
        hoverId: null,
      });
    } else {
      dndManager?.changeResult({
        status: DragResultStatusEnum.CANCEL,
        hoverId: null,
        sourceId: null,
        targetId: null,
      });
    }
  };

droppableConnect

droppableConnect 使用 React.cloneElement() 将目标元素克隆,并为其添加 drop 相关的属性onDrop、onDragOver、onDragLeave,返回具备 drop 特性的 React 元素。

export function dropabbleConnect(
  element: React.ReactElement,
  targetId: targetId,
  dndManager: IDndManager
): React.ReactElement {

  ...
  ...
  // clone element 
  return React.cloneElement(element, {
    onDrop: dropHandler,
    onDragOver: dragOverHandler,
    onDragLeave: dragLeaveHandler,
  });
}

onDrop在元素完成 drop 时触发,在dropHandler中将 dndManager 当前拖拽过程的 targetId 设置为当前元素的 Id。

  const dropHandler = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    if (dndManager.dropMode) e.dataTransfer.dropEffect = dndManager.dropMode;
    // set targetId
    dndManager.changeResult({
      targetId: targetId,
    });
  };

onDragOver 和 onDragLeave 在拖拽元素进入和离开可 drop 元素时触发,在事件监听函数中主要完成 dndManager 的 hoverId 的设置 。

  const dragOverHandler = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    if (dndManager.dropMode) e.dataTransfer.dropEffect = dndManager.dropMode;
    // set hoverId
    dndManager.changeResult({
      hoverId: targetId,
    });
  };
  const dragLeaveHandler = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    // clear hoverId
    dndManager.changeResult({
     hoverId: null,
    });
  };

以上便完成了拖拽组件四个主要部分的创建,并且通过 DndManager 组件 和 DndComponent 组件,实现拖拽功能:

    <DndManager
      onDragEnd={(v) => {
        console.log(v);
      }}
    >
      <div
        style={{
          display: 'flex',
          justifyContent: 'space-between',
          width: '300px',
          marginTop: '200px',
        }}
      >
        <DndComponent sourceId="source_1">
          <button>Source</button>
        </DndComponent>
        <DndComponent targetId="target_1">
          <button>Target</button>
        </DndComponent>
      </div>
    </DndManager>

不过,到这里并不意味着结束,还有一些case需要处理。

Enhancements

当拖拽元素进入可 drop 的 traget 元素时,我们希望可以给 target 添加特定的样式,例如改变元素背景,如下图:

Jan-09-2022 00-35-08.gif

实现这个的效果,我们需要判断当前元素是否在拖拽过程中 hover。在 dndManager.result 中有关于 hover 元素的信息,即hoverId,通过判断 hoverId 和当前元素的 id 可以判断元素是否 hover, 即 isDragOver 。

{ isDragOver: dndManager.result.hoverId === targetId }

除此之外,还需要能够在 DndComponent 组件内的子元素获取到 isDragOver 的值。

如何在组件的 children 中获取到组件内的内容呢? 实现这一点,需要让 DndComponent 组件支持函数类型的 children,而 isDragOver 可以作为函数的 argument。

interface IDragDropProps {
  children: React.ReactElement | (({ isDragOver }: { isDragOver: boolean }) => React.ReactElement);
  ...
}

const DndComponent = ({ children, sourceId, targetId }: IDragDropProps): React.ReactElement => {

  ...
  ...

  return cloneElement(
    dropabbleConnect(
      draggableConnect(
        // 判断 children 的类型
        typeof children === 'function'
          // 将 isDragOver 传递给 children
          ? children({ isDragOver: dndManager.result.hoverId === targetId }) 
          : children,
        sourceId,
        dndManager
      ),
      targetId,
      dndManager
    ),
    { ref: dndContainerRef }
  );
};

这样我们就可以在 target 元素中获取 isDragOver 属性,并实现特效。

<DndComponent targetId="target_1">
  {({ isDragOver }) => (
    <button style={{ background: isDragOver ? '#e6f7ff' : '#fff' }}>Target</button>
  )}
</DndComponent>

边界 case 处理

在我们的组件中还发现一个问题,target 元素响应了非同一 manager 的 source 元素的 drop 事件。 Jan-09-2022 01-23-05.gif

当拖拽组件分属不同的 manager 时,往往意味着它们有不同的拖拽行为,因此不同 manager 管理的拖拽组件之间不应该产生交互。

解决这个问题需要在 target 元素的事件中添加对 sourceId 的判定。

  const dropHandler = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    const { sourceId } = dndManager.result;
    // check sourceId
    if (sourceId && dndManager.sourceMap[sourceId]) {
      if (dndManager.dropMode) e.dataTransfer.dropEffect = dndManager.dropMode;
      dndManager.changeResult({
        targetId: targetId,
      });
    }
  };
  const dragOverHandler = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    const { sourceId } = dndManager.result;
    // check sourceId
    if (sourceId && dndManager.sourceMap[sourceId]) {
      if (dndManager.dropMode) e.dataTransfer.dropEffect = dndManager.dropMode;
      dndManager.changeResult({
        hoverId: targetId,
      });
    } else {
      e.dataTransfer.dropEffect = 'none';
    }
  };
  const dragLeaveHandler = (e: React.DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    const { sourceId } = dndManager.result;
    // check sourceId
    if (sourceId && dndManager.sourceMap[sourceId]) {
      dndManager.changeResult({
        hoverId: null,
      });
    }
  };

解决后的拖拽效果:

disabledrop-different.gif

总结

在本篇文章中 我们结合拖拽 API 完成了一个简单通用的拖拽组件设计,在这个过程中也总结了几个关键的点:

  • 保持组件的简洁,只对外暴露 manager 和 dnd 组件就能够满足多样的需求。
  • 采用 manager 统一管理多个组件的拖拽行为和状态。
  • 利用 Context 实现 manager,这样的方式具备两个优点:
    • 将 manager 作为组件树的全局变量
    • 不用关心拖拽组件在整个组件树的位置,意味着可以随意的组合内部的元素
  • 采用 cloneElement,可以在不影响元素原有属性的前提下,添加额外属性,扩展元素。
  • draggableConnect 和 droppableConnect 保持独立,元素既可以具备单一特性,也可以两者都具备。