造个拖拽排序的轮子

引子
最近遇到一个需求,要求在移动端拖拽排序.简单搜了下要么体积太大,功能太多,要么不支持移动端.轮子走起.

需求示例

npm地址
git地址

建立项目

首先按照轮子开发模板创建项目.
这个包我们将发布到npm上,这样项目直接add就可以,方便管理.

梳理需求细节

需求:

  1. 首先需要实现拖拽功能.
  2. 实现一个算法,用于判定排序.
  3. 最好同时兼容PC/移动端.

期望的使用方式:

  1. 类的使用方式new Sorter(option)
  2. 事件回调风格sorter.on(event, callback)
  3. 没有侵入性,不需要修改太多源码.

开发

创建基类

创建一个基类,用于实现一个简单的事件回调

1
2
3
4
5
6
7
8
9
10
11
class EmitAble {
task = {}

on(event, callback) {
this.task[event] = callback
}

fire(event, payload) {
this.task[event] && this.task[event](payload)
}
}

简单需求,简单实现.有更复杂的需求可以百度更完美的实现.
通过让真正的功能类继承这个基类就可以在内部通过this.fire(event, payload)向实例派发事件了.

算法实现

实际上浏览器已经为我们实现了一个自然排布+换行的排序规则.我们只需要考虑将一个数组(list)中的一个元素(source)插入目标元素(target)的实现就行.

1
2
3
4
5
6
// 取出被拖拽元素
let temp = list.splice(source, 1);
// 截取开头到被交换位置的元素
let start = list.splice(0, target);
// 组装成结果数组
this.list = [...start, ...temp, ...list];

落到浏览器的实现上,就是移动一个元素(source),在移动过程中更新target.
为了让这个过程可以被看到,我们可以在容器中添加一个占位元素,它的大小与被移动元素一致,而被移动元素则将其定位,使其脱离文档流,占位元素则根据以上算法及更新后的target反复取出插入到新的位置.

要点

  1. 如何更新target
    我们知道,前端开发就是摆盒子,既然是盒子,就有四边.而我们在移动一个盒子时,可以把这个移动盒子视作一个点,可以取鼠标的位置,也可以取盒子的中心点.
    那么我们只需要对所有盒子进行检查,移动点命中的盒子就是target
    现在这个问题抽象为一个点是否在一个盒子内.

    1
    2
    3
    4
    5
    6
    7
    isHit(point, rect) {
    let {x, y} = point
    let {left, top, right, bottom} = rect
    return !(x < left || x > right || y < top || y > bottom)
    }

    let hitIndex = this.rectList.findIndex(rect => helper.isHit(point, rect))
  2. dom操作性能如何?
    我们可以实现惰性操作.只在必要时进行.也就是只在target变化时取出,插入dom

  3. 这么多盒子,如何整理它们的数据.
    以容器即父元素为参照.所有盒子的位置均按其距离父元素的top/left计算.
    提供一个方法用于获取所有子元素的位置信息.计算方式如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
      getPosOfParent(el) {
    let parent = el.parentNode
    let pR = parent.getBoundingClientRect()
    let cR = el.getBoundingClientRect()
    return new Rect({
    width: cR.width,
    height: cR.height,
    top: cR.top - pR.top,
    left: cR.left - pR.left,
    index: el.dataset.hasOwnProperty('index') ? +el.dataset.index : -1,
    })
    }

    // Rect类
    // 这样拖拽元素我们只需要更新它的top/left就可以了.
    class Rect {
    constructor(opt) {
    Object.assign(this, opt)
    }

    get centerX() {
    return this.left + this.width / 2
    }

    get centerY() {
    return this.top + this.height / 2
    }

    get bottom() {
    return this.top + this.height
    }

    get right() {
    return this.left + this.width
    }
    }

主体框架

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Sorter extends EmitAble{
constructor(el, opt){
super()
this.$el = el
this.$options = {...initialOption, ...opt}
this.$init()
}

$init() {
this.freshThreshold()
this.listen()
}

freshThreshold() {
this.children = [...this.$el.children]
this.children.forEach((child, index) => {
child.classList.add('drag-item')
child.dataset.index = index
})
this.rectList = this.children.map(child => helper.getPosOfParent(child))
}

listen() {
this.$el.addEventListener(events.down, this.down)
this.$el.addEventListener(events.move, this.move)
document.addEventListener(events.up, this.up)
}

unbindListener(){}

down(){}
move(){}
up(){}
}

实现拖拽

我们的拖拽不需要考虑二次拖拽,比较简单.用transform的话,只需在move回调中,获取与down时的鼠标间距,设置给拖拽元素即可.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
down: e => {
// 点击了可拖拽元素的子元素时,上溯到可拖拽元素
let target = this.drag = getParentByClassName(e.target, 'drag-item')
let {clientX, clientY} = e.touches ? e.touches[0] : e
let move = this.moveRect = helper.getPosOfParent(this.drag)
this.point = {
startX: clientX,
startY: clientY,
}
this.insetHolder(move.index)
}
move: e => {
e.preventDefault()
let {clientX, clientY} = e.touches ? e.touches[0] : e
let {startX, startY} = this.point
let deltaY = clientY - startY
let deltaX = clientX - startX
css(this.drag, {
transform: `translate3d(${deltaX}px,${deltaY}px,0)`,
})
}

但按上面提出的要点,我们需要在down回调,使拖拽元素脱离文档流,如设置绝对定位的话,则需将原本的位置加回来,或者设置定位时直接设置left,top.
同时,我们需要将一个占位元素,插入拖拽元素的位置.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
insetHolder(index) {
let div = this.getHolder()
this.$el.insertBefore(div, this.$el.children[index])
}
getHolder() {
// 再次进入,则需要先从容器中取出.
if (this.$holderEl) return this.$el.removeChild(this.$holderEl)
let el = this.$holderEl = document.createElement('div')
let {width, height} = this.moveRect
el.className = 'sorter-holder'
el.style.width = width + 'px'
el.style.height = height + 'px'
el.style.background = '#f7f7f7'

return el
}

在move回调中,我们还需要反映拖拽产生的效应.也就是在合适的时机,调用insertHolder.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
effectSibling() {
let move = this.moveRect
let point = {
x: move.centerX,
y: move.centerY,
}
// 找到移动块中心点进入了哪个块
let hitIndex = this.rectList.findIndex(rect => helper.isHit(point, rect))
if (hitIndex === -1) return
// 惰性操作.hitIndex没有变化,什么都不做
if (this.hidIndex === hitIndex) return
this.hidIndex = hitIndex
// 回到原位
if (hitIndex === move.index) {
this.insetHolder(move.index)
}
// 往左上移动
else if (hitIndex < move.index) {
this.insetHolder(hitIndex)
}
// 往右下移动
else {
this.insetHolder(hitIndex + 1)
}
}

完成拖拽

鼠标抬起时,拖拽结束,将一切复原.我们得到了一个dragIndex及一个hitIndex.
有了这两个值,即可按照最初的算法,真正的对元素进行排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
changeItem({source, target}) {
if (source === target) return;
const parent = this.$el;
let list = [...parent.children];

let temp = list.splice(source, 1);
let start = list.splice(0, target);
list = [...start, ...temp, ...list];

// 用fragment优化dom操作.
const frag = document.createDocumentFragment();
list.forEach(el => frag.appendChild(el));
parent.innerHTML = '';
parent.appendChild(frag);

// 刷新dragger实例
this.freshThreshold();
}

如果是使用MVVM框架开发,开发者往往希望得到数据结果之后,操作数据再做更改.因此可以使用fire对外派发结果.
同时提供一个配置参数用于控制是否执行内置排序功能.

1
2
this.fire('drag-over', pos)
this.$options.change && this.changeItem(pos)

这样实例就可以通过监听drag-over事件绑定回调.执行一些业务逻辑.

1
2
3
4
5
let sorter = new Sort(el, {change: false})
sorter.on('drag-over', pos => {
console.log(pos.source, pos.target)
// do something
})

主体完整代码

Git地址