从Drag事件到H5搭建平台(附简易源码)

365 阅读10分钟

最近在做一个拖拽生成H5页面的项目。期间做了一些调研,也看过一些文档,在这里做个记录,方便之后回忆。

1. Drag原生事件

首先,拖拽组件本质上就是在调整DOM的顺序、结构。drag事件是浏览器事件中的一种。不仅如此还包含了拖拽过程中的事件节点,这里我想把他们称之为拖拽过程中的生命周期

developer.mozilla.org/zh-CN/docs/…

  • dragstart
  • drag
  • dragenter
  • dragover
  • dragleave
  • dragend

在做拖拽的相关的需求之前,建议先将MDN中的原生api阅读一下。一来了解一下拖拽的"庐山真面目",二来也感受一下浏览器的边界能力在哪里。

因为react-dnd,react-beautiful-dnd不过是对浏览器的能力做了一层封装。而这种库的介绍文档太长不容易在短时间内了解其所有功能。

2. React-dnd 库

首先来看一下React-dnd库的自我介绍

React DnD 是一组 React 实用程序,可帮助您构建复杂的拖放界面,同时保持组件解耦。它特别适用于 Trello 和 Storify 这种应用,在拖拽的同时可以进行数据传输,并且改变组件的样式和状态。

抓重点!数据传输状态

2.1 一些特性(了解Rect-dnd的设计理念)

  • React-dnd的操作对象是components
  • React-dnd的数据流是单项的(其实是借鉴了Redux)
  • React-dnd帮你抹平了浏览器的兼容性问题
  • React-dnd支持拓展和测试(默认是使用HTML5的drag和dorp事件,不过你也可以自定义“backend”;支持在Node环境下测试组件的交互)

2.2 一些概念

  • Items

    一个Item就是一个用来描述正在被拖拽内容的普通JavaScript对象;当你拖拽一个 banner组件时,Item的内容可能就是这样的:

    {
        type: "BANNER",
        version: "1.0.0",
        componentName: "banner",
        id: "BANNER_1-0-0_uuid1630686650775gavonSbf",
        isBussinessComponent: false,
    }
    
  • Types

类型是一个字符串(或符号),它唯一地标识了应用程序中的整个项目类。类型很有用,因为随着您的应用程序的增长,您可能希望使更多内容可拖动,但您不一定希望所有现有的放置目标突然开始对新项目做出反应。 这些类型允许您指定兼容的拖放源和放置目标。

  • Monitors

    拖拽本质上是有状态的。拖拽这个动作要么正在进行,要么不在进行(dragging),当前时刻要么存在被拖拽的元素(item)和元素类型(type),要么没有。

    React-dnd通过Monitors的形式将内部的状态暴露给组件。当dragdrop事件的状态发生改变,component 就可以对更新后的props做出相应的改变。

    假设你想当A组件被拖拽的时候,B区域显示高亮的效果,那么就可以给B区域组件传一个 collect 函数。(monitor具体的API后面会介绍)

    function collect(connect, monitor) {
      	return {
          	highlighted: monitor.canDrop(),
        	  hovered: monitor.isOver()
        }
    }
    
  • connectors

    假设我们的"后端"是基于DOM Events来处理的,就像我们 1. Drag原生事件 中提到的那样。但是我们用于描述DOM的本质上是JSX,那么React-dnd的"后台"在处理时怎么知道哪个DOM是需要添加dragable=true的呢?

    connectors允许你给你的组件预定义一个角色,有一下三种:

    1. drag source 拖拽源(比如我们的组件物料)

    2. drag preview 拖放预览

    3. drop target 拖放目标

      假设我们下面来定义一个允许放置拖拽目标的组件,首先在collect函数中拿到connect

function collect(connect, monitor) {
	return {
        highlighted: monitor.canDrop(),
        hovered: monitor.isOver(),
        connectDropTarget: connect.dropTarget()
    }
}

然后我们就可以在组件的render函数中拿到从monitor中传下来的props,并且JSX用connectDropTarget包括起来用于drag、drop、dragover等原生事件的监听。

render() {
  const { highlighted, hovered, connectDropTarget } = this.props;

  return connectDropTarget(
    <div className={classSet({
      'Cell': true,
      'Cell--highlighted': highlighted,
      'Cell--hovered': hovered
    })}>
      {this.props.children}
    </div>
  );
}
  • Drag Sources and Drop Targets

相信从上面的例子中,你已经体会到了拖放目标的基本用法。这里我们再来详细的讲述一下。

如果你希望React-dnd来帮你管理一些拖拽中的状态,使你的组件拥有可拖、可放置、可预览、甚至是可拖可放(比如调整顺序)的能力,那么需要以下4要素。

  1. types 用来描述正在被拖拽内容的普通JavaScript对象
  2. items 对组件的描述
  3. side effects 拖拽过程中的一些生命周期函数
  4. collecting function(monitor) 给组件传递一些拖拽过程中组件内部需要感知的属性

那么一个被React-dnd包装的组件可能就长这样

import { DragSource } from 'react-dnd';


function collectFunc(connect, monitor) {
  return {
    connectDragSource: connect.dragSource(),
    isDragging: monitor.isDragging(),
  };
}

const cardMonitors = {

  isDragging(props, monitor) {
    return monitor.getItem().id === props.id;
  },

  // 在这个函数中构造被拖拽函数的数据结构
  beginDrag(props) {
    return {
      id: props.id,
    };
  },

  // 在endDrag中判断是否成功添加元素,但是endDrag感觉有延迟,会等待一段时间才执行
  endDrag(props, monitor) {
  	console.log('endDrag');
  }
};

// ...
export default DragSource('box', cardMonitors, collectFunc)(DragableWrapper)

一个既能拖又能放的组件可能就长这样

import { DragSource, DropTarget } from 'react-dnd';

//...

(DropTarget(
  'box',
  dropCardSource,
  (connect) => ({
    connectDropTarget: connect.dropTarget(),
  }),
)(
  DragSource(
    'box',
    dragCardSource,
    (connect, monitor) => ({
      connectDragSource: connect.dragSource(),
      isDragging: monitor.isDragging(),
    }),
  )(<DragableAndDropableWrapper />),
)
  • Backends

React-dnd默认使用HTML5 drag and drop API作为 Backends 的默认值。但是HTML5 drag and drop API也存在一些缺陷:在以 touch 事件为主的屏幕上是不支持的,这也是为什么在React-dnd中将HTML5 drag and drop API设计成成可插拔的。你也可以基于 touch 事件、mouse 事件来对组件的拖拽事件进行实现,由拖拽事件组成的插件在React-dnd就称为 backends。目前官方列出的插件

  1. react-dnd-html5-backend (默认)
  2. react-dnd-touch-backend (对平板、移动设备的事件支持)
  3. react-dnd-test-backend (用做测试)

3. 搭建拖拽平台

首先,参照开源项目搭建基本的页面结构

3.1 组件可拖拽

接下来的工作,就是让我们左边的组件物料可拖拽。参照react-dnd的思路,我们需要将组件用dragSource包裹一下。因此我们写一个高阶函数专门给组件提供拖拽的能力。

import React, { Component } from 'react';
import { DragSource } from 'react-dnd';

// 将事件的状态变化通知给组件
function collectFunc(connect, monitor) {
  return {
    connectDragSource: connect.dragSource(),
    isDragging: monitor.isDragging(),
  };
}

// 当事件状态发生改变触发的函数
const cardMonitors = {

  isDragging(props, monitor) {
    return monitor.getItem().id === props.id;
  },

  beginDrag(props) {
    const item = {
      id: props.id,
      // 还可以自定义其他参数
    };
    return item;
  },

  // 在endDrag中判断是否成功添加元素,但是endDrag感觉有延迟,会等待一段时间才执行
  endDrag(props, monitor) {
    // 取消dragging
  }
};

class DragableWrapper extends Component {
  constructor(props) {
    super(props);
    this.state = { };
  }

  render() {
    const { child, key, onClick } = this.props;

    return (
      <div key={key} className="dragable-wrapper" onClick={onClick}>
        {this.props.connectDragSource(
          child,
        )}
      </div>
    );
  }
}

export default DragSource('box', cardMonitors, collectFunc)(DragableWrapper);

注意事项:

  1. 首先预留一个child作为组件的展示
  2. 引入react-dnd 中的DragSource,接受三个参数,分别是 1.types 标识了该组件的项目 2. monitors 监视器用于拖拽过程中时间的监听 3. connectors 将拖拽过程中状态的变化反馈给组件
  3. monitors中的beginDrag的返回值用于标识拖拽过程中需要携带的数据。

所以,我们左边的组件在渲染时,大概是这个样子。

import { DragableWrapper } from './components'

// ...
renderComponent = (componentList)  => {
  return componentList.map((item) => {
      const uid = uuid();
      return (
        // 传下去的props 需要在 beginDrag 的时候return 才能在事件中拿到相应的数据
        <DragableWrapper
          key={uid}
          child={
            <div>
              <span>{item.componentName}</span>
            </div>
          }
        />
      );
    })
}

render() {
	return <div>{this.renderComponents(componentList)}</div>
}

可以看到,组件左边的组件目前已经是可拖拽的了。

3.2 预览区域可放置内容

dragSource原理差不多,预览区域是可以放置的区域,所以这里的预览区域需要将组件用dropSource包裹一下。进一步考虑:之后预览区域的内容也是可拖拽的,所以需要将这个高阶组件用dropSourcedragSource同时包裹一下。

import React, { Component } from 'react';
import { DragSource, DropTarget } from 'react-dnd';
import './index.less';

const dragCardSource = {
  isDragging(props, monitor) {
    return monitor.getItem().id === props.id;
  },

  // 在这个函数中构造被拖拽函数的数据结构
  // beginDrag(props, monitor, component) {
  beginDrag(props) {
    // Return the data describing the dragged item
    const item = {
      id: props.id,
    };
    return item;
  },
};

const dropCardSource = {
  hover(props, monitor, component) {
    if (!component) {
      return null;
    }
    const item = monitor.getItem(); // item标识正在被做拖拽的元素

    // 从容器外拖拽过来的情况 (根据是否生成了id 和 index进行判断)
    if (!item.id && !item.index) {
      addedComponent.splice(props.index, 0, item); //  在hover元素之前放置drag元素
    }
  },
};

class DragableAndDropableWrapper extends Component {
  constructor(props) {
    super(props);
    // this.myRef = React.createRef();
    this.state = {};
  }

  // static getNode = this.getNode;

  // getNode = () => this.myRef.current;

  render() {
    const {
      forwardedRef,
      child,
      key,
      connectDropTarget,
      connectDragSource,
      className,
      isDragging,
      onClick,
    } = this.props;

    const opacity = isDragging ? 0 : 1;
    return connectDropTarget(
      connectDragSource(
        <div
          ref={forwardedRef}
          key={key}
          className={`drag-drop-wrapper ${className}`}
          style={{ opacity }}
          onClick={onClick}
        >
          {child}
        </div>
      )
    );
  }
}

const RefHOC = React.forwardRef((props, ref) => (
  <DragableAndDropableWrapper {...props} forwardedRef={ref} />
));

export default DropTarget('box', dropCardSource, (connect) => ({
  connectDropTarget: connect.dropTarget(),
}))(
  DragSource('box', dragCardSource, (connect, monitor) => ({
    connectDragSource: connect.dragSource(),
    isDragging: monitor.isDragging(),
  }))(RefHOC)
);

关键步骤

  1. dropSource的监听器(dragCardSouce函数)中监听hover事件,当左边拖拽的元素hover到可放置区域,就将组件添加到预览区域中。(当然也有可能存在hover了但是没有在放置区域放下的情况)

  2. 在被拖拽的组件监视endDrag行为,如果没有拖拽放置成功,则从预览区域中删除本次添加的元素(可以通过redux实现)

    // 当事件状态发生改变触发的函数
    const cardMonitors = {
    
      isDragging(props, monitor) {
        return monitor.getItem().id === props.id;
      },
    
      beginDrag(props) {
        const item = {
          id: props.id,
          // 还可以自定义其他参数
        };
        return item;
      },
    
      // 在endDrag中判断是否成功添加元素,但是endDrag感觉有延迟,会等待一段时间才执行
      endDrag(props, monitor) {
        if (!monitor.didDrop()) {
          // drop 不成功 将拖拽添加的元素退回
          const { addedComponent, draggingId } = props;
          props.setValues({
            addedComponent: addedComponent.filter((item) => item.id !== draggingId),
            draggingId: "",
          });
        } else {
          // 如果 drag && drop 成功,设置默认的active compnent
          props.setValues({
            activeComInfo: monitor.getItem(),
          });
        }
      },
    };
    

3.3 预览区域可放置内容

接着上面的思路,我们在调整我们的顺序时,同样也是监听hover事件,这个时候我们能拿到两个下标dragIndex, hoverIndexdragIndex表示当前正在拖拽的组件下标,hoverIndex表示当前hover的下标。我们设定当拖拽组件的位移超过hover组件的中间位置时,开始交换位置(是否有更好的交互方式?)。那么我们就需要通过获取getBoundingClientRect这个API来获取当前元素的位置,当然,前提是我们需要先拿到真实的DOM节点。具体代码如下:

hover(props, monitor, component) {
    ...
    const node = component;
      if (!node) {
        return null;
      }
    const hoverBoundingRect = node.getBoundingClientRect();
    const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;

    const clientOffset = monitor.getClientOffset();

    // Get pixels to the top
    const hoverClientY = clientOffset.y - hoverBoundingRect.top;
  
      if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
        return;
      }

      if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
        return;
      }
  	// 在这里交换位置!!
      props.moveComponnet(dragIndex, hoverIndex);

      // 由于交换了位置,需要手动设置一下monitor中的index 
      monitor.getItem().index = hoverIndex;
}

注:component拿到的不一定是真实的DOM,这取决于你用的是函数组件、类组件或是其他高阶组件。

官方示例

3.4 支持直接将组件拖到预览区域的任意位置

到目前为止,我们已经实现了拖放、排序功能。目前拖拽进来的元素仅仅是简单的push到数组的最后一个位置,体验不佳,我们希望的是拖拽进来的元素能插入到预览区域的任意位置。其实相当于将3.2、3.3中的功能组合起来。我们同样还是在hover的时候做处理。

   if (props.materialDragging && ![dragHoverIndex].includes(props.index)) {
         const { draggingId } = props;
         item.id = draggingId;
         // 先将插进去的元素过滤掉, 然后在新的位置插入一个新的元素 (但draggingId不需要重新生成)
         const newAddedComponnet = cloneDeep(
           props.addedComponent.filter((item) => item.id !== draggingId)
         );
         newAddedComponnet.splice(props.index, 0, item); //  在hover元素之前放置drag元素
         props.setRematchValues({
           addedComponent: newAddedComponnet,
           draggingId: item.id,
           materialDragging: true, // 标识当前是否在拖拽
           dragHoverIndex: props.index,
         });
         monitor.getItem().index = props.index; //  由于交换了位置,需要手动设置一下monitor中的index
      }

4. 简易源代码

github地址