巨详细!帮你踩坑HTML5拖拽api(下)

879 阅读8分钟

我正在参加「掘金·启航计划」

接下来,我将会详细说明我在使用拖拽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>

image-20221002140559801.png

drop-box绑定了dragenterdragover(这里不绑定了,与另外两个事件同理),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)
})

如果只在drop1drop2之间移动,那么一切事件触发表现性状与第一章节一样

image-20221002134235407.png

注意,drop2drop3也在drop-box的范围内,按理来说,在drop2drop3范围内移动的时候,控制台依旧只会打印 drop-box的数据。但是,在具体操作过程中,表现形式如下

image-20221002140927928.png

执行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)
})

事件触发逻辑如下

image-20220528111113484-16551061607132.png

执行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的范围,才调用一次drop1dragleave

image-20220528175432437.png

那么,为了解决这个问题,可以引入栈的概念,当初始化的时候,准备一个空栈

image-20220528175751875.png

但执行步骤1的时候,判断当前栈是否为空,如果为空,打印dragenter,如果不为空不打印,并且入栈1

image-20220528175928477.png

那么1步骤执行完,控制台就会打印如下,并且栈里有一位

dragenter drop1-box

那么执行步骤2的时候,同理会触发dragenter逻辑,但是因为栈里有一位,所以不会打印dragenter,栈再加一

image-20220528180539523.png

但是2操作也会触发dragleave事件,那么执行dragleave的时候,只需要先把栈个数-1,然后再判断栈中个数是否为0,如果为0,就打印 dragleave drop1-box,这时候,栈中个数为1,所以不打印

image-20220528180726990.png

那么同理,按这个约定,执行完3,4,5操作后,栈中的个数还剩一个

image-20220528180828798.png

当执行步骤6的时候,栈个数再减去1,此时,栈中的个数为0,控制台打印dragleave drop1-box

image-20220528180919862.png

这就解决了1.1的问题

2.2 解决1.2问题

如果drop3也绑定了拖拽事件,那么就想实现一个效果

  1. 进入drop1drop2,只触发一次drop1dragenter
  2. 进入drop3触发 drop3dragenter,并触发drop1dragleave
  3. 离开drop3时,触发drop3dragleave,触发drop1dragenter
  4. 离开drop1,触发drop1dragleave

image-20220528182208649.png

那么,为了解决这个问题,其实只要给drop3dragenter/dragover/dragleave事件加一个阻止事件冒泡就可以

有一个相对复杂的dom的例子,如下。分别给drop1drop3drop5绑定了事件

image-20220528202258675.png

<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没什么关系了

image-20220529100646451.png

因此在浏览器中,事件触发机制会出现以下情况

  • 拖动drag-drop1的时候,会触发drag-drop1dragenter事件
  • 拖动drag-drop1的时候,还可以拖到drag-drop1的子元素中

所以需要做两件事情来解决这个问题

  1. dragStart的时候把当前的节点存储到全局(比如叫dragdom),然后在以后的拖拽函数中,判断绑定此事件的domdragdom是否相同,如果相同,直接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一致,那么就需要把当前的所有子元素(不包括文本节点)的 pointerEventsnone
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')
})
...