原文链接:https://javascript.info/mouse-drag-and-drop,translate with ❤️ by zhangbao.
拖放是一个伟大的交互革命。用拖拽的方式实现复制、移动(比如文件管理器)和排序(放入购物车)是一个简单完成很多任务的方式。
在现代 HTML 标准中有一个《拖放事件》一节 。
这类事件很有趣,因为可以借此很轻易地完成一些简单任务,还可以将“外部”文件拖拽到浏览器中,然后用 JavaScript 来获取文件内容。
但原生拖拽事件也有局限性。 例如,我们可以限制某个区域的拖动。此外,我们不能实现仅在“水平”或“垂直”方向上的拖拽。当然,还有其他一些不能使用原生拖拽 API 实现的任务。
因此,下面我们使用鼠标事件来实现拖拽功能,也没那么难。
拖拽算法
基本的拖拽算法是这样的:
为可拖拽元素绑定
mousedown事件。准备移动元素(创建一个副本或者怎样)。
在
mousemove事件里通过改变position: absolute元素的left/top属性值来移动元素。释放鼠标按键(
mouseup)的时候,执行拖拽结束后的行为。
这是基本规则,之后还会拓展。例如,当位于可放置(droppable)元素上时,高亮该元素。
下面展示了拖拽小球的算法:
ball.onmousedown = function (event) { // (1) 要开始了!// (2) 移动前的准备:绝对定位加设置 z-index 值ball.style.position = 'absolute';ball.style.zIndex = 1000;// 然拖拽元素离开当前父级元素,放到 body 中// 让它相对 body 进行定位document.body.append(ball);// ... 将绝对定位的球置于光标下面moveAt(event.pageX, event.pageY);// 让球的中心点落在 (pageX, pageY) 坐标点function moveAt(pageX, pageY) {ball.style.left = pageX - ball.offsetWidth / 2 + 'px';ball.style.top = pageY - ball.offsetHeight / 2 + 'px';}function onMouseMove(event) {moveAt(event.pageX, event.pageY);}// (3) 在 mousemove 的时候移动球document.addEventListener('mousemove', onMouseMove);// (4) 释放球,取出不需要的事件处理器ball.onmouseup = function () {document.removeEventListener('mousemove', onMouseMove);ball.onmouseup = null;};};
当我们执行代码的时候,会发现有点奇怪。在最开始拖拽球的时候,“复制”了一个球:我们拖拽的其实是副本,这导致了拖拽后释放鼠标,onmouseup 事件失效了。
这是因为浏览器有自己的“拖放”,用于图像和其他一些自动运行、并与我们发生冲突的元素。
禁止此种行为使用:
ball.ondragstart = function () {return false;};
好了,现在就正常了。
另外重要的一点是——我们是在 document 上绑定 mousemove 事件的,而不是在 ball 上。我们的第一印象,总会感觉鼠标是在球上的,所以应该给球绑定这个事件才对。
可我们要记住,mousemove 事件触发是有频度的,并不是每移动一像素就会触发。如果我们移动过快的话,鼠标光标可能会从球的某处直接跳入文档中(甚至是窗口外)。
所以我们应该给 document 绑定事件。
正确定位
上例中,光标总是处于球的中央位置。
ball.style.left = pageX - ball.offsetWidth / 2 + 'px';ball.style.top = pageY - ball.offsetHeight / 2 + 'px';
这样不坏,但有一个副作用。当我们初始化拖拽的时候,不论我们是在球的哪个地方按下(mousedown),球的中心点都会突然“跳到”光标所在处。
如果我们自始至终都能保证光标和球的相对位置就好了。
例如,如果我们最开始的拖拽点在球的一个边缘位置,我们按下鼠标按键,然后在整个拖拽过程中,都会一直保持在那个位置。

- 当用户按下鼠标的时候(
mousedown),我们能计算光标相对于球左上角的相对位置shiftX/shiftY。在拖动过程中,我们应该保持住这个偏移量。
为了得到这个偏移量,我们需要用到坐标轴减法。
// onmousedownlet shiftX = event.clientX - ball.getBoundingClientRect().left;let shiftY = event.clientY - ball.getBoundingClientRect().top;
请注意,在 JavaScript 中没有提供获取元素文档坐标的方法,所以我们使用了窗口坐标。
- 然后,在拖拽球进运动的时候,我们始终保持光标和球的相对位置就行了。
// onmousemove// ball 是 position: absolute 定位的ball.style.left = event.pageX - shiftX + 'px';ball.style.top = event.pageY - shiftY + 'px';
最终的更好的定位代码如下:
ball.onmousedown = function (event) {let shiftX = event.clientX - ball.getBoundingClientRect().left;let shiftY = event.clientY - ball.getBoundingClientRect().top;ball.style.position = 'absolute';ball.style.zIndex = 1000;document.body.append(ball);moveAt(event.pageX, event.pageY);function moveAt(pageX, pageY) {ball.style.left = pageX - shiftX + 'px';ball.style.top = pageY - shiftY + 'px';}function onMouseMove(event) {moveAt(event.pageX, pageY);}document.addEvenListener('mousemove', onMouseMove);ball.onmouseup = function () {document.removeEventListener('mousemove', onMouseMove);ball.onmouseup = null;};};ball.ondragstart = function () {return false;};
如果我们点击从球的右下方开始拖动,差别就会明显看出来。在前面的例子中,球的中心点会“跳跃”到光标处。现在,球可以流畅地在相对位置上跟随光标移动了。
检测可放置元素
前例中,我们可以把球放在文档里的任何区域,在现实场景里,我们通常是要把一个元素放置到另一个元素中中——例如,把一个文件放到一个文件夹里,把用户放入垃圾箱或者其他的什么操作。
理论上,就是把“可拖拽”元素置于“可放置”元素上。
我们需要知道拖放的目标元素,即那个可放置元素。然后实现拖拽行为,在拖拽过程中,正确地高亮可放置元素。
这个解决方案很有趣,需要点技巧,我们把它写在这里。
我们首先想到的是什么呢?也许要为我们潜在的可放置元素添加 mouseover/mouseup 事件处理器,然后在鼠标在经过它时高亮,然后我们就知道拖拽到这个元素上了。
但这不行。
问题是,在拖拽的时候,拖拽元素总是位于可放置元素之上。因此鼠标事件只会发生在包括自身在内的祖先元素上,而不会是下面的元素。
例如,有两个 div 元素,红的在蓝的上面。但是我们无法捕捉到蓝的,因为红的在上面。
<style>div {width: 50px;height: 50px;position: absolute;top: 0;}</style><div style="background:blue" onmouseover="alert('不会在这上面的……')"></div><div style="background:red" onmouseover="alert('在红的上面了!')"></div>
对可拖动元素而言,球总位于其他元素之上,所以事件发生在它上面。无论我们在底层元素上绑定了什么处理程序,都不会触发的。
这就是为什么在实践中,为潜在的可放置元素绑定处理程序行不通的原因,因为不会执行。
那么,该怎么做呢?
有一个方法 document.elementFromPoint(clientX, clientY),它返回距离指定窗口坐标的最内的元素(如果坐标处在窗口之外的话,返回 null)。
因此,我们可以在鼠标事件处理器中,向下面这样,检查光标下是否有可放置元素:
// 在鼠标事件处理器中ball.hidden = true; // (*)let elemBlow = document.elementFromPoint(event.clientX, event.clientY);ball.hidden = false;// elemBlow 就是球下面的那个元素了。如果是可放置元素,我们就对球处理。
需要注意的是,调用 elementFromPoint 方法之前,要先隐藏球。否则我们得到的总是球元素本身,因为此时球是光标处最顶层元素:elemBelow=ball。
我们可以在任何时候使用这些代码来检查我们的“越过”的元素。如果有的话,就处理它。
扩展版的 onMouseMove 方法如下:
let currentDropable = null; // 当前我们越过的可放置元素function onMouseMove(event) {moveAt(event.pageX, event.pageY);ball.hidden = true;let elemBelow = document.elementFromPoint(event.clientX, event.clientY);ball.hidden = false;// mousemove 事件可能会在窗口外触发(当球拖拽离开屏幕时)// if (clientX/clientY) 离开窗口,那么 elemFromPoint 方法返回 nullif (!elemBlow) { return }// 约定所有添加 .droppable 类名的元素都是可放置元素let droppableBelow = elemBlow.closest('.droppable');if (currentDroppable !== doppableBelow) {// 飞经或者飞离// 注意:两个值都有可能是 nullif (currentDroppable) {// 飞离可放置元素(取消高亮)leaveDroppable(currentDroppable);}currentDroppable = droppableBelow;if (currentDroppable) {// 飞入可放置元素(添加高亮)enterDroppable(currentDroppable);}}}
下面是完整代码:
<!doctype html><html><head><meta charset="UTF-8"><link rel="stylesheet" href="style.css"></head><body><p>Drag the ball.</p><img src="https://en.js.cx/clipart/soccer-gate.svg" id="gate" class="droppable"><img src="https://en.js.cx/clipart/ball.svg" id="ball"><script>let currentDroppable = null;ball.onmousedown = function(event) {let shiftX = event.clientX - ball.getBoundingClientRect().left;let shiftY = event.clientY - ball.getBoundingClientRect().top;ball.style.position = 'absolute';ball.style.zIndex = 1000;document.body.append(ball);moveAt(event.pageX, event.pageY);function moveAt(pageX, pageY) {ball.style.left = pageX - shiftX + 'px';ball.style.top = pageY - shiftY + 'px';}function onMouseMove(event) {moveAt(event.pageX, event.pageY);ball.hidden = true;let elemBelow = document.elementFromPoint(event.clientX, event.clientY);ball.hidden = false;if (!elemBelow) return;let droppableBelow = elemBelow.closest('.droppable');if (currentDroppable != droppableBelow) {if (currentDroppable) { // null when we were not over a droppable before this eventleaveDroppable(currentDroppable);}currentDroppable = droppableBelow;if (currentDroppable) { // null if we're not coming over a droppable now// (maybe just left the droppable)enterDroppable(currentDroppable);}}}document.addEventListener('mousemove', onMouseMove);ball.onmouseup = function() {document.removeEventListener('mousemove', onMouseMove);ball.onmouseup = null;};};function enterDroppable(elem) {elem.style.background = 'pink';}function leaveDroppable(elem) {elem.style.background = '';}ball.ondragstart = function() {return false;};</script></body></html>
整个过程中,我们在变量 currentDroppable 中保存当前的“放置目标”,将它高亮或者做其他事情。
总结
我们再回顾一下拖放功能算法。
关键内容:
事件流:
ball.mousedown→document.mousemove→ball.mouseup(取消原生ondragstart事件)。拖动开始——记住初始时,光标相对元素的偏移值:
shiftX/shiftY,在拖动过程中保持住这个偏移量。使用
document.elementFromPoint方法监测光标下的可放置元素。
我们可以在这个基础上做很多事情。
mouseup时我们结束拖动:改变数据、移动元素。可以高亮我们经过的元素。
限制拖动的有效区域以及方向。
我们可以对
mousedown/mouseup事件使用委托的形式。一个通过检查event.target的值来管理一个较大区域中发生的此类事件,可以同时管理数百个元素的拖放。……
有许多框架是基于此理论创建的:DragZone、Droppable、Draggable 等。它们中的多数做了与上面类似的事情,现在你应该比较容易理解了。或者是自己实现,因为我们已经知道怎样去处理事件,这可能比采用框架更加灵活。
练习题
问题
一、Slider
实现一个 Slider。

当我们用鼠标拖动蓝色滑块的时候,滑块跟随鼠标的移动而移动。
实现细节:
当在滑块上按下鼠标时,拖动过程中,无论鼠标是否位于滑块上,都要保证滑块跟随鼠标移动(为了给用户提供便利)。
当鼠标滑动的范围离开了灰色轨道时,确保滑块保持在轨道边缘,而不是越出轨道之外移动了。
二、在球场里拖动超级英雄
这个任务能够帮助你更加全面的理解拖拽和 DOM。
所有的元素都包含一个类名 draggable——表示是可拖动的。就像本章中的那个球。
要求是:
使用事件委托来跟踪拖动开始:为
document绑定一个mousedown事件处理器。如果元素被拖动到了窗口顶部/底部边缘——往上/往下滚动页面都能允许进一步的拖拽。
没有水平滚动条。
可拖拽元素不应该离开窗口,即使是在非常快速地移动鼠标的情况下。
这里提供一个链接,来让你查看是先前的代码准备。
答案
一、Slider
这里实现是水平拖拽功能。
我们使用 position: relative 来定位 thumb 在滑块中的位置,这比使用 position: absolute 更加方便。
<!DOCTYPE html><html><head><meta charset="utf-8"><style>.slider {border-radius: 5px;background: #E0E0E0;background: linear-gradient(left top, #E0E0E0, #EEEEEE);width: 310px;height: 15px;margin: 5px;}.thumb {width: 10px;height: 25px;border-radius: 3px;position: relative;left: 10px;top: -5px;background: blue;cursor: pointer;}</style></head><body><div id="slider" class="slider"><div class="thumb"></div></div><script>let thumb = slider.querySelector('.thumb');thumb.onmousedown = function(event) {event.preventDefault(); // 阻止浏览器的默认选择行为let shiftX = event.clientX - thumb.getBoundingClientRect().left;// 无需 shiftY, 因为 thumb 只是水平滚动的document.addEventListener('mousemove', onMouseMove);document.addEventListener('mouseup', onMouseUp);function onMouseMove(event) {let newLeft = event.clientX - shiftX - slider.getBoundingClientRect().left;// 光标位于滑块之外 => 将 thumb 锁定在边界if (newLeft < 0) {newLeft = 0;}let rightEdge = slider.offsetWidth - thumb.offsetWidth;if (newLeft > rightEdge) {newLeft = rightEdge;}thumb.style.left = newLeft + 'px';}function onMouseUp() {document.removeEventListener('mouseup', onMouseUp);document.removeEventListener('mousemove', onMouseMove);}};thumb.ondragstart = function() {return false;};</script></body></html>
二、在球场里拖动超级英雄
为了拖拽元素,我们使用 position: fixed,能更加容易的管理坐标。最后的的话,需要转换到 position: absolute。
然后,当坐标位于窗口顶部/底部时,我们可以使用 window.scrollTo 去滚动它。
点击这里查看解决方案。
(完)
