如何实现性能更好,体感更流畅的HTML元素拖拽?

513 阅读4分钟

前言

目前遇到一个需求,是记录多个元素在容器内的位置,并且可以通过拖拽改变任一元素的位置。简单搜索了一下发现目前网上的文章大多是针对某一元素相对于视口的拖拽,并且都是通过topleft定位来改变位置,因此写一篇文章简单阐明笔者的实现方式。

单一元素相对于视口

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta
            http-equiv="X-UA-Compatible"
            content="IE=edge"
        />
        <meta
            name="viewport"
            content="width=device-width, initial-scale=1.0"
        />
        <title>单一元素-视口</title>
        <style>
            * {
                padding: 0;
                margin: 0;
                box-sizing: border-box;
            }
        </style>
        <style>
            .ball {
                width: 50px;
                height: 50px;
                position: absolute;
                border-radius: 50%;
                background: green;
            }
        </style>
    </head>
    <body>
        <div class="ball"></div>
        <script>
            const ball = document.querySelector(".ball");
            let isDragging = false;
            let x, y, l, t;
            const ballDown = (e) => {
                isDragging = true;
                //获取开始拖拽时鼠标的x坐标和y坐标
                x = e.clientX;
                y = e.clientY;
                //获取左部和顶部的偏移量
                l = ball.offsetLeft;
                t = ball.offsetTop;
            };
            ball.addEventListener("mousedown", ballDown);
            const drag = (e) => {
                if (!isDragging) return;
                //获取x和y
                const nx = e.clientX;
                const ny = e.clientY;
                //计算移动后的左偏移量和顶部的偏移量
                const nl = nx - (x - l);
                const nt = ny - (y - t);
                //重新设置小球位置
                ball.style.left = nl + "px";
                ball.style.top = nt + "px";
            }
            ball.addEventListener("mousemove", drag);
            const stopDrag = () => {
                isDragging = false;
            }
            ball.addEventListener("mouseup", stopDrag);
        </script>
    </body>
</html>

效果如下:

不难看出目前的实现存在问题,即当指针移动速度过快,离开了小球元素时,小球就不会跟随鼠标移动了,想解决这个问题也很简单,将mousemove事件绑定到容器上(即document)即可。

const ball = document.querySelector(".ball");

//绑定容器
const container = document.documentElement;

let isDragging = false;
let x, y, l, t;
const ballDown = (e) => {
    isDragging = true;
    x = e.clientX;
    y = e.clientY;
    l = ball.offsetLeft;
    t = ball.offsetTop;
  
    //将事件绑定到容器中
    container.addEventListener("mousemove", drag);
    container.addEventListener("mouseup", stopDrag);
};
ball.addEventListener("mousedown", ballDown);
const drag = (e) => {
    if (!isDragging) return;
    const nx = e.clientX;
    const ny = e.clientY;
    const nl = nx - (x - l);
    const nt = ny - (y - t);
    ball.style.left = nl + "px";
    ball.style.top = nt + "px";
};
const stopDrag = () => {
    isDragging = false;
    container.removeEventListener("mousemove", drag);
    container.removeEventListener("mouseup", stopDrag);
};

效果如下:

不难看出,此时不论鼠标移动多快,都不会丢失小球。

单一元素相对于容器

相对于视口是较为简单的场景,但往往在需要拖拽的元素外层还有一个容器

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta
            http-equiv="X-UA-Compatible"
            content="IE=edge"
        />
        <meta
            name="viewport"
            content="width=device-width, initial-scale=1.0"
        />
        <title>单一元素-容器</title>
        <style>
            .container {
                width: 400px;
                height: 400px;
                position: absolute;
                top: 50%;
                left: 50%;
                transform: translate(-50%, -50%);
                border: 1px solid red;
            }
            .ball {
                width: 50px;
                height: 50px;
                position: absolute;
                border-radius: 50%;
                background: green;
            }
        </style>
    </head>
    <body>
        <div class="container">
            <div class="ball"></div>
        </div>
        <script>
            const container = document.querySelector(".container");
            const ball = document.querySelector(".ball");
            let isDragging = false;
            let x, y, l, t;
            const ballDown = (e) => {
                isDragging = true;
                x = e.clientX;
                y = e.clientY;
                l = ball.offsetLeft;
                t = ball.offsetTop;
                container.addEventListener("mousemove", drag);
                container.addEventListener("mouseup", stopDrag);
            };
            ball.addEventListener("mousedown", ballDown);
            const drag = (e) => {
                if (!isDragging) return;
                const nx = e.clientX;
                const ny = e.clientY;
                const nl = nx - (x - l);
                const nt = ny - (y - t);
                ball.style.left = nl + "px";
                ball.style.top = nt + "px";
            };
            const stopDrag = () => {
                isDragging = false;
                container.removeEventListener("mousemove", drag);
                container.removeEventListener("mouseup", stopDrag);
            };
        </script>
    </body>
</html>

效果如下:

不难看出,当鼠标在容器内时,小球的拖拽很流畅,但当鼠标移到容器外时,小球也跟到了容器外,并且移动不连续。因此我们需要限制小球的移动范围。

const container = document.querySelector(".container");
const ball = document.querySelector(".ball");
let isDragging = false;
let x, y, l, t;
const ballDown = (e) => {
    isDragging = true;
    x = e.clientX;
    y = e.clientY;
    l = ball.offsetLeft;
    t = ball.offsetTop;
  
    //鼠标移出了容器,因此事件需绑定在更大的容器上
    document.documentElement.addEventListener("mousemove", drag);
    document.documentElement.addEventListener("mouseup", stopDrag);
};
ball.addEventListener("mousedown", ballDown);
const drag = (e) => {
    if (!isDragging) return;
    const nx = e.clientX;
    const ny = e.clientY;
    const nl = nx - (x - l);
    const nt = ny - (y - t);
  
    //边缘控制
    if (nl < 0) {
        ball.style.left = "0px";
    } else if (nl > container.clientWidth - ball.clientWidth) {
        ball.style.left = container.clientWidth - ball.clientWidth + "px";
    } else {
        ball.style.left = nl + "px";
    }
    if (nt < 0) {
        ball.style.top = "0px";
    } else if (nt > container.clientHeight - ball.clientHeight) {
        ball.style.top = container.clientWidth - ball.clientWidth + "px";
    } else {
        ball.style.top = nt + "px";
    }
};
const stopDrag = () => {
    isDragging = false;
    document.documentElement.removeEventListener("mousemove", drag);
    document.documentElement.removeEventListener("mouseup", stopDrag);
};

效果如下:

目前小球被限制只能在容器中。

多个元素相对于容器

本文将以两个小球为例,阐明笔者在处理多个元素拖拽时的想法

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta
            http-equiv="X-UA-Compatible"
            content="IE=edge"
        />
        <meta
            name="viewport"
            content="width=device-width, initial-scale=1.0"
        />
        <title>多个元素-容器</title>
        <style>
            .container {
                width: 400px;
                height: 400px;
                position: absolute;
                top: 50%;
                left: 50%;
                transform: translate(-50%, -50%);
                border: 1px solid red;
            }
            .ball1,
            .ball2 {
                width: 50px;
                height: 50px;
                position: absolute;
                border-radius: 50%;
            }
            .ball1 {
                background: red;
            }
            .ball2 {
                background: green;
            }
        </style>
    </head>
    <body>
        <div class="container">
            <div class="ball1"></div>
            <div class="ball2"></div>
        </div>
        <script>
            const container = document.querySelector(".container");
            const ball1 = document.querySelector(".ball1");
            const ball2 = document.querySelector(".ball2");
      
            //增加currentBall
            let currentBall = null;
      
            let isDragging = false;
            let x, y, l, t;
            const ballDown = (e) => {
      
                //将当前点击的小球记录下来
                currentBall = e.target;
          
                isDragging = true;
                x = e.clientX;
                y = e.clientY;
                l = e.target.offsetLeft;
                t = e.target.offsetTop;
                document.documentElement.addEventListener("mousemove", move);
                document.documentElement.addEventListener("mouseup", stopDrag);
            };
      
            //事件委托
            container.addEventListener("mousedown", ballDown);
      
            //只需要操作当前的小球即可
            const move = (e) => {
                if (!isDragging) return;
                const nx = e.clientX;
                const ny = e.clientY;
                const nl = nx - (x - l);
                const nt = ny - (y - t);
                if (nl < 0) {
                    currentBall.style.left = "0px";
                } else if (nl > container.clientWidth - currentBall.clientWidth) {
                    currentBall.style.left = container.clientWidth - currentBall.clientWidth + "px";
                } else {
                    currentBall.style.left = nl + "px";
                }
                if (nt < 0) {
                    currentBall.style.top = "0px";
                } else if (nt > container.clientHeight - currentBall.clientHeight) {
                    currentBall.style.top = container.clientWidth - currentBall.clientWidth + "px";
                } else {
                    currentBall.style.top = nt + "px";
                }
            };
            const stopDrag = () => {
                currentBall = null;
                isDragging = false;
                document.documentElement.removeEventListener("mousemove", move);
                document.documentElement.removeEventListener("mouseup", stopDrag);
            };
        </script>
    </body>
</html>


效果如下:

笔者的思路是点击小球时,将当前被点击的小球e.target绑定至已声明的变量currentBall中,后续的所有操作和单一小球类似,只不过操作的是currentBall而不是具体的小球

保存位置数据

在调整了元素的位置后,我们往往需要记录下元素调整后的位置,在这里笔者使用offsetLeftoffsetTop,为了方便这里使用了localStorage作为存储地点。

<!DOCTYPE html>
<html lang="en">
  
    省略部分代码
  
    <body>
        <div class="container">
            <div class="ball1"></div>
            <div class="ball2"></div>
        </div>
        <div class="position">
            <div class="ball1-pos">
                red ball <br />
                offsetLeft: <span class="b1-l">0</span> offsetTop: <span class="b1-t">0</span>
            </div>
            <div class="ball2-pos">
                green ball <br />
                offsetLeft: <span class="b2-l">0</span> offsetTop: <span class="b2-t">0</span>
            </div>
        </div>
        <script>
            const b1_l = JSON.parse(localStorage.getItem("b1_l"));
            const b1_t = JSON.parse(localStorage.getItem("b1_t"));
            const b2_l = JSON.parse(localStorage.getItem("b2_l"));
            const b2_t = JSON.parse(localStorage.getItem("b2_t"));

            const container = document.querySelector(".container");
            const ball1 = document.querySelector(".ball1");
            const ball2 = document.querySelector(".ball2");
            const spn_b1_l = document.querySelector(".b1-l");
            const spn_b1_t = document.querySelector(".b1-t");
            const spn_b2_l = document.querySelector(".b2-l");
            const spn_b2_t = document.querySelector(".b2-t");

            b1_l && (ball1.style.left = b1_l + "px");
            b1_t && (ball1.style.top = b1_t + "px");
            b2_l && (ball2.style.left = b2_l + "px");
            b2_t && (ball2.style.top = b2_t + "px");

            //省略部分代码
            ...

            const move = (e) => {
        
                ...
          
                spn_b1_l.innerHTML = ball1.offsetLeft;
                spn_b1_t.innerHTML = ball1.offsetTop;
                spn_b2_l.innerHTML = ball2.offsetLeft;
                spn_b2_t.innerHTML = ball2.offsetTop;
                localStorage.setItem("b1_l", JSON.stringify(ball1.offsetLeft));
                localStorage.setItem("b1_t", JSON.stringify(ball1.offsetTop));
                localStorage.setItem("b2_l", JSON.stringify(ball2.offsetLeft));
                localStorage.setItem("b2_t", JSON.stringify(ball2.offsetTop));
            };
      
            ...

        </script>
    </body>
</html>

效果如下:

性能提升

改变元素的位置可以通过修改lefttop属性,也可以使用transform属性。由于修改lefttop会引起reflow回流以及repaint重绘,对性能影响较大,而transform属性只触发repaint,同时还有GPU加速,因此性能较好

使用left和top触发了reflow和repaint

使用transform只触发了repaint

当页面中DOM元素较多时,性能影响更明显:

首先通过JS生成10万个div元素:

for (let i = 0; i < 100000; i++) {
    const el = document.createElement("div");
    el.classList.add("box");
    document.querySelector(".container").appendChild(el);
}

加

不难看出不论是CPU的占用率还是小球的跟手程度,两者都有非常大的差距。

因此我们可以做一个简单的改写(以单一元素相对于视口为例):

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta
            http-equiv="X-UA-Compatible"
            content="IE=edge"
        />
        <meta
            name="viewport"
            content="width=device-width, initial-scale=1.0"
        />
        <title>单一元素-视口</title>
        <style>
            * {
                padding: 0;
                margin: 0;
                box-sizing: border-box;
            }
        </style>
        <style>
            .ball {
                width: 50px;
                height: 50px;
                position: absolute;
                border-radius: 50%;
                background: green;
            }
        </style>
    </head>
    <body>
        <div class="ball"></div>
        <script>
            const ball = document.querySelector(".ball");
            const container = document.documentElement;
            let isDragging = false;
            let x, y, l, t;
            const ballDown = (e) => {
                isDragging = true;
                x = e.clientX;
                y = e.clientY;
                l = ball.offsetLeft;
                t = ball.offsetTop;
                container.addEventListener("mousemove", drag);
                container.addEventListener("mouseup", stopDrag);
            };
            ball.addEventListener("mousedown", ballDown);
            const drag = (e) => {
                if (!isDragging) return;
                const nx = e.clientX;
                const ny = e.clientY;
                const nl = nx - (x - l);
                const nt = ny - (y - t);
          
                //使用transform
                ball.style.transform = `translate(${nx - x}px,${ny - y}px)`;
          
                // ball.style.left = nl + "px";
                // ball.style.top = nt + "px";
            };
            const stopDrag = () => {
                isDragging = false;
                container.removeEventListener("mousemove", drag);
                container.removeEventListener("mouseup", stopDrag);
            };
        </script>
    </body>
</html>

这里需要注意的是,由于transform是相较于元素原有位置的移动,因此位移量与之前方法中的计算方式不同,只需要考虑鼠标的位移nx - xny - y即可

transform引起的offsetLeft与offsetTop不准确的问题

有得必有失,使用transform在提升性能的同时,也带来了新的问题,即无法获取元素移动后的offsetLeftoffsetTop,这两个值始终为元素原始位置的对应值。

如果此时松开鼠标左键,并重新拖拽小球,小球则会从原始位置开始移动,如下图:

因此要想获得准确的位置信息,还需要进行计算。通过代码不难得知,该元素的位移量就是鼠标的位移量,所以元素当前的位置实际上就是原来的坐标“加”鼠标移动的坐标。

代码如下:

const ball = document.querySelector(".ball");
const container = document.documentElement;
let isDragging = false;
let x, y, l, t, nl, nt;
const ballDown = (e) => {
    isDragging = true;
    x = e.clientX;
    y = e.clientY;
    l = ball.offsetLeft;
    t = ball.offsetTop;
    container.addEventListener("mousemove", drag);
    container.addEventListener("mouseup", stopDrag);
};
ball.addEventListener("mousedown", ballDown);
const drag = (e) => {
    if (!isDragging) return;
    const nx = e.clientX;
    const ny = e.clientY;
    nl = nx - (x - l);
    nt = ny - (y - t);
    ball.style.transform = `translate(${nx - x}px,${ny - y}px)`;
};
const stopDrag = () => {
    isDragging = false;
  
    //在停止拖拽后,设置小球的left和top,并还原transform
    ball.style.left = nl + "px";
    ball.style.top = nt + "px";
    ball.style.transform = "none";
  
    console.log("ball.offsetLeft", ball.offsetLeft, "ball.offsetTop", ball.offsetTop);
    container.removeEventListener("mousemove", drag);
    container.removeEventListener("mouseup", stopDrag);
};

效果如下:

对于容器内的多个元素也是同理:

let currentBall = null;
let isDragging = false;
let x, y, l, t, nl, nt, tX, tY;
const ballDown = (e) => {
    currentBall = e.target;
    isDragging = true;
    x = e.clientX;
    y = e.clientY;
    l = e.target.offsetLeft;
    t = e.target.offsetTop;
    document.documentElement.addEventListener("mousemove", move);
    document.documentElement.addEventListener("mouseup", stopDrag);
};
container.addEventListener("mousedown", ballDown);
const move = (e) => {
    if (!isDragging) return;
    const nx = e.clientX;
    const ny = e.clientY;
    nl = nx - (x - l);
    nt = ny - (y - t);
  
    //计算偏移量
    const overflow = nl < 0 || nt < 0 || nl > container.clientWidth - currentBall.clientWidth || nt > container.clientHeight - currentBall.clientHeight;
    tX = nx - x;
    tY = ny - y;
    if (overflow) {
        nl < 0 && (tX = -l);
        nl > container.clientWidth - currentBall.clientWidth && (tX = container.clientWidth - currentBall.clientWidth - l);
        nt < 0 && (tY = -t);
        nt > container.clientHeight - currentBall.clientHeight && (tY = container.clientHeight - currentBall.clientHeight - t);
    }
    nl = tX + l;
    nt = tY + t;
    currentBall.style.transform = `translate(${tX}px,${tY}px)`;
};
const stopDrag = () => {

    //改变元素真实位置
    currentBall.style.left = nl + "px";
    currentBall.style.top = nt + "px";
    currentBall.style.transform = "none";
  
    spn_b1_l.innerHTML = ball1.offsetLeft;
    spn_b1_t.innerHTML = ball1.offsetTop;
    spn_b2_l.innerHTML = ball2.offsetLeft;
    spn_b2_t.innerHTML = ball2.offsetTop;
    currentBall = null;
    isDragging = false;
    document.documentElement.removeEventListener("mousemove", move);
    document.documentElement.removeEventListener("mouseup", stopDrag);
};

效果如下:

至此效果基本符合预期。

后记

本文基本阐述了笔者对于元素拖拽的想法,事件匆忙,考虑难免有不周之处,如果您有更好的方案,欢迎讨论。