随着互联网的发展,人们对前端体验的要求不断提高,过去纯点击式的网页操作难免让人感到厌烦。为了使用户操作更简便,HTML5中新增了一项功能 - 拖拽,它允许用户以鼠标拖拽的方式来操作网页,这更加符合人们的操作习惯。实际上该功能更多的是依赖JavaScript API的支持。除了支持在浏览器内部拖拽元素外,该接口还支持从浏览器外部向浏览器内拖拽文件,它借助的是操作系统的支持以及HTML5新增的另外一个特性 - File。下面我们就一起来看HTML5的拖拽如何使用。
拖拽的基本原理
在HTML5中,拖拽是由一系列与拖拽相关的事件组成的,如下:
事件名产生事件的元素事件说明dragstart被拖拽的元素拖拽开始drag被拖拽的元素拖拽过程中dragover拖拽时鼠标经过的元素被拖拽元素在当前元素上方移动dragenter拖拽时鼠标经过的元素被拖拽元素进入当前元素区域dragleave拖拽时鼠标经过的元素被拖拽元素离开当前元素区域drop拖放的目标元素有元素被拖放到了当前元素中dragend拖放的对象元素拖拽结束
上述事件的触发流程为:
- 当鼠标点击一个元素并移动,这会在该元素上触发dragstart和drag事件(如果元素不是链接或图片,则需要设置draggable=“true”,否则不允许拖动)。
- 当被拖拽元素进入某个元素的区域时,会触发该区域元素的dragenter事件。
- 当被拖拽元素在某个元素上方移动时,会触发该区域元素的dragover事件。
- 当被拖拽元素离开某个元素时,会触发该区域元素的dragleave事件。
- 释放鼠标时,会触发目标元素的drop事件,同时会触发被拖拽元素的dragend事件。
这些事件构成了一次拖拽完整的生命周期,拖拽过程中的所有行为都应该在这些事件中定义。拖拽最为核心的流程就是:在拖拽开始时,将我们需要传递的数据写入一个拖拽事件对象内;当释放鼠标时,由目标元素得到这个事件对象,并取出写入的数据执行操作。这个过程中,鼠标所经过的元素都具备获取该事件对象的能力。
现在我们以一个最基本的原生拖拽为例,来讲解拖拽的大致流程。该例子来自W3School(这里是截图,如需体验效果,请移步网页示例原生拖拽):
这里是两个div,其中左边的div里含有一张图片,现在我们希望实现将图片自由在两个div内拖拽。下面是页面的HTML结构(这里省略了css样式代码):
<div id="div1"> <img id="drag1" src="/i/eg_dragdrop_w3school.gif"/> </div> <div id="div2"> </div>
上面暂时省略了拖拽相关的事件,我们将通过一步步为元素添加事件,来讲解这些事件具体的含义和用法。
首先第一步,我们需要为图片(img元素)设置draggable=“true”(实际上img和a元素默认就是可拖拽的,不需要设置该参数。这里为了讲解原理,我们暂且把图片当做一个普通元素看待)。于是img标签就变成了下面的样子:
<img id="drag1" src="/i/eg_dragdrop_w3school.gif" draggable="true"/>
现在img就成了一个可拖拽的元素。当你用鼠标在该元素上点击并移动时,鼠标的下方就会出现一张浅色的图片跟随鼠标移动,这是浏览器的默认行为,它表示当前你正在拖拽该图片。
虽然浏览器为鼠标下方添加了一张图片,让用户在视觉上认为图片已经跟着鼠标移动了,但事实并不是这样。如果只是添加这一个属性,当你把图片拖拽到右侧容器并释放鼠标后,你会发现什么都没有发生 – 图片仍然在原来的位置。这说明拖拽并不是只开启元素的拖拽功能就可以。想要实现拖拽功能,最重要的是依赖一个事件对象,这个事件对象可以看做一个数据载体,而上面讲到的7个拖拽事件就是允许我们在不同阶段操作这个事件对象。
我们来看这个事件对象在拖拽的过程中的具体行为。
上面我们说到,dragstart在被拖拽元素上触发,于是我们可以为被拖拽元素(也就是img图片)注册一个回调函数,它负责在拖拽开始时向事件对象写入数据:
<img id="drag1" src="/i/eg_dragdrop_w3school.gif" draggable="true" ondragstart="drag(event)"/> <script> function drag(event){ event.dataTransfer.setData("Text",event.target.id); } </script>
现在img上注册了一个dragstart回调函数。一旦我们对该图片执行了拖拽,浏览器就会封装一个拖拽对象(我们记为event),并触发该元素的dragstart事件,并将该对象传入我们的回调函数。得到这个对象后,我们在回调函数内只定义了一行代码,就是将被拖拽元素的id保存在该对象的dataTransfer属性内。由于拖拽事件是在图片元素上触发的,所以event.target就是这个图片元素,此时dataTransfer内保存的就是img的id:“drag1”。setData的第一个参数“Text”表示当前存储的数据类型是文本(或者说是普通的字符串)。如果你想看一下这个事件对象是什么样的,它大概长这样:
OK,现在让我们跳过这个令人眼花的对象。我们只需要知道这个对象里存储了与本次拖拽相关的所有参数,而我们想要传递的数据也已经写在了它的dataTransfer属性里。
在默认情况下,所有的DOM元素都不接受释放行为。从视觉效果上来看,当你拖拽该图片到某块空白区域时,你可能会发现鼠标变成了禁用图标(一般为一个圆圈带一个斜线),这表示当前区域不允许释放拖拽元素。我们可以通过事件对象的preventDefault()方法来禁止这种默认行为。比如我们现在想让上面例子中右侧的那个div允许释放被拖拽元素,我们就可以给它注册一个ondragover(鼠标拖拽时在当前元素上方移动会触发该事件)回调函数,在该函数内取消浏览器的默认行为。代码如下:
<div id="div2" ondragover="allowDrop(event)"> </div> <script> function allowDrop(event){ event.preventDefault(); } </script>
取消了浏览器的默认行为后,当鼠标拖拽图片移动到右侧的div时,鼠标就会变成可释放的图标(具体图标因浏览器而异),它表示当前区域接受释放。对于上面的代码,如果你尝试拖拽,你会发现虽然从视觉上已经可拖拽了,但是一旦鼠标释放,仍然什么都没有发生,图片并没有按我们所想被放置到右侧的容器中。
这是为什么呢?
因为浏览器没有为我们释放鼠标的事件定义任何默认的行为,我们想做什么事必须自己手动定义。那么浏览器为什么不为我们提供自动移动DOM元素的默认行为呢?原因也很简单:为了避免歧义。我们知道,HTML是一种嵌套的结构,假如有下面两个嵌套的div:
当你在内部的div内释放鼠标时,这个事件不光会被内部的div元素捕获,还会被外部的div捕获(包括document元素也会捕获到这个事件),那么浏览器把被拖拽元素添加到哪个元素上呢?浏览器无法做出选择,因此无法为开发者提供默认的行为(当然可能还有其他原因,但仅这一个原因就已经很充分了)。
但是这个问题对开发者来说就不存在任何歧义,因为开发者可以选择为哪个元素绑定回调来处理这个事件(当然也可以都绑定,它们都会得到执行)。回到上面的例子,现在我们希望把图片拖放到右侧div时移动图片,于是我们给右侧的div绑定一个ondrop事件来监听鼠标的释放事件。代码如下:
<div id="div2" ondrop="drop(event)" ondragover="allowDrop(event)"></div> <script> function drop(event){ event.preventDefault(); var data=event.dataTransfer.getData("Text"); event.target.appendChild(getElementById(data)); } </script>
我们给右侧div绑定了ondrop事件,当鼠标拖拽图片在该区域释放时就会执行该回调函数,浏览器会把拖拽开始时生成的事件对象作为参数传递进来(还记得吗?我们在这个对象的dataTransfer属性里记录了被拖拽图片元素的id,现在要派上用场了)。
首先第一步,仍然是用preventDefault()禁止浏览器的默认行为(我们在dragover里只是保证鼠标在移动时不出现丑陋的禁用图标,但是鼠标释放时仍然会出现,虽然只有一瞬间,但这非常影响用户体验)。
第二步,取出我们在datatTransfer里写入的图片元素的id。
第三步,用原生选择器从DOM树中找到这个元素,使用appendChild方法添加到当前元素(此时的event.target指的是右侧的容器,因为该事件是在右侧容器上触发的)上。
所以我们真正执行的操作无非就是把图片元素查出来,用原生的DOM方法添加到右侧容器中。但是被拖拽的图片和目标容器本身是相互独立的,只有借助一个事件对象,目标容器才知道到底是哪个元素需要被添加进来。而这个事件的dataTransfer属性就是数据传递的载体。
经过上面的修改,这个代码就可以实现把图片从左侧div拖拽到右侧div了。为了能够实现两个容器的相互拖拽,我们需要为左侧容器也写上同样的监听事件,这样两个div就都具备了放置图片元素的能力,也就是W3School示例中的效果。一个最基本的拖拽也就实现了。
换个角度看拖拽
(原创声明:如需引用该部分内容,请注明出处)
从更高的角度来说,拖拽是浏览器为开发者封装的一个消息通道。这个通道的起点是被拖拽的元素,终点是目标元素。浏览器用一个事件对象用来描述与拖拽相关的参数,它产生于起点,被传递到目标元素,而鼠标经过的元素也可以从这个通道中获取事件对象。
用户将鼠标放到一个元素上,按下并拖动的行为将在该元素上开启这个消息通道。浏览器会生成一个用于描述该拖拽行为的事件对象,我们可以通过该对象写入自己的数据,也可以设置与本次拖拽相关的参数(比如移动时鼠标的样式、允许的拖拽类型等),然后该对象将在消息通道内传递。
浏览器向我们提供了若干个原生事件来从通道中获取这个事件对象,并执行需要执行的操作(比如当鼠标进入某个元素时,该元素可以通过ondragenter事件接口从消息通道中获取事件对象,假如你希望鼠标从当前元素上方掠过时变为可放置的样式,就可以通过event.preventDefault()来实现,就像我们上面做的那样)。让我们从新的角度重新来看浏览器为开发者提供的7个原生事件:
事件名产生事件的元素角色事件说明dragstart被拖拽的元素通道起点开启消息通道,生成事件对象,并写入数据或设置参数等drag被拖拽的元素通道起点允许在整个拖拽过程中从消息通道里获取事件对象dragend拖放的对象元素通道起点释放鼠标时,浏览器通过该事件通知起点元素dragenter拖拽时鼠标经过的元素通道的路径允许当鼠标进入某个元素时,从消息通道中获取事件对象dragover拖拽时鼠标经过的元素通道的路径允许当鼠标在某个元素上移动时,从消息通道中获取事件对象dragleave拖拽时鼠标经过的元素通道的路径允许当鼠标离开某个元素时,从消息通道中获取事件对象drop拖放的目标元素通道的终点释放鼠标时,目标元素从通道中得到事件对象
从表格中可以看到,被拖拽的元素(通道的起点)可以在拖拽的开始和结束,以及拖拽的整个过程(通常每隔350毫秒触发一次)中监听该事件。鼠标经过的元素只能在鼠标从该元素上方经过时监听到拖拽事件(这个过程又细分为进入、移动和离开)。而目标元素只能在鼠标释放时才能监听到该事件(因为在用户释放鼠标之前,我们无法知道目标元素是谁)。
现在是不是对拖拽又有了新的认识?
既然拖拽只是建立一个消息通道,那么我们可以传递的消息又何止元素的id呢?实际上该对象支持写入四种数据类型:
- “text/plain”:或简写为"text"。纯文本,也就是字符串。
- “text/html”:HTML格式的数据。
- “text/xml”:xml格式的数据。
- “text/url-list”:或简写为“url”。url列表。
实际上第一种数据类型就可以满足大多数情况下的需求。对于非字符串类型的数据,只需要压缩成字符串,最后再解析为原数据结构即可(如json数据可以用JSON.stringify压缩成字符串,再用JSON.parse解析为对象)。
上面我们只是传递了被拖拽元素的id,实际上与该元素相关的任何参数,甚至与该元素无关的数据(只要我们认为它对本次拖拽有用),都可以写入通道。而且释放鼠标时也不一定要执行appendChild来添加元素,我们可以在释放鼠标时做任何我们想做的事(如弹出一个提示框,或者根据传过来的参数生成任意的DOM结构,甚至把被拖拽的元素添加到页面的任何地方(只要你认为需要这样做,浏览器都是允许的,哪怕用户觉得很奇怪))。
下面我将自己写一个示例,来说明拖拽的灵活性(代码在后面可以找到,可以直接保存为一个HTML文件双击运行)。
该例子中,我们把上面的一个可拖拽按钮的textContent(即:“可拖拽元素”这几个字)写入事件对象。然后为第一个div定义的拖拽行为是添加到内部的一个ul中,为第二个定义的行为是使用alert弹出提示框,为第三个定义的行为是将其显示在第一个div内,同时在字符串前面拼上“来自第三个div的”这几个字。
现在当我们向第一个div内拖拽时,列表就会多出一项,文字内容为“可拖拽元素”。而向第二个div内拖拽时,就会出现如图所示的网页提示信息。向第三个div内拖拽时,我们看到在第一个div内列表的最后面多了一项“来自第三个div的可拖拽元素”。js代码如下:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>HTML5原生拖拽</title> <style> .des{ width:300px; height:200px; float: left; border: 1px solid #e6e6e6; } p{ color: #a6a6a6; font-size: 12px; } </style> </head> <body> <button id="src" draggable="true" class="nav">可拖拽元素</button> <br/><br/> <div id="des1" class="des"> <p>放进该区域会显示为列表<p/> <ul id="container"> </ul> </div> <div id="des2" class="des"> <p>放进该区域会得到一条提示<p/> </div> <div id="des3" class="des"> <p>放进该区域会输出在第一个div内<p/> </div> </body> <script> var src = getElementById("src"); var des1 = getElementById("des1"); var des2 = getElementById("des2"); var des3 = getElementById("des3"); src.addEventListener("dragstart", function(e){ var dt = e.dataTransfer; dt.effectAllowed = 'all'; dt.setData("text/plain", e.target.textContent); }); des1.addEventListener("drop", function(e){ var dt = e.dataTransfer; var text = dt.getData("text/plain"); var container = getElementById("container"); var li = createElement("li"); li.textContent = text; container.appendChild(li); e.preventDefault(); e.stopPropagation(); }, false); des2.addEventListener("drop", function(e){ var dt = e.dataTransfer; var text = dt.getData("text/plain"); ; e.preventDefault(); e.stopPropagation(); }, false); des3.addEventListener("drop", function(e){ var dt = e.dataTransfer; var text = dt.getData("text/plain"); text = "来自第三个div的" + text; var container = getElementById("container"); var li = createElement("li"); li.textContent = text; container.appendChild(li); e.preventDefault(); e.stopPropagation(); }, false); des1.ondragover = function(e){e.preventDefault();} des1.ondrop = function(e){e.preventDefault();} des2.ondragover = function(e){e.preventDefault();} des2.ondrop = function(e){e.preventDefault();} des3.ondragover = function(e){e.preventDefault();} des3.ondrop = function(e){e.preventDefault();} </script> </html>
虽然是很简单的示例,但是我想已经可以证明拖拽的灵活性。另外这里只是在释放时有不同的行为,我们还可以对不同的元素在拖拽开始时向事件对象写入任意的内容,这样组合起来拖拽就会变得相当灵活。
下面我们介绍一个将element-ui的el-tree上的节点拖拽到外部的例子,它更能说明原生拖拽的强大之处。
el-tree中节点拖拽的扩展
el-tree是Vue的element-ui中的树组件,该组件提供了较为强大的拖拽功能,一个最基础的可拖拽树只需要写成下面这样即可(来自element-ui官网):
<el-tree :data="data" node-key="id" default-expand-all @node-drag-start="handleDragStart" @node-drag-enter="handleDragEnter" @node-drag-leave="handleDragLeave" @node-drag-over="handleDragOver" @node-drag-end="handleDragEnd" @node-drop="handleDrop" draggable :allow-drop="allowDrop" :allow-drag="allowDrag"> </el-tree>
这里的事件监听器就对应我们上面讲到的原生事件,它们对每个节点都是生效的。draggable属性表示开启树的拖拽功能,这样树的每个节点都会被添加draggable属性。
默认情况下,树上的节点只支持在树的内部拖拽。也就是说,你无法把树上的节点拖拽到树的外面。但是这又是一个非常常见的需求(github的issue中说el-tree具备这个能力,但是并没有找到相关示例)。作为前端开发者,如果框架有一定的局限性,原生技术将是我们最强大的武器。下面我将简单介绍我是如何将树上的节点拖拽到树的外部的,希望对感兴趣的同学有所启发。
假设我们现在有一个容器,我们希望可以把树上的某个节点拖拽到这个容器里形成一个列表,这个列表暂时只保留原树节点的文本内容和id(因具体需求而异)。我们继续使用上面的树作为拖拽源,并给出下面一个div作为容器:
<div class="menu-list" @drop="handleTargetDrop" @dragover="handleTargetDragOver"> <ul> <li v-for="item in menus" :key="item.id> <span>{{item.name}}</span> </li> </ul> </div>
由于这是在Vue中,我们不需要直接操作DOM,只需要修改该ul对应的数据即可。在拖拽开始之前,menus值为空,所以该容器内不会显示任何内容。
现在我们先来处理el-tree。由于我们不需要改变树结构,因此需要屏蔽树自身的drop行为,这可以很容易通过设置绑定的allow-drop来实现,同时需要设置allow-drag使节点可拖拽:
allowDrop(draggingNode, dropNode, type) { return false; }, allowDrag(draggingNode) { return true; },
好的,现在树上的节点都无法在树上移动了,并且都是可拖拽的。接下来要处理向外部拖动的行为了。我们需要定义节点的node-drag-start事件,它与原生事件的dragstart对应,是框架向我们提供的接口。我们可以在该事件的回调函数内将我们需要传递的数据封装进去,为了简单,我们直接传递整个节点的data即可。如下:
handleDragStart(node, ev) { let dt = ev.dataTransfer; ev.dataTransfer.effectAllowed = 'copy'; dt.setData("text/plain", JSON.stringify(node.data)); },
现在我们把树上被拖拽的那个节点的data压缩成json字符串写进了事件对象的dataTransfer里。至此,树节点已经可以向外提供节点数据了。
下一步就是要处理我们的容器了。首先,我们要取消浏览器阻止拖拽的默认行为,为了用户体验,我们在dragover和drop中同时阻止该行为(drop的我们后面可以看到)。
handleTargetDragOver(e){ e.preventDefault(); },
下面就是要处理drop事件,我们需要在鼠标释放时修改容器的列表所对应的数据menus(从这里就可以看出MVVM的设计理念,我们的视线永远放在如何操作数据上,而不会想着如何操作DOM,因为框架会在数据变化时自动操作DOM)。实际上这相当简单:
handleTargetDrop(e){ let data = e.dataTransfer; let content = JSON.parse(data.getData("text/plain")); this.menus.push({id: content.id, name: content.name}); e.preventDefault(); //通常不需要阻止冒泡,但是当出现容器嵌套时最好这么做 //它可以防止节点被添加到数组中两次 e.stopPropagation(); }
我们看到,只需要非常简单的代码,就可以将树上的节点拖拽到外部容器了,这再一次证明了原生拖拽的灵活性和强大。
总结
本文并不是一篇详细介绍原生拖拽细节的文章。实际上拖拽事件中有很多的参数都可以设置,比如你可以设置当前拖拽只能复制,或者修改鼠标移动时跟随鼠标移动的图片,你还可以设置当元素进入某个区域时,底部的元素产生一定的动态效果,这样会带来相当高级的用户体验。
此外,在事件对象的dataTransfer中还包含一个有用的属性files,它存储了从浏览器外部拖拽进来的文件,如果我们在某个元素的drop事件中读取这个文件列表,就可以获取用户拖拽进来的文件,HTML5的file API允许我们直接在浏览器显示该文件,或者选择上传到服务器等(如果你使用的是Chrome,并且征得了用户同意,甚至可以修改这些文件,这依赖fileWriter接口,但由于安全问题,该接口的支持性不是很好)。除此之外,单凭拖拽甚至可以写出一些有趣的HTML5网页游戏,而这完全取决于你的创造能力。
本文最重要的目的不是展示该技术可以被使用得多么神奇,而是希望探究它的基本原理,为以后的使用打下良好的基础。希望对不了解原生拖拽的同学有所帮助。