用HTML5的拖拽API实现简单的拖拽排序(结合react)

使用HTML5提供的drag and drop API实现节点拖拽排序


1. 项目需求

项目的需求其实看起来比较简单,通过select组件选择不同的数据项后,可以有一个拖拽组件来调整这个数据项在数组中的顺序,同时更新存储数据项的数组,做到实时刷新数组。

但是具体实现起来的时候发现了一些问题:

  1. 我们项目使用的是react框架,整个项目没有使用过jquery,所以不能因为这个拖拽排序的功能而使用jq插件,增加打包体积

  2. 拖拽排序,先拖拽,后排序,所以其实是两个功能。同时,不仅要支持实时刷新数组,还要支持部分数据项允许拖拽,部分数据项不允许拖拽

  3. 比较好的一点是这个拖拽是用在公司内部的系统里,所以也不用考虑兼容性的问题,因此我最开始也考虑使用一些插件,但是坑比较多,下面会做一些介绍。

2. 尝试过的插件以及react的渲染机制

1.React DND

拖拽功能很完善,API相对还是很强大的。使用的是HTML5的drag and drop API.
文档写的也还是很不错的,顶层API里对于拖拽的整个交互过程比较清晰,是我觉得逻辑很规范的一套实现标准。定义了dragSource(拖拽源), dropTarget(拖放目标), dragLayer(拖放层)等一系列概念。
但是因为体积太大了,只能放弃。

2.react-sortable-pane

拖拽偏向于位置移动和形状的变化,在排序上做的也是我觉得相对最好的一个,可以直接设置isSortable和isResizable选择是否禁用排序和缩放。不过对于div的样式定义需要直接写到jsx语法里,感觉很不爽。
对于事件的扩展支持的比较好,规定好了水平或者垂直方向上的排序后,每次拖拽都会返回一个oldArray和一个newArray,分别对应排序前和排序后的数据数组(正是我想要的!)
但是正如前面说的,这个插件强调样式,如果不定义宽高的话,会有意想不到的坑和bug,对于需要不定高的排序组件来说,这个插件的可用性瞬间下降了许多。

3.dragula

这其实也是一款很不错的拖拽插件,使用鼠标移动事件而不是draggable的html5特性来模拟拖拽,兼容性尚可,不过实际使用起来效果感觉一般。比如拖拽对象时,需要实时生成一个镜像元素跟随鼠标移动,但是dragula自身提供的样式与自定义样式会有冲突,同时这个镜像元素进行绝对定位,必须设置z-index为很高的一个值才行(而我所要编写的拖拽组件组要用于弹框组件中,z-index的排序真是头大)。

另外,在使用dragular的时候还让我对react的渲染机制有了比较深入的了解。
我们都知道react会事先生成一个虚拟DOM,然后插入到真实节点中,如果state发生改变后,会比较需要更新的DOM节点,实现最小化的更新。
然后问题来了。我们可以发现每一个DOM节点都有一个data-reactid,(其实是我的猜测)目前的react根据这个来与state中的数据进行比对,但是利用dragula进行拖拽排序,其实是会改变真实DOM的顺序的,因此data-reactid的顺序也就改变了,重新渲染的时候,react会按照data-reactid的顺序渲染,自然而然数据渲染的顺序就乱序了。

也就是说,我们的state里有一个数组[1, 2, 3, 4],通过遍历渲染这个state,产生一个DOM列表,列表中有四个<li>,他们对应的data-reactid是[r1, r2, r3, r4].
现在我们拖动了实际DOM的顺序,改变数组的顺序为[1, 2, 4, 3],此时data-reactid变成了[r1, r2, r4, r3].
按照预想,重新渲染的时候,四个<li>的文本内容顺序应当与state中的数组顺序一致,但是实际情况是,react会根据data-reactid的顺序去渲染,结果就是这样:

1
2
3
4
r1 -> 1
r2 -> 2
r4 -> 3
r3 -> 4

(这个渲染机制我不能肯定百分之百是这样,之后查阅了react的文档会再来更新)我有想过利用dataset来新建一个数组维护发生改变的真实DOM顺序,但是实践起来问题也蛮大。

react在15.0之后取消了data-reactid,但是其内部的渲染机制依然按照上面这样一种逻辑,具体的情况可以参考我的另一篇日志React中渲染节点的一点小思考

4.其他拖拽组件

其实还尝试好几款其他的插件,要不是排序支持的不好,要不就是体积太大。

3. HTML5原生实现

综上所述,各种插件各种坑,最后还是手写吧。

参考了这一篇A Sortable List Component in React utilizing
the HTML5 Drag & Drop API
,其实感觉原生拖拽API也还是比较好使的,但是主要还是要搞清楚HTML5中提供的那几个drag和drop的API具体都有什么用。

关于这几个API如何使用可以查看MDN,这里简单介绍一下

  1. 整个拖拽事件触发的顺序如下:
    dragstart -> drag -> dragenter -> dragover -> dragleave -> drop -> dragend

  2. 事件详情
    dragstart:事件主体是被拖放元素,在开始拖放被拖放元素时触发。
    darg:事件主体是被拖放元素,在正在拖放被拖放元素时触发。
    dragenter:事件主体是目标元素,在被拖放元素进入某元素时触发。
    dragover:事件主体是目标元素,在被拖放在某元素内移动时触发。
    dragleave:事件主体是目标元素,在被拖放元素移出目标元素是触发。
    drop:事件主体是目标元素,在目标元素完全接受被拖放元素时触发。
    dragend:事件主体是被拖放元素,在整个拖放操作结束时触发。

整体思路

  1. 使用HTML5的拖拽API(因为不需要兼容低版本浏览器)进行拖拽;

  2. 在排序这一点上,因为之前提到的react在渲染时会根据已有的节点顺序渲染,如果拖动并改变了了真实节点的顺序,同时也更新了状态,那么就会出现乱序的问题。

(这个问题也可以通过新建一个sorted数据对象来解决,这个对象保存着改变顺序后的新的数据,渲染时使用旧的数据。但是感觉很不优雅,毕竟dom操作应该跟state操作同步才对)

所以这里的解决方式是
1)拖拽事件发生时,隐藏拖拽源,并实时创建一个placeholder节点
2)根据拖拽的位置将实时placeholder插入到对应节点前后
3)拖拽结束时,获取插入位置,改变state中存储数据的顺序,显示拖拽源,隐藏placeholder

因为HTML5中拖拽改变节点,其实是利用dataTransfer传递节点数据,只要不传递数据,利用placeholder模拟排序,就避免了dom顺序被拖动带来的二次渲染乱序问题。

如何获取插入位置

拖拽排序,肯定还是绕不开排序这个问题,那么应该怎么获取新插入节点的顺序?呢

一开始有想过使用dataset,将每个节点的位置信息存储在节点上,通过element.dataset.order获取节点的顺序,进行排序操作,但是因为真实节点的位置改变后,重新渲染dataset的值时,会出现前述乱序问题;而之后使用隐藏拖拽源这样的方式进行排序,可以很好的解决dataset重写的问题,但是为了让节点看起来更加干净,最后还是放弃了这个思路。

那么,直接获取真实节点上的数据不行,我们还可以获取react dom上的数据呀!只要给每个节点传入一个prop记录其order就可以了,拖拽时间发生时通过回调函数传入当前props上的order给父组件,父组件通过这个order就可以查找到真实节点,同时进行数据的重拍。
实现起来可能略微有点绕,之后会有实例。

具体实现

参考文章

  1. HTML 5的革新—— drag && drop(拖动)
  2. 原生Javascript+HTML5一步步实现拖拽排序
  3. A Sortable List Component in React utilizing the HTML5 Drag & Drop API