我正在参加「掘金·启航计划」
接下来,我将会详细说明我在使用拖拽api的时候,遇到了哪些坑,解决方案是什么
1 实际应用遇到的问题
看过上篇文档,你一定会觉得,拖拽好像也不是很难,一个属性,7个事件。但是如果你真的直接使用上面的事件与api
开发过复杂拖拽应用的话,一定会记忆尤新。因为不进行一系列”优化"的话,基本是不可用的。接下来就来仔细讲解一下,实际运用遇到的坑
1.1 drop元素存在多层HtmlElement
我们先看一个例子
<div class="drag-box" style="border: 1px double;width:100px; margin: 50px" draggable="true">
dragItem
</div>
<div
class="drop1-box"
style="border: 1px solid;width:200px; height: 200px;margin: 50px;"
>
drop1
<div
class="drop2-box"
style="border: 1px solid;width:120px; height: 120px;margin: 30px;"
>
drop2
<p
class="drop3-box"
style="border: 1px solid;background: red;margin-top: 20px;height:40px"
>
drop3
</p>
</div>
</div>
<script>
const drop1 = document.querySelector('.drop1-box')
drop1.addEventListener('dragleave', (e) => {
console.log('dragleave', e.target.className)
})
drop1.addEventListener('dragenter', (e) => {
console.log('dragenter', e.target.className)
})
</script>
给drop-box
绑定了dragenter
,dragover
(这里不绑定了,与另外两个事件同理),dragleave
事件
const dropBox = document.querySelector('.drop-box')
dropBox.addEventListener('dragenter', (e) => {
console.log('dragenter', e.target.className)
})
dropBox.addEventListener('dragleave', (e) => {
console.log('dragleave', e.target.className)
})
如果只在drop1
与drop2
之间移动,那么一切事件触发表现性状与第一章节一样
注意,drop2
与drop3
也在drop-box
的范围内,按理来说,在drop2
与drop3
范围内移动的时候,控制台依旧只会打印 drop-box
的数据。但是,在具体操作过程中,表现形式如下
执行1
操作的时候,控制台打印
dragenter drop1-box
执行2
操作的时候,控制台打印
dragenter drop2-box
dragleave drop1-box
执行3
操作的时候,控制台打印
dragenter drop3-box
dragleave drop2-box
执行4
操作的时候,控制台打印
dragenter drop2-box
dragleave drop3-box
执行5
操作的时候,控制台打印
dragenter drop1-box
dragleave drop2-box
执行6
操作的时候,控制台打印
dragleave drop1-box
主要原因还需要了解一下,在日常开发中最常用的两种元素
- Text节点: 使用
document.createTextNode
创建的节点 - HTMLElement节点: 使用
document.createElement
创建的节点
如果所绑定的drag
等相关子元素中,只存在Text
,伪元素等节点,那么并不会存在上面问题,上面的问题只会出现在进入HtmlElement
子节点中时出现
1.2 子节点也绑定了drop
在复杂应用中,不会只给最外层元素绑定drop
等事件,还会给子节点,子孙节点等等无限嵌套节点绑定drop
事件,这里先探讨两层的情况下的例子,无限嵌套其实同理。比如接着1.1
的例子,给drop3
绑定相关事件
const drop3 = document.querySelector('.drop3-box')
drop3.addEventListener('dragleave', (e) => {
console.log('drop3 dragleave', e.target.className)
})
drop3.addEventListener('dragenter', (e) => {
console.log('drop3 dragenter', e.target.className)
})
事件触发逻辑如下
执行1
操作的时候,控制台打印
drop1 dragenter drop1-box
执行2
操作的时候,控制台打印
drop1 dragenter drop2-box
drop1 dragleave drop1-box
执行3
操作的时候,控制台打印
drop3 dragenter drop3-box
drop1 dragenter drop3-box
drop1 dragleave drop2-box
执行4
操作的时候,控制台打印
drop1 dragenter drop2-box
drop3 dragleave drop3-box
drop1 dragleave drop3-box
执行5
操作的时候,控制台打印
drop1 dragenter drop1-box
drop1 dragleave drop2-box
执行6
操作的时候,控制台打印
drop1 dragleave drop1-box
2.3 自己既可以drag也可以drop
如果某一个元素自己又可以拖拽,又允许drop
,那么就会出现自己的子节点可以是自己的情况,类似著名的循环引用
const obj = {}
obj.children = obj
例子如下,当然这个只是简化模型,具体情况比这个复杂,比如祖先节点拖到十八代孙节点下
<div class="drag-box" style="border: 1px double;width:100px; margin: 100px" draggable="true">
dragItem
</div>
<script>
const drag = document.querySelector('.drag-box')
drag.addEventListener('dragover', (e)=>e.preventDefault())
drag.addEventListener('drop', (e)=>{
console.log('drop')
})
</script>
2 解决问题
2.1 解决1.1问题
1.1
的事件行为逻辑其实是不直观的,按理来说,应该是这个样子的,如果进入了drop1
的范围内,只触发一次dragEnter
事件,离开drop1
的范围,才调用一次drop1
的dragleave
那么,为了解决这个问题,可以引入栈的概念,当初始化的时候,准备一个空栈
但执行步骤1的时候,判断当前栈是否为空,如果为空,打印dragenter
,如果不为空不打印,并且入栈1
那么1步骤执行完,控制台就会打印如下,并且栈里有一位
dragenter drop1-box
那么执行步骤2的时候,同理会触发dragenter
逻辑,但是因为栈里有一位,所以不会打印dragenter
,栈再加一
但是2操作也会触发dragleave
事件,那么执行dragleave
的时候,只需要先把栈个数-1,然后再判断栈中个数是否为0
,如果为0,就打印 dragleave drop1-box
,这时候,栈中个数为1,所以不打印
那么同理,按这个约定,执行完3,4,5操作后,栈中的个数还剩一个
当执行步骤6的时候,栈个数再减去1,此时,栈中的个数为0,控制台打印dragleave drop1-box
这就解决了1.1
的问题
2.2 解决1.2问题
如果drop3
也绑定了拖拽事件,那么就想实现一个效果
- 进入
drop1
与drop2
,只触发一次drop1
的dragenter
- 进入
drop3
触发drop3
的dragenter
,并触发drop1
的dragleave
- 离开
drop3
时,触发drop3
的dragleave
,触发drop1
的dragenter
- 离开
drop1
,触发drop1
的dragleave
那么,为了解决这个问题,其实只要给drop3
的dragenter
/dragover
/dragleave
事件加一个阻止事件冒泡就可以
有一个相对复杂的dom
的例子,如下。分别给drop1
,drop3
,drop5
绑定了事件
<div class="drag-box" style="text-align: center;border: 1px double;width:100px; margin: 50px" draggable="true">
dragItem
</div>
<div class="drop1-box" style="border: 1px solid;margin: 50px; width: 500px;">
drop1
<div class="drop2-box" style="border: 2px double;margin: 30px;">
drop2
<div class="drop3-box" style="border: 2px dashed;margin: 30px;">
drop3
<div class="drop4-box" style="border: 2px solid;margin: 30px;">
drop4
<div class="drop5-box" style="border: 2px double;margin: 30px">
drop5
<div class="drop6-box" style="border: 2px dashed;margin: 30px;height:40px">
drop6
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const dragBox = document.querySelector('.drag-box')
dragBox.addEventListener('dragend', ()=>{
console.log('dragend')
})
// --------------------------------------------------------------------------------------------------------------------
let drop1Stack = 0
let drop1Enter = false
const drop1 = document.querySelector('.drop1-box')
drop1.addEventListener('dragenter', (e) => {
if (drop1Stack++ === 0) {
drop1Enter = true
console.log('drop1 dragenter', e.target.className)
}
})
drop1.addEventListener('dragover', (e) => {
if (drop1Enter) {
console.log('drop1 dragover')
e.preventDefault()
}
})
drop1.addEventListener('dragleave', (e) => {
if (--drop1Stack === 0) {
drop1Enter = false
console.log('drop1 dragleave', e.target.className)
}
})
drop1.addEventListener('drop', (e) => {
console.log('drop1 drop')
})
// --------------------------------------------------------------------------------------------------------------------
let drop3Stack = 0
let drop3Enter = false
const drop3 = document.querySelector('.drop3-box')
drop3.addEventListener('dragenter', (e) => {
e.stopPropagation()
if (drop3Stack++ === 0) {
drop3Enter = true
console.log('drop3 dragenter', e.target.className)
}
})
drop3.addEventListener('dragover', (e) => {
if(drop3Enter){
console.log('drop3 dragover')
e.stopPropagation()
e.preventDefault()
}
})
drop3.addEventListener('dragleave', (e) => {
e.stopPropagation()
if (--drop3Stack === 0) {
drop3Enter = false
console.log('drop3 dragleave', e.target.className)
}
})
drop3.addEventListener('drop', (e) => {
e.stopPropagation()
console.log('drop3 drop')
})
// --------------------------------------------------------------------------------------------------------------------
let drop5Stack = 0
let drop5Enter = false
const drop5 = document.querySelector('.drop5-box')
drop5.addEventListener('dragenter', (e) => {
e.stopPropagation()
if (drop5Stack++ === 0) {
drop5Enter = true
console.log('drop5 dragenter', e.target.className)
}
})
drop5.addEventListener('dragover', (e) => {
if(drop5Enter){
console.log('drop5 dragover')
e.stopPropagation()
e.preventDefault()
}
})
drop5.addEventListener('dragleave', (e) => {
e.stopPropagation()
if (--drop5Stack === 0) {
drop5Enter = false
console.log('drop5 dragleave', e.target.className)
}
})
drop5.addEventListener('drop', (e) => {
e.stopPropagation()
console.log('drop5 drop')
})
</script>
使用1.1
+阻止事件冒泡
的机制,就能实现1.2
想要的效果
- 1 控制台打印如下
drop1 dragenter drop1-box
- 1.5 控制台打印如下
drop1 dragover
- 2 控制台打印如下
drop3 dragenter drop3-box
drop1 dragleave drop2-box
- 2.5 控制台打印如下
drop3 dragover
- 3 控制台打印如下
drop5 dragenter drop5-box
drop3 dragleave drop4-box
- 3.5 控制台打印如下
drop5 dragover
- 4 控制台打印如下
drop3 dragenter drop4-box
drop5 dragleave drop5-box
- 4.5 控制台打印如下
drop3 dragover
- 5 控制台打印如下
drop1 dragenter drop2-box
drop3 dragleave drop3-box
- 5.5 控制台打印如下
drop1 dragover
- 6 控制台打印如下
drop1 dragleave drop1-box
2.3 解决1.3问题
见下面的示例
<div class="drop" style="border: 1px solid #000;width:200px;margin: 50px;">
<div draggable="true" class="drag-drop1"
style="border: 1px solid #000; margin: 10px;height:50px;text-align: center;line-height: 50px;">
drag-drop1
</div>
<div draggable="true" class="drag-drop2"
style="border: 1px solid #000; margin: 10px;height:50px;text-align: center;line-height: 50px;">
drag-drop2
</div>
</div>
拖拽的元素可以拖到webview
外面,这个说明了这个行为已经和dom
没什么关系了
因此在浏览器中,事件触发机制会出现以下情况
- 拖动
drag-drop1
的时候,会触发drag-drop1
的dragenter
事件 - 拖动
drag-drop1
的时候,还可以拖到drag-drop1
的子元素中
所以需要做两件事情来解决这个问题
dragStart
的时候把当前的节点存储到全局(比如叫dragdom
),然后在以后的拖拽函数中,判断绑定此事件的dom
与dragdom
是否相同,如果相同,直接return
let dragDom = null
const dragDrop1 = document.querySelector('.drag-drop1')
dragDrop1.addEventListener('dragstart', (e) => {
e.stopPropagation()
dragDom = dragDrop1
if (dragDom === dragDrop1) {
return
}
console.log('dragDrop1 dragstart')
})
dragDrop1.addEventListener('drag', (e) => {
e.stopPropagation()
if (dragDom === dragDrop1) return
console.log('drag')
})
dragDrop1.addEventListener('dragend', (e) => {
e.stopPropagation()
if (dragDom === dragDrop1) return
console.log('dragDrop1 dragend')
})
dragDrop1.addEventListener('dragenter', (e) => {
e.stopPropagation()
if (dragDom === dragDrop1) return
console.log('dragDrop1 dragenter')
})
dragDrop1.addEventListener('dragover', (e) => {
e.preventDefault()
e.stopPropagation()
if (dragDom === dragDrop1) return
e.preventDefault()
console.log('dragover')
})
dragDrop1.addEventListener('dragleave', (e) => {
e.stopPropagation()
if (dragDom === dragDrop1) return
console.log('dragDrop1 dragleave')
})
dragDrop1.addEventListener('drop', (e) => {
e.stopPropagation()
if (dragDom === dragDrop1) return
console.log('dragDrop1 drop')
})
- 第二件事情,就是如果
dragdom
与绑定的事件的dom
一致,那么就需要把当前的所有子元素(不包括文本节点)的pointerEvents
为none
const dragDrop1 = document.querySelector('.drag-drop1')
dragDrop1.addEventListener('dragstart', (e) => {
e.stopPropagation()
dragDom = dragDrop1
if (dragDom === dragDrop1) {
// 设置pointerEvents
const childNodes = Array.from(this.dropDom.children)
let child
for (child of childNodes) {
child['style']['pointerEvents'] = 'none'
}
return
}
console.log('dragDrop1 dragstart')
})
...
dragDrop1.addEventListener('dragend', (e) => {
e.stopPropagation()
if (dragDom === dragDrop1) {
// 设置pointerEvents
const childNodes = Array.from(this.dropDom.children)
let child
for (child of childNodes) {
child['style']['pointerEvents'] = 'auto'
}
return
}
console.log('dragDrop1 dragend')
})
...