本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金
欢迎各位加入虾皮,15天带薪年假以及业内顶级的薪酬,每个人都可以参与做技术项目,不需要永远做一颗螺丝钉,你是否心动了呢?快联系我内推(wx: R704369725),加入亚洲市值第三的公司吧!
背景
由于业务上有一些UI拖拽排序的需求,然后经过了解这方面的类库有SortableJs、react-dnd、react-sortable-hoc
主要实现的效果为下面这个录屏,代码仓库
出于好奇心以及对知识的渴望,我去看了看react-sortable-hoc这个库的具体实现,这个拖拽效果到底是如何实现的呢?
一些前端基础知识
其实拖拽,就是对html元素的位置不断改变,看起来就像是拖动一样
因此先来简单介绍一下关于html中元素定位信息的一些知识
HTML Element Property
- offsetParent 返回一个指向最近的(指包含层级上的最近)包含该元素的定位元素或者最近的
table,td,th,body元素 - offsetWidth 用于获取元素的宽度
- offsetHeight 用于获取元素的高度
- offsetLeft 用于获取元素左上角相当于HTMLElement.offsetParent的左边距
- offsetTop 用于获取元素左上角相当于HTMLElement.offsetParent的上边距
HTML Mouse Event
- PageX 返回的相对于整个文档的x(水平)坐标以像素为单位的只读属性
- PageY 返回的相对于整个文档的y(纵向)坐标以像素为单位的只读属性
getBoundingClientRect
Element.getBoundingClientRect() 方法返回元素的大小及其相对于视口的位置
关于demo的说明
拖拽其实只是一种视觉效果。根本原理是监听鼠标拖动事件,并把鼠标的移动距离加到拖动的元素上,于是便形成了拖拽的效果。
demo是阉割版,只保留了最基本的功能,从功能上,可以分为两个组件
SortableElement
- 用于包裹需要拖动的每个element,用于添加css属性,形成视觉效果。每个拖动的元素,就是一个SortableElement
SortableContainer
- 用于统一监听omMouseMove、onMouseDown、onMouseUp,然后由Container统一计算拖动对各个Element造成的影响后,下发每个Element的样式。
接下来就看看拖动效果到底是如何实现的,主要可以分为四步
- 元素挂载时(onComponentDidMount)
- 拖动前
- 拖动时
- 拖动后
SortableContainer挂载
获取ref,并传给各Elements
SortableElement挂载
- 把自己的ref传给Container
- 缓存自己在数组里的下标
- 计算自己相对于Container的left和top偏移量,并缓存起来 怎么计算?这里就要用到上面的基础知识了,大概的做法就是从Element节点向上递归,取每一层级的offsetTop和offsetLeft并累加,直到加到Container为止。主要函数如下
export function getEdgeOffset(node, parent, offset = { left: 0, top: 0 }) {
if (!node) {
return undefined;
}
const nodeOffset = {
left: offset.left + node.offsetLeft,
top: offset.top + node.offsetTop,
};
if (node.parentNode === parent) {
return nodeOffset;
}
// 递归拿left 和 top
return getEdgeOffset(node.parentNode, parent, nodeOffset);
}
拖动前
- 把拖动的节点visibility设为hidden (请注意,并不是直接给目标元素添加偏移量)
- 使用cloneNode拷贝一个拖动节点对象,并用fix布局展示出来,这就是跟着鼠标移动的那个节点
- 记录拖动节点的index(这个就是onSortEnd中的oldIndex)
- 记录事件发生的坐标(event.pageX, event.pageY) 用于计算后续移动的位置 经过以上几步,相当于以Container的左上角为原点,建立了一个坐标系
拖动时
- 从事件中获取当前的pageX和pageY,并减去上面第四步中事件发生的坐标,得到鼠标目前为止的总偏移量translate
- 对于上面第二步中拷贝出来的节点,加上这段偏移量,看起来就像是节点跟随鼠标一起移动了
- 遍历全部的节点,全部的节点分为三种:拖拽节点前的节点(就是gif中的aaa、bbb、ccc、ddd、eee)、拖拽节点(gif中的fff)、拖拽节点后的节点(gif中的ggg以及后面的节点)
拖拽节点前的节点
对于拖拽节点前的节点,有如下判断
// sortingOffset是拖拽节点现在距离Container的left和top
if (
now.index < draggedIndex && 这一行说明,这是前面的节点
((sortingOffset.left <= node.absoluteOffset.left &&
sortingOffset.top <= node.absoluteOffset.top) || 这说明拖拽节点被拖拽到前面去了,所以当前节点需要挪动
sortingOffset.top <= node.absoluteOffset.top) 这说明,拖拽节点被拖到上面去了,所以当前节点需要挪动
) {
translate.x = draggedNode.offsetWidth; // 当前节点需要挪动的距离
// 下面这个判断是,如果往后挪动后,是否会超出距离,如果会,则要换行
// 换行的话需要根据下一个节点进行运算
// 打个比方,fff移动到了ddd的前面,那么ddd需要移动
// ddd的位置是left: 100px, top: 100px,但是eee(ddd的下一个节点)的位置是left: 0px, top: 100
// 那么,如果ddd往后挪,会超出位置,因此需要换行,那么换行需要移动多少的距离?
// left: 0 - 300 = -300, top: 100 - 0 = 100,所以ddd的translate就是 left: -300, top: 100
// 因为translate是基于当前位置生效的,所以看起来ddd就完成了换行,移动到了原本eee的位置
// 然后eee也会进行计算,发现fff挪动到了更前面的位置,于是eee也往后挪100的位置,就到了原本fff的位置上了
if (
node.absoluteOffset.left + translate.x >
containerRef.current.getBoundingClientRect().width -
offset.width * 2
) {
if (nextNode) {
translate.x =
getEdgeOffset(nextNode, containerRef.current).left -
node.absoluteOffset.left;
translate.y =
getEdgeOffset(nextNode, containerRef.current).top -
node.absoluteOffset.top;
}
}
setNewIndex(i);
}
拖拽节点后的节点
具体原理和上面差不多,就不赘述了,请聪明的读者举一反三
拖动结束
根据拖动时得出的newIndex,以及拖动开始时记录下的oldIndex,回调用户传入的onSortEnd,告知用户拖动的结果,等到用户根据拖动的结果调整数组后,会触发react新一轮的渲染,待新一轮的渲染后,便会按拖动后的顺序呈现。
上图中用户的操作大概可以这样理解
- 元素挂载,此时数组是['aaa', 'bbb', 'ccc', 'ddd']
- onMouseDown 此时知道正在拖拽的元素下标为3,内容为ddd
- onMouseMove 此时可以计算出拖动到了位置2
- onMouseMove 此时可以计算出拖动到了位置1
- 鼠标松开,回调onSortEnd(1, 3)
- 用户根据回调调整数组为['aaa', 'ddd', 'ccc', 'bbb']
- 按照变化后的数组重新渲染
React.Context
这里还有一个小小的知识点。SortableContainer以及SortableElement是通过Context来实现父子组件通信的,具体可以参考context。
react官方不推荐使用context,因为context会让数据流变得复杂,但是在设计组件的时候,context能有效屏蔽组件内部的逻辑,实现用户无感的父子组件通信,降低用户调用的门槛。