Drag&Drop 拖放API简介以及在React中的实践

最近有个需求,须要产品导航栏支持拖放。
虽然开源社区已有很多成熟的拖放库,但考虑到代码可控性和可定制性,仍是本身写吧。css

选型

关于选型,前端实现拖放功能,无外乎几种:
一、经过样式布局+鼠标事件,采用此方案的插件如:@shopify/draggable
二、Canvas绘制,插件如:konva
三、Drag&Drop接口,插件如:dragulahtml

通过一番研究,最终选择了原生Drag&Drop的方案,缘由以下:
一、原生拖放事件,顺应JS语言发展趋势;
二、兼容性符合项目要求;
三、在Can I use...中有以下描述:
前端

插图-1
最少的代码,最方便的方法,就是它了。

事件

一个拖放行为,天然牵涉到两部分元素,即拖动元素和释放区域元素。
与之相关的事件总共有8个,其中绑定在拖动元素的事件有三个:dragdragstartdragend
剩下5个事件绑定在释放区域元素上:dragenterdragoverdragleavedragexitdrop
具体定义能够参考mdnreact

定义可拖动元素

浏览器中,有三种元素,默认是能够被拖动的,它们是:
一、被选中后的文本;
二、图片;
三、连接
其余元素要转成可拖动元素,必须添加draggable="true",如:git

<div draggable="true"></div>
复制代码

注意:这里不能略写,如写成:github

<div draggable></div>
复制代码

是无效的。浏览器

定义可被释放区域

要使一块元素可被释放,首先须要绑定dragenterdragover事件,而后阻止事件,以下:app

<div ondragover="return false">
<div ondragover="event.preventDefault()"> 复制代码

由于,这两个事件的默认行为就是“不触发”drop事件,因此要定义成可被释放区域,就反其道而行之便可。框架

DataTransfer对象

一个完整的拖放操做,除了拖动一个元素,在指定区域释放以外,还有最重要的一步,就是将元素携带的信息在被释放区域中展现。
好比,拖放一张图片,本质上就是获取到被拖动的图片src属性值,并在释放时,在释放区域展现一张相同src的图片。
而这个信息,就存储在DataTransfer对象中。
对于非默承认拖放元素来讲,其包含的信息须要在dragstart事件中设置,使用DataTransfer.setData(),如:dom

dragItem.ondragstart = e => {
  e.dataTransfer.setData('text/plain', 'drag info');
}
复制代码

若是但愿拖动时,展现自定义的图片,还能够调用dataTransfer.setDragImage,如:

dragItem1.ondragstart = e => {
  const img = new Image(); 
  img.src = 'img_url.jpg'; 
  e.dataTransfer.setDragImage(img, 0, 0);
}
复制代码

drop事件中,能够取得拖放元素的信息,并将指定信息经过dom操做,展现在特定区域,如:

dropArea.ondrop = e => {
  e.preventDefault();
  const data = event.dataTransfer.getData("text/plain");
  const div = document.createElement("div");
  div.textContent = data;
  e.target.appendChild(div);
}
复制代码

在DataTransfer对象还有一对属性,用来确保释放区域只能释放特定类型的拖拽元素,即dropEffecteffectAllowed
effectAllowed只能在dragstart事件中设置,在dragenterdragover事件中,须要设置dropEffect的值与effectAllowed一致,才能触发drop事件。如:

dragItem.ondragstart = e => {
  e.dataTransfer.effectAllowed = "move";
}

dropArea.ondragover = e => {
  e.preventDefault();
  e.dataTransfer.dropEffect = "move";
}
复制代码

其余属性及方法,详细能够查看mdn

跨终端能力

跨终端能力是drag&drop最大的特色。
最多见的跨终端需求,就是从用户的本地拖放文件到浏览器中指定区域实现上传功能。
在指定区域的drop事件中,经过DataTransfer对象的files属性,便可得到文件列表信息,如:

dropArea.ondrop = e => {
  e.preventDefault();
  const files = e.dataTransfer.files;
  if (files.length) {
    Array.prototype.forEach.call(files, f => {
      console.log(f.name); //打印文件名
    });
  }
}
复制代码

在React中实践

在React项目中使用drag&drop,依然遵循React数据驱动的原则,即事件->数据->DOM更新
因此,像以前提到的,经过DataTransfer对象传递数据的方式,在React项目中,能够改成操做组件对象属性,保证数据流的清晰。
但除此以外,在实际实践中,仍是遇到了一些问题,须要特殊处理。具体以下:

一、必须保留dataTransfer.setData

起初,为保证数据流清晰,在React组件中,绑定onDragStart,仅负责监听事件,数据的变更和传递所有修改组件属性,可是会遇到Firefox浏览器没法拖放的兼容问题。经查发现,在Firefox中,可拖放元素必须知足:
一、添加draggable="true"
二、绑定事件dragstart
三、在dragstart中,dataTransfer.setData设置数据
因此,即便e.dataTransfer.setData('text', '');设置空字符串,也必须添加上这一条。

二、防止跨终端拖拽或不合法拖拽

drop&drag跨终端能力有时也会成为干扰。在项目中,会发现,若是没有作判断,同一个页面同时打开两个浏览器tab,其拖放元素能够跨tab拖动,可能会形成意外BUG。为此,须要增长判断。
一种方式,在组件实例构建时,生成一个随机字符,借助dataTransfer.setData,为拖放元素打上标记。同时,在drop事件中执行判断。
固然,若是拖放元素和释放区域分属不一样组件,则须要在他们的父组件中,生成随机字符,以props形式,传递到两个子组件。

三、防止Firefox自动打开新页面

在上述提到的为拖放元素打标签中,起初采用的是这样的写法:

e.dataTransfer.setData('text', uniqDataTransferTag);
复制代码

结果在Firefox中,每次drop事件触发时,浏览器会自动打开新tab并搜索uniqDataTransferTag(随机字符)
根据官方解释,须要在drop事件中调用e.preventDefault(),同时阻止冒泡e.stopPropagation(),但通过尝试,依然不生效。初步判断,可能与React的SyntheticEvent机制有关。因而只好曲线救国,改成设置自定义的MIME type,如:

e.dataTransfer.setData('ucloud_drag_tag', uniqDataTransferTag);
复制代码

四、节流与避免event被回收

在项目中,须要在onDragOver中,判断被拖放元素当前位置,并执行DOM操做。
根据定义,dragover事件会在被拖放元素拖到释放区域上时,每几百毫秒触发一次,显然不作任何处理会很是影响性能。这里,天然想到采用节流throttle方式优化。
因为节流是异步操做,而根据React的SyntheticEvent,event对象会在当前事件循环结束后移除,除非调用e.persist(),才能在异步操做中访问到。

五、HACK拖放元素拖动过程当中,实现“被拖走”的视觉效果

根据设计师要求,项目中但愿实现元素拖动开始后要被拖走,以下图:

插图-2
但默认的拖放效果,实际上是这样:
插图-3
很惋惜,官方并无提供对被拖放元素拖动开始后设置效果的接口。通过尝试,找到一个经过样式HACK方法,以下:
一、新增一个 css class,包含样式:

transform: translateX(-9999px);
复制代码

二、对被拖放元素添加样式:

transition: transform 0.1s;
复制代码

3。在拖动开始后,添加上述第一步的css class

六、实现长按元素激活拖放效果

根据交互设计,须要实现长按元素必定时长后才能够触发拖拽。
起初,采用的方案是,绑定鼠标事件mousedown,触发setTimeout,达到固定时长后触发state更新,改变拖放元素的draggable值。但实际测试中发现,这种方法存在必定的失败率,即明明已经达到了长按的时长,依然不能拖放。并且,在Firefox中这个问题更加明显。
推测,多是draggable的更新偶尔会晚于dragstart事件,致使拖放失败。
因而转变思路,增设组件的属性做为判断标志,在mousedown事件中更新判断标志,而draggable始终设为true。以下:

// mousedown事件处理函数
handleLongPress = e => {
    this.resetDragTimer(); // 清除定时器

    return (this.triggerDragTimer = setTimeout(() => {
      this.isMenuDraggable = true; // 判断标志
    }, this.triggerDragInterval));
};

// dragstart事件处理函数
handleDragStart = e => {
    if (!this.isMenuDraggable) {
        e.preventDefault();
    } else {
      ...
    }
};
复制代码

总结

Drag&Drop做为原生拖放API,能够用最少代码实现拖放,看似“简单”,实际并不是如此。在实践中,仍是须要对官方接口定义,以及各浏览器差别有足够了解,才能避免各类未知错误。而在React这类数据驱动的框架中运用时,如何处理事件监听,同时又不打乱组件的数据流,仍是须要好好设计一番。

相关文章
相关标签/搜索