如何实现原生 JS 的拖拽效果
前言: 关于“拖拽”,其实是一个老生常谈的需求了,并且还是一个非常经典的面试题。之前在项目中拖拽的场景都是直接使用轮子,虽然很快就能完成设计需求,但是这个的原理一直都是我十分想去深入了解的一部分。
正好在今天的项目中再一次碰到了这个需求,我觉得是时候去探索一下它了。
tips: 本文不会使用 Draggable
去实现,而是会采用原生的 JS 鼠标移动,鼠标点击等事件去完成。并且你需要明确知道的一点是: 🎁本文的最终目的并不是实现一个开箱即用的轮子,而是让你理解拖拽实现的原理,知其然并知其所以然。 希望你可以有耐心和我一起完成下面的功能。
我们先看一下预览效果:
一. 前期准备
-
这个需求实现要准备的文件很少,你只需要创建一个
.vue
文件即可快速开始接下来的实现,你可以自己动手写出下面的样式,也可以跳到源码标题复制我的样式来快速进行下一步。 -
样式方面,在这里我使用的是
UnoCSS
,将样式內联在了标签里,如果你还不了解这种写法,你可以点击下方的文章学习。不过即使你之前从未了解过UnoCSS
,也不会影响你下面的阅读,因为样式不是本文的重点,并不影响整体阅读。 手把手教你如何创建一个代码仓库 -
在这里我们简化一下,我们暂时去掉不重要的
hover
动画的影响,直接切入主题 “拖拽”。 -
注意:为了减少出现大量的属性名导致本文理解起来难度会有些许提升的缘故,在这里我们暂时不牵扯 Y 轴上的拖拽效果。你也不必担心,因为它和 X 轴上的移动原理是完全一样的,还希望读者学习之后可以自行推导出。
二. click 和 mouseDown 和 mouseUp 的区别
-
首先用户想要完成拖拽这一操作,它的动作里肯定包含了鼠标按下的这个动作。在这里比较容易和 click 事件搞混。首先我们要知道 click 事件是包含两个动作的。一个是用户鼠标按下,一个是用户鼠标抬起。这两个关键动作如果在一起则构成了我们的 click 事件。
-
在这里我们补充一个额外的知识。注意上面划红线的一句话:
其实理解起来也很简单,就是当你同时给一个元素添加
click
和mouseDown
和mouseup
的时候。虽然看起来click
好像是由另外两个事件组成的,但其实它们三个是相互独立的事件。并且click
的优先级会低一点点,会在这两个事件执行完毕之后再去执行。 -
验证一下,我们直接先给滑块一个绑定这三个事件。
我们看一下控制台的输出顺序:
说明我们上面的结论是没问题的。
三. clientX 是什么?
- 不过我们今天的主角是
onMousedown
这个事件,所以我们暂时先把另外两位请下台稍作休息。
- 我们点击一下这个元素,在这里我们需要用到当前点击传递过来的事件对象身上的一些属性。
其实拖拽的关键点就在于如何利用这些属性来动态改变滑块的位置。 - 在这里我们选择使用
clientX
这个属性。这里如果大家对其它关于 X 的属性不了解的话,还希望自行去了解一下,不属于本文的范畴。你可以点击这里去了解其它属性的含义。
你必须知道的关于坐标轴的属性
在这里我简单介绍一下clientX
代表的含义。
假设我在滑块黄色圆点处点击了一下,那么从可视区域的范围内的最左侧开始到这个黄点的距离就是 clientX。
为什么我在这里要强调可视区域呢?我在下面的文章里已经做出了非常详细的介绍,感兴趣的话可以自行查阅。
[🫱 你必须知道的 clientWidth, offsetWidth, scrollWidth](juejin.cn/post/719612… - 这个属性对我们来说非常关键,聪明的你已经猜到了,它其实就代表着我们拖拽的起点坐标,这里我们需要把它保存到一个变量里。
四. onMouseMove 和 onMouseUp 的使用
- 和上面的代码大同小异,这里我就不过多赘述。
- 绑定这个事件之后,我们会发现当我鼠标在滑块内移动的时候,它就会执行。
- 但是这个效果并不是我们想要的,我们想要的是当我们鼠标按下的时候你开始记录就可以了,不需要触发的这么频繁。要达成也非常简单,增加一个中间变量
isDown
,来记录这个状态即可。那么随之就需要搭配我们的onMouseDown
和onMouseUp
来共同维护这个变量。
我把这个变量值直接显示在页面上,接下来我们测试一下:
可以看到已经暂时达到我们的需求。 - 到目前为止我们的实现其实存在一个 bug。具体看下面:
细心的读者可能已经发现了一个问题,当我在滑块内部按下鼠标后 isDown 的值变为了true
,但是当我鼠标划出滑块内部然后抬起的时候,mouseup 事件并没有被正确的执行。
- 最开始我在这里迷惑了很久 🤔,去 MDN 查阅相关事件的时候,并没有发现任何相关的解释。
- 但是我突然注意到了之前看到
Click
事件上的一段解释。
- 由这句话我猜想是否应该把这个
onMouseUp
上移到最外层的元素上来呢?🤔 说干就干。
然后我们验证一下:
嗯~现在我们的代码应该是没什么问题了,可以接着进行下一步了。 - 这里或许会有小伙伴迷惑,那我如果不在滑块外面松开了,我依旧在滑块内部松开呢?我们先验证一下:
可以看出,是丝毫不影响我们的效果的。
奇怪 🤔,这是为什么呢? - 我们首先给滑块一个不一样的
onMouseUp
事件。
经过上面的实验,我猜你已经发现了,其实非常简单,就是因为事件冒泡的机制。虽然我们在滑块内部松开了鼠标,但是由于事件冒泡,最外层 div 的onMouseUp
事件也被触发了,所以正确的设置了isDown
的状态。
五. 拖拽效果的原理
- 解决了边界问题,那么我们现在就可以放心地去完成拖拽的效果了,别着急写代码。首先让我们分析一下拖拽的原理到底是什么?
- 假设我在滑块内部鼠标按下后,拖拽了一段距离然后松开了鼠标。我们用下图的起点和终点分别代表这两个事件。
- 然后我们结合我们上面提到的关键属性
clientX
。
可以看出,我们滑块滑动的距离其实就是clienX
的差值。
- 关键问题就来了,如何得出这个差值?其实非常非常简单,我们的
onMouseMove
会被传递的那个事件对象上也存在一个clientX
属性,那我的起点坐标信息有了,这两者相剪不就是我们想要的结果吗?
六. 拖拽效果的实现
- 移动的距离有了,那么接下来就是如何将这个滑块动起来了,这里我查阅了两种方式,我们先介绍第一种。主要思路为将滑块更换为
absolute
布局,然后更改left
值来完成。这里我们先简单实现一下,然后再讲解它的弊端。 - 我们先给滑块打上 ref,因为之后我们要借助 JS 去操作这个元素节点。
- 思路非常清晰,当我们鼠标按下(onMouseDown)的时候,要给滑块设置
absolute
。 - 当鼠标移动(onMouseMove)的时候,将滑块
left
的值修改为差值。
- 对了,别忘了需要给滑块滑动范围的外壳 div 设置 relative 属性。
- 到这里我们其实就可以看到简单的效果了。
- 但是目前还会出现一个问题,如果我在滑动的时候松手,然后重新拖拽的时候,滑块会从头开始。
- 造成这个情况的原因也很简单,理想情况下,假设你在中间松手之后重新拖拽了 10px 的距离。
那么根据我们现在的逻辑,其实你刚刚移动了 1px 的时候,我们的代码马上执行了onMouseMove
函数。
那么它会马上设置我们滑块的left
为 1px,就造成了滑块马上回到了起点的现象。 - 解决方法也很简单,当鼠标按下的时候,拿到起始的 left 值即可。
然后我们在鼠标移动的差值之前每次都加上初始值就 ok 了。
我们看一下效果:
七. 更优雅的拖拽方案
- 在上面我们使用到了
absolute
定位,并且重复修改left
的值。其实这样的操作是会引起页面的重排。在性能方面上的考虑来讲,我们可以采取搭配tansform
来去操作这个移动的效果,对性能方面考虑来讲是更优的选择。 - 并且实现起来更加简单,我们只需要在滑块移动的时候修改
tansform
属性的tanslateX
即可。
效果如下:
只是目前还是会出现在中间松手,然后重新拖拽会返回起点的情况,造成的原因和上面absolute
的情况一样,都是需要加上初始的值。 - 但是这里获取初始值的方法不太一样。由于我们第一次调取
onMouseDown
的时候,我们的onMouseMove
事件其实还没触发,所以我们的transform
属性有可能为 字符串 String格式的 null。并且这里需要特别注意的一点是,我们拿到的tansform
属性是一个 matrix 函数的字符串表示形式。它并不是我们理想状态下的tansformX = 110 px
等这样现成可以使用的值。
- 这里我们如果要是使用的话的话,需要自己去通过字符串的一些方法去自行切割。
而我们想要的数据就是切割好的数组中的第五个。
- 那么对应的,在
onMouseMove
函数中直接使用即可。
这是页面的效果:
七. 源码
<script setup lang="ts">
import { ref } from "vue";
const slider = ref<HTMLDivElement>();
const startPoint = ref<number>(0);
const isDown = ref<boolean>(false);
const premitiveX = ref<number>(0);
function onMouseDown(e: any) {
isDown.value = true;
const style = window.getComputedStyle(slider.value!);
const { transform } = style;
if (transform !== "none") {
const matrixArr = transform.replace(/[^0-9\-,]/g, "").split(",");
console.log("matrixArr", matrixArr);
premitiveX.value = parseInt(matrixArr[4]);
} else {
premitiveX.value = 0;
}
const { clientX } = e;
startPoint.value = clientX;
}
function onMosueUp(e: any) {
isDown.value = false;
}
function onMouseMove(e: any) {
if (!isDown.value) return;
const { clientX } = e;
const moveDistance = clientX - startPoint.value;
const offset = premitiveX.value + moveDistance;
console.log("offset", offset);
slider.value!.style.transform = `translateX(${offset}px)`;
}
</script>
<template>
<div @mouseup="onMosueUp" class="w-100vw h-100vh bg-blue flex items-center">
<div class="w-500px h-200px bg-black flex ml-100px relative">
<div
ref="slider"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
class="w-100px h-full border-white border-4px"
>
<span class="text-50px">滑块</span>
</div>
</div>
</div>
</template>
<style></style>
总结
最开始写这个解锁效果的时候,其实也查阅了很多教程,大部分都是直接教你如何使用 H5 draggble
这个标签去实现的,但是我就在想 H5 之前人们是如何使用这个拖拽的呢?于是就自己去思考和动手尝试,最终才有了这篇文章。
随之几天我也会重新更新一篇使用 draggable
实现拖拽效果的文章,还是会秉持着通俗易懂的语言来和你一起学习这个知识点。与君共勉才是我写作的真正目的。