在 HTML 5 之前,想要实现 Drag and Drop(拖拽/拖放)一般需要求助于 JQuery,所幸 HTML 5 已经把 DnD 标准化,现在我们能“轻易”地为几乎任意元素实现拖放功能。只是它的难度取决于你对 API 的理解程度,而官方文档并不好懂。这篇文章会一步步带你了解它的 API。

最终效果如下:

拖动事件

继续之前,有必要先了解拖动时会触发哪些事件。考虑拖动 Source Element,途中经过 Intermediate Element,最终进入 Target Element 并松开鼠标,则路径上会触发的事件如下图所示:

这些事件的具体内容下面会讲到,你可以先跳过之后再回来查看,简单来说:

  1. dragstart:当我们“拖”起元素时会触发。
  2. dragenter:当拖动元素 A 进入另一个元素 B 时,会触发 B 的 dragenter 事件。
  3. dragleave:与 dragenter 相对应,当拖动元素 A 离开元素 B 时,触发 B 的 dragleave 事件。
  4. dragover:当拖动元素 A 在另一个元素 B 中移动/停止时触发 B 的 dragover 事件。文档说是每几百毫秒触发一次,Chrome 实测 1ms 左右触发;Firefox 大概是 300ms
  5. drop:当在拖动元素 A 到元素 B 上,释放鼠标时触发 B 的 drop 事件,相当于元素 B 接收了元素 A 。
  6. dragend:在 drop 事件之后,还会触发元素 A 的 dragend 事件,这里可以对元素 A 作一些清理工作。

除了上面的事件外,还有两个一般用不到的事件:

  1. drag:和 dragover 类似,当元素 A 被拖动时,每隔一段时间就会触发这个事件。与 dragover 不同,drag 事件是触发在源元素 A 上,而 dragover 是触发上潜在目标元素 B 上的。
  2. dragexit:这个事件只有 Firefox 支持,和 dragleave 作用几乎相同,发生在 dragleave 之前。

如果想实际验证一下这些事件是何时触发的,可以看看这个 jsfiddle,console 里会输出拖放的元素及对应的事件。下面我们开始一起实现咱们的拖放示例吧。

让元素可拖放

一般在 HTML 里,元素默认是不可以作为源元素的(除了 <a><img>),例如一个div ,我们是“拖不动”它的。这时只需要为它加上 draggable="true" 属性它就能“拖”了。下面是我们的 DOM 结构:

<div id="drag-container">
<div class="dropzone">
<div id="draggable" draggable="true">
Drag Me
</div>
</div>
<div class="dropzone"></div>
<div class="dropzone"></div>
</div>

draggable 元素上加了 draggable="true",这样我们就能拖动它了,起码在 Chrome 里可以,在 Firefox 里我们还需要在 dragstart 里为 dataTransfer 设置一些数据,因此需要加上下面的代码。具体的作用我们之后会说。

let draggable = document.getElementById('draggable');
draggable.addEventListener('dragstart', (ev) => {
ev.dataTransfer.setData('text/plain', null);
});

于是效果如下(CSS 没有贴出):

这样红色的 Drag Me 元素就可以拖动了。下面我们增加一些拖动时的反馈,让交互更真实。

添加拖动特效

首先,我们想在拖起元素让原始的元素变成半透明,这样当我们拖动时就会知道它是“真的可以拖动的”,而不是浏览器的什么奇怪行为。为此,我们可以监听 dragstart 事件:

draggable.addEventListener("dragstart", (ev) => {
ev.target.style.opacity = ".5";
});

这样一来我们开始拖动元素,它就变得透明了,然而我们松开鼠标,它依旧保持透明!这可不是我们想要的结果,因此我们需要监听 dragend 在拖动结束后还原透明度:

draggable.addEventListener("dragend", (ev) => {
ev.target.style.opacity = "";
});

下面,我们希望拖着元素 A 进入目标 B 时让 B 的边框变成虚线,以示意我们可以放入元素。

let dropzones = document.querySelectorAll('.dropzone');
dropzones.forEach((dropzone) => {

dropzone.addEventListener('dragenter', (ev) => {
ev.preventDefault();
dropzone.style.borderStyle = 'dashed';
return false;
});

dropzone.addEventListener('dragover', (ev) => {
ev.preventDefault();
return false;
});

dropzone.addEventListener('dragleave', (ev) => {
dropzone.style.borderStyle = 'solid';
});
});

我们为所有的 dropzone 都监听了 dragenterdragleave 事件,当拖动元素进入它们时,边框会变成虚线,离开时变回实线。这里有几个注意点:

  • dragenterdragover 里我们调用了 ev.preventDefault(),事实上几乎所有元素默认都是不允许 drop 发生的,这里调用ev.preventDefault() 可以阻止默认行为。
  • dragenter 中我们通过 dropzone 变量来修改样式而不是 ev.target,你可能觉得 ev.target 指向的是目标 B 元素,然而它指向的是源元素 A。
  • 我们在 dragenter 而不是 dragover 中修改样式,是因为 dragover 会触发太频繁了。

我们完成了“拖”的操作,最后需要完成“放”的操作了。

数据传输 DataTransfer

拖动是最终目的是为了对源和目标元素做一些操作。为了完成操作,需要在源和目标传输数据,我们可以通过设置/读取全局变量来完成,这并不是一个好习惯。在 HTML 5 中,我们通过 DataTransfer 完成。

我们在 dragstart 时设置需要传输的数据,在 drop 中获取需要的数据。 event.dataTransfer 提供了两个主要函数:

  • setData(format, data):用于添加数据,一般 format 对应于 MIME 类型字符串,常见的有 text/plaintext/htmltext/uri-list等,但同时也可以是任意自定义的类型;不幸的是 data 只能是 stringfile
  • getData(format):用于获取数据。

我们要实现将 Drag Me 放到其它蓝色元素中,需要传输它的 ID ,通过下面的代码实现:

draggable.addEventListener('dragstart', (ev) => {
ev.target.style.opacity = ".5";

// 设置 ID
ev.dataTransfer.setData('text/plain', ev.target.id);
});

dropzones.forEach((dropzone) => {
dropzone.addEventListener('drop', (ev) => {
ev.preventDefault()
ev.target.style.borderStyle = 'solid';

// 获取 ID
const sourceId = ev.dataTransfer.getData('text/plain')
ev.target.appendChild(document.getElementById(sourceId))
})
});
  • dragstart 时通过 setData 将 ID 放入 DataTransfer
  • drop 事件中,通过 getData 获取元素 ID 并通过 appendChild 加入到蓝色元素中。

至此我们的简单示例就结束了,为了实现这么一个简单的示例,我们用到了全部的 6 个事件。因此从入门的角度来说 DnD API 并不容易,但换句话说这也就是它的几乎全部内容了,而你现在已经掌握了!恭喜!

其它用法

定制拖放的行为时,还会有一些其它的需求,如拖放时的图标,到目标元素时鼠标的指针样式等,这里简单介绍一些。

当我们拖动元素时,浏览器默认生成了元素的缩略图,你可能需要自己设置,这时可以使用 DataTransfersetDragImage(image, xOffset, yOffset); 函数。参考 MDN 上的例子

event.dataTransfer.dropEffectevent.effectAllowed 共同决定了浏览器在执行拖动时的鼠标指针的行为,还有一些其它的用途。只是我实际测试时发现并不起作用, StackOverflow 的这个问题 说了一些自己的理解。

HTML5 还支持从操作系统中拖拽文件到浏览器中,或者从浏览器到操作系统中。如果从操作系统中获取文件,则可以访问 event.dataTransfer.files 字段,包含了操作系统中的文件内容。反之,在 dragstart 时正确设置 event.dataTransfer.files 则允许从浏览器中拖拽文件到操作系统中。

一些坑

  • dataTransfer 的内容只在 drop 里可读,所以如果你想在 dragEnterdragOver 中通过 dataTransfer.getData() 返回的内容来决定一个目标元素是否允许放置是不可行的。其它的事件里只能通过一个个检查 dataTransfer.items 里的 type 来获取已经设置的 format 而无法获取 data
  • dropdragend 事件是顺序触发的,但在 dragend 里没有办法知道 drop 事件是否已经触发。

如果你遇到过其它的坑,也请在评论区留言~

参考