手写一个性能较好的拖拽排序(上)

lxf2023-03-17 18:57:01

拖拽排序是前端的常见需求,特别是在管理系统或者编辑器里。

比如低代码编辑器就支持把组件拖到页面不同位置来添加组件、调整顺序:

手写一个性能较好的拖拽排序(上)

那么拖拽排序是怎么实现的呢?

首先我们分析下它会用到啥事件:

比较容易想到的是 mousedown、mouseup、mousemove 等事件,这是 pc 端的。

在移动端对应的就是 touchstart、touchmove、touchend 等事件。

mouse、touch 事件都可以统一为 pointer 事件,也就是 pointerdown、pointermove、pointerup 等事件。

我们可以基于 pointer 事件来实现兼容 pc 和移动端的拖拽排序的效果。

手写一个性能较好的拖拽排序(上)

但从 pointer 事件开始处理还是挺麻烦的,而且拖拽是个常见的需求,所以浏览器后来提供了 drag 事件,包括 dragstart、drag、dragend、dragover、drop 等。

所以现在实现拖拽排序可以从 pointer 事件开始处理,也可以直接从 drag 事件开始处理。

这两种方式实现拖拽排序我们都试一下,今天先实现第一种方式,基于 pointer 事件的。

我们来理一下思路:

拖拽和排序分开来看:

拖拽可以给元素设置 fixed 定位,pointermove 的时候根据指针位置改变元素的 x、y,这样就能实现元素跟随指针移动的效果。

但这样性能不好,一般这种位移我们会用 transform 的 translate3d(x, y, 0) 来做,3D 的 transform 会触发硬件加速,也就是使用 GPU 来计算,性能更好。

那排序呢?

排序就是改变 dom 元素的顺序,可以找到要移动到的位置的下一个元素,通过 insertBefore 插入到它之前。

手写一个性能较好的拖拽排序(上)

前端框架渲染的时候也经常用到 insertBefore 来调整 dom 元素位置。

拖拽和排序我们都知道怎么做了,那两者结合起来呢?也就是如何在拖拽的时候判断出排序移动到的位置?

这个也比较容易想到,根据拖动的时候的指针位置在哪个元素内部,来确定拖动到的位置。

用 getBoundingClientRect 的 api 来获取元素的宽高和位置:

手写一个性能较好的拖拽排序(上)

然后判断下指针位置是不是在这里面,就可以知道拖到哪个元素了。

然后通过 insertBefore 插到它前面就行。

手写一个性能较好的拖拽排序(上)

这就是 translate3D 来拖拽 + insertBefore 来排序的效果。

当然,这个拖拽的元素是用 cloneNode 复制的一份新节点,拖拽完之后删掉它。

不过上面这种还是生硬了点,而且性能也不好,因为每次拖动到新元素的位置都是 insertBefore 来操作 dom。

我们期望的效果是这样的:

手写一个性能较好的拖拽排序(上)

有一个过渡的动画。

如果是直接 insertBefore 改变了 dom 顺序,是没有这种过渡效果的。

那怎么办呢?

刚才我们用到过 translate3d,这里是不是也可以通过 getBoundingClientRect 记录下各个元素的位置,然后把位置有变动的元素 translate3d 到对应的位置的呢?

这样就可以设置 transition 效果了。

而且更重要的是,拖拽过程中只做 translate3d 的 transform,不调用 insertBefore 修改 dom 顺序,最后拖拽完成之后再操作 dom。

从 n 次 dom 操作变成了一次,这样拖拽排序的性能会好得多。

小结一下:

拖拽的实现可以通过 cloneNode 复制一个元素,fixed 定位到它原本的位置,然后 pointermove 的时候设置 tanslate3D 的值来改变位置。

移动是通过 insertBefore 改变 dom 顺序,移动到的元素可以通过拖拽时的指针位置和元素位置对比来确定。

但是直接移动 dom 太生硬,性能也不好,所以我们拖拽过程中用 translate3D 改变位置,加上 transition 效果,拖拽完成后再用 insertBefore 改变 dom 顺序。

dom 的位置和宽高用 getBoundingClientRect 获取。

思路理清了,我们来实现一下:

先准备一个容器:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <style>
        .container {
            width: 400px;
            margin: 0 auto;
            background: pink;
            padding: 20px;
        }
   </style>
</head>

<body>
    <div class="container">
    </div>
</body>
</html>

手写一个性能较好的拖拽排序(上)

添加个列表:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <style>
        .container {
            width: 400px;
            margin: 0 auto;
            background: pink;
            padding: 20px;
        }

        .list {
            display: flex;
            flex-wrap: wrap;
            gap: 15px;
        }

        .list-item {
            width: 100px;
            height: 100px;
            border: 1px solid #000;
            background: #fff;
            line-height: 100px;
            text-align: center;
            list-style: none;
            user-select: none;
        }
    </style>

</head>

<body>
    <div class="container">
        <ul class="list">
            <li class="list-item">111</li>
            <li class="list-item">222</li>
            <li class="list-item">333</li>
            <li class="list-item">444</li>
            <li class="list-item">555</li>
            <li class="list-item">666</li>
            <li class="list-item">777</li>
            <li class="list-item">888</li>
            <li class="list-item">999</li>
        </ul>
    </div>
</body>
</html>

手写一个性能较好的拖拽排序(上)

然后开始写拖拽排序的逻辑:

class Draggable {
    containerElement = null;

    constructor(options) {
        this.containerElement = options.element;

        this.init();
    }
    init(){
        this.bindEventListener();
    }
    onPointerDown(e) {
    }
    onPointerMove(e) {
    }
    onPointerUp(e) {
    }
    bindEventListener() {
        this.containerElement.addEventListener('pointerdown', this.onPointerDown.bind(this));
        this.containerElement.addEventListener('pointermove', this.onPointerMove.bind(this));
        this.containerElement.addEventListener('pointerup', this.onPointerUp.bind(this));
   }
}

封装一个拖拽的 class,传入容器元素,绑定 pointerdown、pointermove、pointerup 事件。

子元素触发的事件会冒泡到父元素,所以把事件监听器绑在父元素就行,性能还更好。这种方式叫做事件代理。

然后这样用:

new Draggable({
    element: document.querySelector('.list')
});

接下来开始写拖拽的逻辑:

先处理 pointerDown,在指针按下的时候复制一个新的元素出来。

我们加一个 drag 属性来记录拖动的元素,加一个 clone 属性记录 clone 出来的元素:

class Draggable {
    containerElement = null;
    drag = { element: null };
    clone = { element: null };

    constructor(options) {
        this.containerElement = options.element;

        this.init();
    }
}

pointerDown 的时候通过 event.target 就可以拿到拖动的元素:

class Draggable {
    containerElement = null;
    drag = { element: null};
    clone = { element: null };

    onPointerDown(e) {
        this.drag.element = e.target;
        this.drag.element.classList.add('active');

        this.clone.element = this.drag.element.cloneNode(true);
        document.body.appendChild(this.clone.element);
        
        this.clone.element.className = 'clone-item';
    }
}

给拖动的元素加一个 active 的 class。

然后调用 cloneNode 复制一个新的 dom 节点出来,参数 true 是子节点也复制的意思。

给它添加一个 clone-item 的 class。

添加的 active 的样式是这样:

.active {
    background: skyblue;
}

而 clone-item 的样式是这样:

.clone-item {
    position: fixed;
    left: 0;
    top: 0;
    z-index: 1;
    width: 100px;
    height: 100px;
    border: 1px solid #000;
    background: #fff;
    line-height: 100px;
    text-align: center;
    list-style: none;
    user-select: none;
    pointer-events: none;
    opacity: 0.8;
}

主要是 fixed 定位,加上有一个 opcity 的透明度。

就达到了这样的效果:

手写一个性能较好的拖拽排序(上)

之后要修改它们的位置,我们把全部的元素位置都计算一遍,放到一个数组里:

class Draggable {
    containerElement = null;
    rectList = [];

    constructor(options) {
        this.containerElement = options.element;

        this.init();
    }

    init() {
        this.getRectList();
        this.bindEventListener();
    }

    getRectList() {
        this.rectList.length = 0;
        for (const item of this.containerElement.children) {
            this.rectList.push(item.getBoundingClientRect());
        }
    }
}

初始化的时候遍历元素的 children,通过 getBoundingClientRect 取出每个元素的 x、y、width、height 保存。

然后 onPointerDown 的时候就可以设置 clone 出元素的位置了:

class Draggable {
    containerElement = null;
    rectList = [];
    drag = { element: null, index: 0, firstIndex: 0};
    clone = { element: null };

    onPointerDown(e) {
        this.drag.element = e.target;
        this.drag.element.classList.add('active');

        this.clone.element = this.drag.element.cloneNode(true);
        document.body.appendChild(this.clone.element);
            
        const index = [].indexOf.call(this.containerElement.children, this.drag.element);

        this.drag.index = index;
        this.drag.firstIndex = index;
        
        this.clone.x = this.rectList[index].left;
        this.clone.y = this.rectList[index].top;
        
        this.clone.element.style.transition = 'none';
        this.clone.element.className = 'clone-item';
        this.clone.element.style.transform = 'translate3d(' + this.clone.x + 'px, ' + this.clone.y + 'px, 0)';

    }
}

从 children 中查找当前 drag 的元素的下标,取出它的 x、y,然后通过 translate3d 设置 clone 出的元素的位置。

通过 firstIndex 记录 drag 的元素的开始位置下标,index 记录移动后的位置的下标。

然后再处理位置的变化:

class Draggable {
    isPointerDown = false;
    drag = { element: null, index: 0, firstIndex: 0 };
    clone = { element: null, x: 0, y: 0 };
    diff = { x: 0, y: 0 };
    lastPointerMove = { x: 0, y: 0 };

    onPointerDown(e) {
        this.isPointerDown = true;
        
        this.lastPointerMove.x = e.clientX;
        this.lastPointerMove.y = e.clientY;
    }
    
    onPointerMove(e) {
        if (this.isPointerDown) {
            this.diff.x = e.clientX - this.lastPointerMove.x;
            this.diff.y = e.clientY - this.lastPointerMove.y;

            this.lastPointerMove.x = e.clientX;
            this.lastPointerMove.y = e.clientY;

            this.clone.x += this.diff.x;
            this.clone.y += this.diff.y;

            this.clone.element.style.transform = 'translate3d(' + this.clone.x + 'px, ' + this.clone.y + 'px, 0)';

        }
    }

按下的时候记录一个标记 isPointerDown,之后按下的状态才处理 pointermove 事件。

记录开始和移动的位置 lastPointerMove,还有位置的变化,也就是 diff。

重新设置 clone 的元素的 translate3d 的 x、y,就达到了拖拽的效果。

然后是重头戏了,拖拽的过程中要判断指针是否碰到了啥元素,相关的元素要做位移:

这个是在 pointermove 里处理的:

onPointerMove(e) {
    if (this.isPointerDown) {
        for (let i = 0; i < this.rectList.length; i++) {
            if (i !== this.drag.index &&
                e.clientX > this.rectList[i].left && e.clientX < this.rectList[i].right &&
                e.clientY > this.rectList[i].top && e.clientY < this.rectList[i].bottom) {
                // 碰到了第 i 个元素
                
                this.drag.index = i;
            }
        }        
    }
}

碰撞检测的逻辑也挺简单,就是指针在的位置的 x 是否在 left 和 right 内,y 是否在 top 和 bottom 内。

并且更新了 drag 元素的 index 位新的下标。

碰撞了之后呢?

那就开始移动元素了,就像前面分析的,设置 translate3d 并且加上 transition 效果。

但也不是所有的元素都移动,只是在开始位置的 index 和结束位置的 index 之间的元素需要动。

也就是如果 drag 的元素原来在前面,那就是这个区间内 firstIndex 之前的不动,之后的往前移(firstIndex 是 drag 的元素的初始下标,index 是当前下标):

if (this.drag.index < i) {
    for (let j = this.drag.index; j < i; j++) {
        if ( j < this.drag.firstIndex) {
            this.containerElement.children[j].style.transform = 'translate3d(0px, 0px, 0)';
        } else {
            const x = this.rectList[j].left - this.rectList[j + 1].left;
            const y = this.rectList[j].top - this.rectList[j + 1].top;
            this.containerElement.children[j + 1].style.transform = 'translate3d(' + x + 'px, ' + y + 'px, 0)';
        }
    }
    this.referenceElement = this.containerElement.children[i + 1];
}

记录要 insertBefore 的目标元素,也就是它后面的元素。

如果 drag 的元素原来在后面,那就是这个区间内的 firstIndex 之后的不动,之前的往后移:

if (this.drag.index > i) {
    for (let j = i; j < this.drag.index; j++) {
        if (this.drag.firstIndex <= j) {
            this.containerElement.children[j + 1].style.transform = 'translate3d(0px, 0px, 0)';
        } else {
            const x = this.rectList[j + 1].left - this.rectList[j].left;
            const y = this.rectList[j + 1].top - this.rectList[j].top;
            this.containerElement.children[j].style.transform = 'translate3d(' + x + 'px, ' + y + 'px, 0)';
        }
    }
    this.referenceElement = this.containerElement.children[i];
}

最后移动它自己:

const x = this.rectList[i].left - this.rectList[this.drag.firstIndex].left;
const y = this.rectList[i].top - this.rectList[this.drag.firstIndex].top;
this.drag.element.style.transform = 'translate3d(' + x + 'px, ' + y + 'px, 0)';
this.drag.index = i;

这样就实现了拖拽过程中的元素移动效果:

手写一个性能较好的拖拽排序(上)

现在没有设置 transition,在 onPointerDown 里设置下:

for (const item of this.containerElement.children) {
    item.style.transition = 'transform 500ms';
}

现在就平滑多了:

手写一个性能较好的拖拽排序(上)

拖拽的过程做完了,最后再处理下指针释放的时候的元素移动就好了:

onPointerUp(e) {
    if (this.isPointerDown) {
        this.isPointerDown = false;

        if (this.referenceElement !== null) {
            this.containerElement.insertBefore(this.drag.element, this.referenceElement);
        }
        
        this.drag.element.classList.remove('active');
        this.clone.element.remove();

        for (const item of this.containerElement.children) {
            item.style.transition = 'none';
            item.style.transform = 'translate3d(0px, 0px, 0px)';
        }
    }
}

执行 insertBefore 改变元素位置,并且把所有的元素的位移置 0,删除 clone 出的用于拖拽的元素。

这样放手之后元素就完成了移动:

手写一个性能较好的拖拽排序(上)

至此,一个性能比较好的拖拽排序就完成了。

全部代码如下:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <style>
        .container {
            width: 400px;
            margin: 0 auto;
            background: pink;
            padding: 20px;
        }

        .list {
            display: flex;
            flex-wrap: wrap;
            gap: 15px;
        }

        .list-item {
            width: 100px;
            height: 100px;
            border: 1px solid #000;
            background: #fff;
            line-height: 100px;
            text-align: center;
            list-style: none;
            user-select: none;
        }

        .active {
            background: skyblue;
        }

        .clone-item {
            position: fixed;
            left: 0;
            top: 0;
            z-index: 1;
            width: 100px;
            height: 100px;
            border: 1px solid #000;
            background: #fff;
            line-height: 100px;
            text-align: center;
            list-style: none;
            user-select: none;
            pointer-events: none;
            opacity: 0.8;
        }
    </style>

</head>

<body>
    <div class="container">
        <ul class="list">
            <li class="list-item">111</li>
            <li class="list-item">222</li>
            <li class="list-item">333</li>
            <li class="list-item">444</li>
            <li class="list-item">555</li>
            <li class="list-item">666</li>
            <li class="list-item">777</li>
            <li class="list-item">888</li>
            <li class="list-item">999</li>
        </ul>
    </div>

    <script>
        class Draggable {
            containerElement = null;
            rectList = [];
            isPointerDown = false;
            drag = { element: null, index: 0, firstIndex: 0 };
            clone = { element: null, x: 0, y: 0 };
            diff = { x: 0, y: 0 };
            referenceElement = null;
            lastPointerMove = { x: 0, y: 0 };

            constructor(options) {
                this.containerElement = options.element;

                this.init();
            }
            init() {
                this.getRectList();
                this.bindEventListener();
            }

            getRectList() {
                this.rectList.length = 0;
                for (const item of this.containerElement.children) {
                    this.rectList.push(item.getBoundingClientRect());
                }
            }

            onPointerDown(e) {
                if (e.pointerType === 'mouse' && e.button !== 0) {
                    return;
                }
                if (e.target === this.containerElement) {
                    return;
                }

                this.isPointerDown = true;

                this.containerElement.setPointerCapture(e.pointerId);

                this.lastPointerMove.x = e.clientX;
                this.lastPointerMove.y = e.clientY;

                this.drag.element = e.target;
                this.drag.element.classList.add('active');

                const index = [].indexOf.call(this.containerElement.children, this.drag.element);

                this.drag.index = index;
                this.drag.firstIndex = index;

                this.clone.x = this.rectList[index].left;
                this.clone.y = this.rectList[index].top;

                this.clone.element = this.drag.element.cloneNode(true);
                document.body.appendChild(this.clone.element);

                this.clone.element.style.transition = 'none';
                this.clone.element.className = 'clone-item';
                this.clone.element.style.transform = 'translate3d(' + this.clone.x + 'px, ' + this.clone.y + 'px, 0)';

                for (const item of this.containerElement.children) {
                    item.style.transition = 'transform 500ms';
                }
            }
            onPointerMove(e) {
                if (this.isPointerDown) {
                    this.diff.x = e.clientX - this.lastPointerMove.x;
                    this.diff.y = e.clientY - this.lastPointerMove.y;

                    this.lastPointerMove.x = e.clientX;
                    this.lastPointerMove.y = e.clientY;

                    this.clone.x += this.diff.x;
                    this.clone.y += this.diff.y;

                    this.clone.element.style.transform = 'translate3d(' + this.clone.x + 'px, ' + this.clone.y + 'px, 0)';

                    for (let i = 0; i < this.rectList.length; i++) {
                        if (i !== this.drag.index && e.clientX > this.rectList[i].left && e.clientX < this.rectList[i].right &&
                            e.clientY > this.rectList[i].top && e.clientY < this.rectList[i].bottom) {
                            if (this.drag.index < i) {
                                for (let j = this.drag.index; j < i; j++) {
                                    if ( j<this.drag.firstIndex) {
                                        this.containerElement.children[j].style.transform = 'translate3d(0px, 0px, 0)';
                                    } else {
                                        const x = this.rectList[j].left - this.rectList[j + 1].left;
                                        const y = this.rectList[j].top - this.rectList[j + 1].top;
                                        this.containerElement.children[j + 1].style.transform = 'translate3d(' + x + 'px, ' + y + 'px, 0)';
                                    }
                                }
                                this.referenceElement = this.containerElement.children[i + 1];
                            } else if (this.drag.index > i) {
                                for (let j = i; j < this.drag.index; j++) {
                                    if (this.drag.firstIndex <= j) {
                                        this.containerElement.children[j + 1].style.transform = 'translate3d(0px, 0px, 0)';
                                    } else {
                                        const x = this.rectList[j + 1].left - this.rectList[j].left;
                                        const y = this.rectList[j + 1].top - this.rectList[j].top;
                                        this.containerElement.children[j].style.transform = 'translate3d(' + x + 'px, ' + y + 'px, 0)';
                                    }
                                }
                                this.referenceElement = this.containerElement.children[i];
                            }
                            const x = this.rectList[i].left - this.rectList[this.drag.firstIndex].left;
                            const y = this.rectList[i].top - this.rectList[this.drag.firstIndex].top;
                            this.drag.element.style.transform = 'translate3d(' + x + 'px, ' + y + 'px, 0)';
                            this.drag.index = i;
                            break;
                        }
                    }
                }
            }
            onPointerUp(e) {
                if (this.isPointerDown) {
                    this.isPointerDown = false;

                    this.drag.element.classList.remove('active');
                    this.clone.element.remove();

                    for (const item of this.containerElement.children) {
                        item.style.transition = 'none';
                        item.style.transform = 'translate3d(0px, 0px, 0px)';
                    }

                    if (this.referenceElement !== null) {
			this.containerElement.insertBefore(this.drag.element, this.referenceElement);
		   }
                }
            }
            bindEventListener() {
                this.containerElement.addEventListener('pointerdown', this.onPointerDown.bind(this));
                this.containerElement.addEventListener('pointermove', this.onPointerMove.bind(this));
                this.containerElement.addEventListener('pointerup', this.onPointerUp.bind(this));

                window.addEventListener('scroll', this.getRectList.bind(this));
                window.addEventListener('resize', this.getRectList.bind(this));
                window.addEventListener('orientationchange', this.getRectList.bind(this));
            }
        }
        new Draggable({
            element: document.querySelector('.list')
        });
    </script>
</body>

</html>

总结

拖拽排序是常见的需求,它有两种实现方式,一种是通过 pointer 事件(mouse、touch 事件)封装,一种是基于 drag 事件封装。

这篇文章我们实现了基于 pointer 事件的拖拽排序。

核心流程是:

pointerdown 的时候 clone 一个新元素,pointermove 的时候改变它的 translate3d 来实现拖拽,pointerup 的时候删掉它。

通过 getBoundingClientRect 取出每个元素的位置,pointermove 的时候判断指针在哪个元素,然后通过 translate3d 移动前后位置之间的元素,还要设置 transition 的过渡效果。

最后 pointerup 的时候通过 insertBefore 完成元素移动,也就是排序的效果。

自始至终,我们只改变了一次 dom 顺序,拖拽过程中只是设置了 translate 位置,这种实现方式性能会比较好。

我们基于 pointer 事件实现的拖拽排序还不错,那基于 drag 事件实现是怎样的呢?有啥区别呢?这个下篇文章再聊。