造轮子之前
如果需要实现一个列表拖拽排序的功能,目前前端社区有比较成熟的方案,在工作中直接使用即可,但作为一个技术人,有时候还是要探究一下实现过程,造一个自娱自乐的轮子,巩固一下JS基本功,在我看来也是一个不错的学习和积累的过程。今天就从头实现一个这样的轮子,不使用其他框架,在实现之前,我也没看其他框架的实现原理,先自己撸一个,再去研究其他实现吧,先看下最终效果,点击查看完整示例:
问题分析
1.拖拽
说到拖拽,有一个drag事件可以轻松胜任,除此之外,mouse相关事件,我这次就使用mouse事件来实现拖拽的效果,首先需要了解一下和mouse相关的事件:
1.1 mousedown
这个事件就是鼠标第一次点击在目标节点时触发的事件,这个比较好理解,拖拽事件也是始于mousedown;
1.2 mousemove
mousemove 事件在定点设备(通常指鼠标)的光标在元素内移动时,会在该元素上触发。简单点说就是只要鼠标在目标节点或子节点上移动,就会一直触发,这里想说一下mouseover事件,有点类似,但又很不一样,当鼠标在不同元素之间移动才会触发;
1.3 mouseup
mousedown事件在定点设备(如鼠标或触摸板)按钮在元素内释放时,在该元素上触发。 mouseup 事件与 mousedown事件相对应。
1.4 mouseleave
mouseleave事件在定点设备(通常是鼠标)的指针移出某个元素时被触发;
关于mouse相关的事件还是挺多的,结合下面这个图整理了一下事件触发情况,需要说明的是鼠标事件均绑定在parent节点上,图中原点1代表阶段1,阶段1-2表示不包含1和2):
鼠标从左向右移动过程中,可以分为如下几个阶段:
- 阶段1
触发事件:parent mouseover
parent mouseenter
parent mousemove(parent的border-size比较大时也会触发多次,下面child同理)
- 阶段(1-2)
触发事件:parent mousemove多次
- 阶段2
触发事件:parent mouseout
child mouseover
child mousemove
- 阶段(2-3)
触发事件:child mousemove多次
- 阶段3
触发事件:child mousemove
- 阶段(3-4)
触发事件:child mouseout
parent mouseover
parent mousemove多次
- 阶段4
触发事件:parent mousemove
- 阶段4以后
触发事件:parent mouseout
parent mouseleave
总结一下:
1.) mouseenter和mouseleave在鼠标开始进入和离开parent的时候才会触发,整个过程只会触发一次;
2.) mousemove在整个过程会一直触发,只是target不同而已;
3.) mouseover和mouseout在target变化时才会触发;
2.重叠区域检查
在拖拽节点B的同时,会和其他列表项发生重叠(如下图的A),如果重叠的面积超过一定的阈值,那么底部被覆盖的列表子项A就需要移动位置,空出来的区域就是拖拽节点B可能最终存放的位置;
下图中红色区域就是重叠的部分,由于我实现的列表是垂直排列的,那么其实只要计算红色区域的高度占据A高度的比例即可,超过阈值就需要移动A的位置了(后面都是B作为被拖拽的节点,A作为可能被覆盖和需要被动移动位置的设定);
在节点B拖拽过程中,获得鼠标坐标信息,实时更新B的translate属性,然后遍历所有列表项,得出列表子项在页面中的位置,这里使用getBoundingClientRect来获取,关于这个api,MDN上的图片很好的解释了各个参数的含义:
重叠区域的计算就显得很简单了,首先判断是否重叠,然后判断是顶部还是底部重叠,得出重叠的比例,具体代码可参考完整示例;
3.实时排序
经过前一步重叠区域检查后,如果超过了阈值,那么就需要移动A节点的位置了,这里不是真正改变DOM的顺序,而是设置A的translate即可,由于拖拽在垂直方向上有两个移动方向,up和down,up时A需要向下移动,down则相反,这里有个细节就是如果B往上拖拽,而A已经向下移动了(比如说translateY = 60),而B再次往下移动和A有效重叠,那么A的translateY就要重置为0;
具体实现
讲完上面一些知识点和细节之后,我们就可以编码了,这里我主要想说一点,就是重叠区域检查的时候,需要判断拖拽的节点B是和列表中那个index的位置重叠,这里的位置必须是没有拖拽前的原始位置信息,而这里为了获取节点原始位置,需要减去translateY的移动距离,示例使用getNodeVerticalPos函数去实现;
ini
......
function getNodeVerticalPos(node) {
const { y, bottom } = node.getBoundingClientRect();
const transformY = normalizeNumberValue(node, '--y');
return {
y,
bottom,
rawY: y - transformY, // 节点在列表中原始的y
rawBottom: bottom - transformY, // 节点在列表中原始的bottom
};
}
......
/**
* @desc 负责检查有效的重叠区域,即正在拖拽的节点B和哪个item正在重叠
*/
function checkValidOverlap(e) {
const { y, bottom } = getNodeVerticalPos(e.target);
for (let i = 0; i < items.length; i++) {
const { rawY, rawBottom } = getNodeVerticalPos(items[i]);
if (!(bottom < rawY || rawBottom < y)) {
let overlapRatio = 0;
if (bottom < rawBottom) {
overlapRatio = (bottom - rawY) / itemHeight;
} else {
overlapRatio = (rawBottom - y) / itemHeight;
}
if (overlapRatio >= ratio) {
if (dragIndex !== i) {
dragIndex = i;
updateNodesPos(e); // 需要更新items[dragIndex]位置的node位置
}
i = items.length;
}
}
}
}
......
最后
这次实现的乞丐版本中每个item都是等高的,如果不是等高的呢,水平方向拖拽排序呢,多想想情况还蛮多,后面再补充吧。