原文链接:https://javascript.info/mouse-drag-and-drop,translate with ❤️ by zhangbao.

拖放是一个伟大的交互革命。用拖拽的方式实现复制、移动(比如文件管理器)和排序(放入购物车)是一个简单完成很多任务的方式。

在现代 HTML 标准中有一个《拖放事件》一节 。

这类事件很有趣,因为可以借此很轻易地完成一些简单任务,还可以将“外部”文件拖拽到浏览器中,然后用 JavaScript 来获取文件内容。

但原生拖拽事件也有局限性。 例如,我们可以限制某个区域的拖动。此外,我们不能实现仅在“水平”或“垂直”方向上的拖拽。当然,还有其他一些不能使用原生拖拽 API 实现的任务。

因此,下面我们使用鼠标事件来实现拖拽功能,也没那么难。

拖拽算法

基本的拖拽算法是这样的:

  1. 为可拖拽元素绑定 mousedown 事件。

  2. 准备移动元素(创建一个副本或者怎样)。

  3. mousemove 事件里通过改变 position: absolute 元素的 left/top 属性值来移动元素。

  4. 释放鼠标按键(mouseup)的时候,执行拖拽结束后的行为。

这是基本规则,之后还会拓展。例如,当位于可放置(droppable)元素上时,高亮该元素。

下面展示了拖拽小球的算法:

  1. ball.onmousedown = function (event) { // (1) 要开始了!
  2. // (2) 移动前的准备:绝对定位加设置 z-index 值
  3. ball.style.position = 'absolute';
  4. ball.style.zIndex = 1000;
  5. // 然拖拽元素离开当前父级元素,放到 body 中
  6. // 让它相对 body 进行定位
  7. document.body.append(ball);
  8. // ... 将绝对定位的球置于光标下面
  9. moveAt(event.pageX, event.pageY);
  10. // 让球的中心点落在 (pageX, pageY) 坐标点
  11. function moveAt(pageX, pageY) {
  12. ball.style.left = pageX - ball.offsetWidth / 2 + 'px';
  13. ball.style.top = pageY - ball.offsetHeight / 2 + 'px';
  14. }
  15. function onMouseMove(event) {
  16. moveAt(event.pageX, event.pageY);
  17. }
  18. // (3) 在 mousemove 的时候移动球
  19. document.addEventListener('mousemove', onMouseMove);
  20. // (4) 释放球,取出不需要的事件处理器
  21. ball.onmouseup = function () {
  22. document.removeEventListener('mousemove', onMouseMove);
  23. ball.onmouseup = null;
  24. };
  25. };

当我们执行代码的时候,会发现有点奇怪。在最开始拖拽球的时候,“复制”了一个球:我们拖拽的其实是副本,这导致了拖拽后释放鼠标,onmouseup 事件失效了。

这是因为浏览器有自己的“拖放”,用于图像和其他一些自动运行、并与我们发生冲突的元素。

禁止此种行为使用:

  1. ball.ondragstart = function () {
  2. return false;
  3. };

好了,现在就正常了。

另外重要的一点是——我们是在 document 上绑定 mousemove 事件的,而不是在 ball 上。我们的第一印象,总会感觉鼠标是在球上的,所以应该给球绑定这个事件才对。

可我们要记住,mousemove 事件触发是有频度的,并不是每移动一像素就会触发。如果我们移动过快的话,鼠标光标可能会从球的某处直接跳入文档中(甚至是窗口外)。

所以我们应该给 document 绑定事件。

正确定位

上例中,光标总是处于球的中央位置。

  1. ball.style.left = pageX - ball.offsetWidth / 2 + 'px';
  2. ball.style.top = pageY - ball.offsetHeight / 2 + 'px';

这样不坏,但有一个副作用。当我们初始化拖拽的时候,不论我们是在球的哪个地方按下(mousedown),球的中心点都会突然“跳到”光标所在处。

如果我们自始至终都能保证光标和球的相对位置就好了。

例如,如果我们最开始的拖拽点在球的一个边缘位置,我们按下鼠标按键,然后在整个拖拽过程中,都会一直保持在那个位置。

拖放功能的鼠标事件实现 - 图1

  1. 当用户按下鼠标的时候(mousedown),我们能计算光标相对于球左上角的相对位置 shiftX/shiftY。在拖动过程中,我们应该保持住这个偏移量。

为了得到这个偏移量,我们需要用到坐标轴减法。

  1. // onmousedown
  2. let shiftX = event.clientX - ball.getBoundingClientRect().left;
  3. let shiftY = event.clientY - ball.getBoundingClientRect().top;

请注意,在 JavaScript 中没有提供获取元素文档坐标的方法,所以我们使用了窗口坐标。

  1. 然后,在拖拽球进运动的时候,我们始终保持光标和球的相对位置就行了。
  1. // onmousemove
  2. // ball 是 position: absolute 定位的
  3. ball.style.left = event.pageX - shiftX + 'px';
  4. ball.style.top = event.pageY - shiftY + 'px';

最终的更好的定位代码如下:

  1. ball.onmousedown = function (event) {
  2. let shiftX = event.clientX - ball.getBoundingClientRect().left;
  3. let shiftY = event.clientY - ball.getBoundingClientRect().top;
  4. ball.style.position = 'absolute';
  5. ball.style.zIndex = 1000;
  6. document.body.append(ball);
  7. moveAt(event.pageX, event.pageY);
  8. function moveAt(pageX, pageY) {
  9. ball.style.left = pageX - shiftX + 'px';
  10. ball.style.top = pageY - shiftY + 'px';
  11. }
  12. function onMouseMove(event) {
  13. moveAt(event.pageX, pageY);
  14. }
  15. document.addEvenListener('mousemove', onMouseMove);
  16. ball.onmouseup = function () {
  17. document.removeEventListener('mousemove', onMouseMove);
  18. ball.onmouseup = null;
  19. };
  20. };
  21. ball.ondragstart = function () {
  22. return false;
  23. };

如果我们点击从球的右下方开始拖动,差别就会明显看出来。在前面的例子中,球的中心点会“跳跃”到光标处。现在,球可以流畅地在相对位置上跟随光标移动了。

检测可放置元素

前例中,我们可以把球放在文档里的任何区域,在现实场景里,我们通常是要把一个元素放置到另一个元素中中——例如,把一个文件放到一个文件夹里,把用户放入垃圾箱或者其他的什么操作。

理论上,就是把“可拖拽”元素置于“可放置”元素上。

我们需要知道拖放的目标元素,即那个可放置元素。然后实现拖拽行为,在拖拽过程中,正确地高亮可放置元素。

这个解决方案很有趣,需要点技巧,我们把它写在这里。

我们首先想到的是什么呢?也许要为我们潜在的可放置元素添加 mouseover/mouseup 事件处理器,然后在鼠标在经过它时高亮,然后我们就知道拖拽到这个元素上了。

但这不行。

问题是,在拖拽的时候,拖拽元素总是位于可放置元素之上。因此鼠标事件只会发生在包括自身在内的祖先元素上,而不会是下面的元素。

例如,有两个 div 元素,红的在蓝的上面。但是我们无法捕捉到蓝的,因为红的在上面。

  1. <style>
  2. div {
  3. width: 50px;
  4. height: 50px;
  5. position: absolute;
  6. top: 0;
  7. }
  8. </style>
  9. <div style="background:blue" onmouseover="alert('不会在这上面的……')"></div>
  10. <div style="background:red" onmouseover="alert('在红的上面了!')"></div>

对可拖动元素而言,球总位于其他元素之上,所以事件发生在它上面。无论我们在底层元素上绑定了什么处理程序,都不会触发的。

这就是为什么在实践中,为潜在的可放置元素绑定处理程序行不通的原因,因为不会执行。

那么,该怎么做呢?

有一个方法 document.elementFromPoint(clientX, clientY),它返回距离指定窗口坐标的最内的元素(如果坐标处在窗口之外的话,返回 null)。

因此,我们可以在鼠标事件处理器中,向下面这样,检查光标下是否有可放置元素:

  1. // 在鼠标事件处理器中
  2. ball.hidden = true; // (*)
  3. let elemBlow = document.elementFromPoint(event.clientX, event.clientY);
  4. ball.hidden = false;
  5. // elemBlow 就是球下面的那个元素了。如果是可放置元素,我们就对球处理。

需要注意的是,调用 elementFromPoint 方法之前,要先隐藏球。否则我们得到的总是球元素本身,因为此时球是光标处最顶层元素:elemBelow=ball

我们可以在任何时候使用这些代码来检查我们的“越过”的元素。如果有的话,就处理它。

扩展版的 onMouseMove 方法如下:

  1. let currentDropable = null; // 当前我们越过的可放置元素
  2. function onMouseMove(event) {
  3. moveAt(event.pageX, event.pageY);
  4. ball.hidden = true;
  5. let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
  6. ball.hidden = false;
  7. // mousemove 事件可能会在窗口外触发(当球拖拽离开屏幕时)
  8. // if (clientX/clientY) 离开窗口,那么 elemFromPoint 方法返回 null
  9. if (!elemBlow) { return }
  10. // 约定所有添加 .droppable 类名的元素都是可放置元素
  11. let droppableBelow = elemBlow.closest('.droppable');
  12. if (currentDroppable !== doppableBelow) {
  13. // 飞经或者飞离
  14. // 注意:两个值都有可能是 null
  15. if (currentDroppable) {
  16. // 飞离可放置元素(取消高亮)
  17. leaveDroppable(currentDroppable);
  18. }
  19. currentDroppable = droppableBelow;
  20. if (currentDroppable) {
  21. // 飞入可放置元素(添加高亮)
  22. enterDroppable(currentDroppable);
  23. }
  24. }
  25. }

下面是完整代码:

  1. <!doctype html>
  2. <html>
  3. <head>
  4. <meta charset="UTF-8">
  5. <link rel="stylesheet" href="style.css">
  6. </head>
  7. <body>
  8. <p>Drag the ball.</p>
  9. <img src="https://en.js.cx/clipart/soccer-gate.svg" id="gate" class="droppable">
  10. <img src="https://en.js.cx/clipart/ball.svg" id="ball">
  11. <script>
  12. let currentDroppable = null;
  13. ball.onmousedown = function(event) {
  14. let shiftX = event.clientX - ball.getBoundingClientRect().left;
  15. let shiftY = event.clientY - ball.getBoundingClientRect().top;
  16. ball.style.position = 'absolute';
  17. ball.style.zIndex = 1000;
  18. document.body.append(ball);
  19. moveAt(event.pageX, event.pageY);
  20. function moveAt(pageX, pageY) {
  21. ball.style.left = pageX - shiftX + 'px';
  22. ball.style.top = pageY - shiftY + 'px';
  23. }
  24. function onMouseMove(event) {
  25. moveAt(event.pageX, event.pageY);
  26. ball.hidden = true;
  27. let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
  28. ball.hidden = false;
  29. if (!elemBelow) return;
  30. let droppableBelow = elemBelow.closest('.droppable');
  31. if (currentDroppable != droppableBelow) {
  32. if (currentDroppable) { // null when we were not over a droppable before this event
  33. leaveDroppable(currentDroppable);
  34. }
  35. currentDroppable = droppableBelow;
  36. if (currentDroppable) { // null if we're not coming over a droppable now
  37. // (maybe just left the droppable)
  38. enterDroppable(currentDroppable);
  39. }
  40. }
  41. }
  42. document.addEventListener('mousemove', onMouseMove);
  43. ball.onmouseup = function() {
  44. document.removeEventListener('mousemove', onMouseMove);
  45. ball.onmouseup = null;
  46. };
  47. };
  48. function enterDroppable(elem) {
  49. elem.style.background = 'pink';
  50. }
  51. function leaveDroppable(elem) {
  52. elem.style.background = '';
  53. }
  54. ball.ondragstart = function() {
  55. return false;
  56. };
  57. </script>
  58. </body>
  59. </html>

整个过程中,我们在变量 currentDroppable 中保存当前的“放置目标”,将它高亮或者做其他事情。

总结

我们再回顾一下拖放功能算法。

关键内容:

  1. 事件流:ball.mousedowndocument.mousemoveball.mouseup(取消原生 ondragstart 事件)。

  2. 拖动开始——记住初始时,光标相对元素的偏移值:shiftX/shiftY,在拖动过程中保持住这个偏移量。

  3. 使用 document.elementFromPoint 方法监测光标下的可放置元素。

我们可以在这个基础上做很多事情。

  • mouseup 时我们结束拖动:改变数据、移动元素。

  • 可以高亮我们经过的元素。

  • 限制拖动的有效区域以及方向。

  • 我们可以对 mousedown/mouseup 事件使用委托的形式。一个通过检查 event.target 的值来管理一个较大区域中发生的此类事件,可以同时管理数百个元素的拖放。

  • ……

有许多框架是基于此理论创建的:DragZoneDroppableDraggable 等。它们中的多数做了与上面类似的事情,现在你应该比较容易理解了。或者是自己实现,因为我们已经知道怎样去处理事件,这可能比采用框架更加灵活。

练习题

问题

一、Slider

实现一个 Slider。

拖放功能的鼠标事件实现 - 图2

当我们用鼠标拖动蓝色滑块的时候,滑块跟随鼠标的移动而移动。

实现细节:

  • 当在滑块上按下鼠标时,拖动过程中,无论鼠标是否位于滑块上,都要保证滑块跟随鼠标移动(为了给用户提供便利)。

  • 当鼠标滑动的范围离开了灰色轨道时,确保滑块保持在轨道边缘,而不是越出轨道之外移动了。

二、在球场里拖动超级英雄

这个任务能够帮助你更加全面的理解拖拽和 DOM。

所有的元素都包含一个类名 draggable——表示是可拖动的。就像本章中的那个球。

要求是:

  • 使用事件委托来跟踪拖动开始:为 document 绑定一个 mousedown 事件处理器。

  • 如果元素被拖动到了窗口顶部/底部边缘——往上/往下滚动页面都能允许进一步的拖拽。

  • 没有水平滚动条。

  • 可拖拽元素不应该离开窗口,即使是在非常快速地移动鼠标的情况下。

这里提供一个链接,来让你查看是先前的代码准备。

答案

一、Slider

这里实现是水平拖拽功能。

我们使用 position: relative 来定位 thumb 在滑块中的位置,这比使用 position: absolute 更加方便。

  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <meta charset="utf-8">
  5. <style>
  6. .slider {
  7. border-radius: 5px;
  8. background: #E0E0E0;
  9. background: linear-gradient(left top, #E0E0E0, #EEEEEE);
  10. width: 310px;
  11. height: 15px;
  12. margin: 5px;
  13. }
  14. .thumb {
  15. width: 10px;
  16. height: 25px;
  17. border-radius: 3px;
  18. position: relative;
  19. left: 10px;
  20. top: -5px;
  21. background: blue;
  22. cursor: pointer;
  23. }
  24. </style>
  25. </head>
  26. <body>
  27. <div id="slider" class="slider">
  28. <div class="thumb"></div>
  29. </div>
  30. <script>
  31. let thumb = slider.querySelector('.thumb');
  32. thumb.onmousedown = function(event) {
  33. event.preventDefault(); // 阻止浏览器的默认选择行为
  34. let shiftX = event.clientX - thumb.getBoundingClientRect().left;
  35. // 无需 shiftY, 因为 thumb 只是水平滚动的
  36. document.addEventListener('mousemove', onMouseMove);
  37. document.addEventListener('mouseup', onMouseUp);
  38. function onMouseMove(event) {
  39. let newLeft = event.clientX - shiftX - slider.getBoundingClientRect().left;
  40. // 光标位于滑块之外 => 将 thumb 锁定在边界
  41. if (newLeft < 0) {
  42. newLeft = 0;
  43. }
  44. let rightEdge = slider.offsetWidth - thumb.offsetWidth;
  45. if (newLeft > rightEdge) {
  46. newLeft = rightEdge;
  47. }
  48. thumb.style.left = newLeft + 'px';
  49. }
  50. function onMouseUp() {
  51. document.removeEventListener('mouseup', onMouseUp);
  52. document.removeEventListener('mousemove', onMouseMove);
  53. }
  54. };
  55. thumb.ondragstart = function() {
  56. return false;
  57. };
  58. </script>
  59. </body>
  60. </html>

二、在球场里拖动超级英雄

为了拖拽元素,我们使用 position: fixed,能更加容易的管理坐标。最后的的话,需要转换到 position: absolute

然后,当坐标位于窗口顶部/底部时,我们可以使用 window.scrollTo 去滚动它。

点击这里查看解决方案

(完)