拖拽

201 阅读9分钟

基础

原生拖拽

被拖拽的元素需要有draggable属性。
当点击该元素,并开始拖拽,在这一瞬间,触发dragStart事件。
移动时,触发onDragOver事件以及Drag事件。
松开时,触发onDrop事件。

              <div
                draggable
                class="max-w-sm mx-auto bg-gradient-to-br from-blue-100 to-purple-100 rounded-xl shadow-lg overflow-hidden flex items-center space-x-4 p-6 hover:transform hover:scale-105 transition-all duration-300"
                onDragStart={(e) => handleDragStart(e, card.id)}
                onDragOver={handleDragOver}
              >
                <img class="h-16 w-16 rounded-full object-cover shadow-xl" src={card.avatar} alt={`${card.name}的头像`} />
                <div class="animate-fade-in">
                  <div class="text-xl font-bold text-indigo-600">{card.name}</div>
                  <div class="text-purple-500 font-medium">{card.position}</div>
                  <div class="mt-2 text-sm text-gray-600 hover:text-indigo-500 transition-colors duration-300">热爱编程,喜欢探索新技术。</div>
                </div>
              </div>

要看到拖动效果,有两个条件:

  1. 设置draggable="true"
  2. 触发dragOver事件时preventDefault()。
  3. 如果是火狐浏览器,还有第三个条件--e.dataTransfer?.setData('text/plain', id.toString());

不过这样只是画面上来说有了拖拽效果。
使用cursor-move的tailwind样式可以在hover时让鼠标变成十字,让用户感觉到这是可以拖动的。
PixPin_2025-08-11_00-57-51.png

拖拽鼠标样式

PixPin_2025-08-11_00-12-09.png

none

PixPin_2025-08-11_00-16-26.png

copy

是一个加号,不太明显
PixPin_2025-08-11_00-17-36.png

move

默认就是move
PixPin_2025-08-11_00-20-24.png

link

PixPin_2025-08-11_00-15-21.png

拖拽样式问题

原生的拖拽,会让透明的被拖拽元素跟随鼠标。
问题就在于,这个透明度完全不能改,这样透明的拖拽非常不直观。
PixPin_2025-08-11_01-53-39.png

现在我们想要的是实心拖拽效果。
segmentfault.com/a/119000002…
这篇博客里面有提到一些思路。

  1. 使用下面这个方法设置空白元素作为拖拽跟随图片。
    e.dataTransfer.setDragImage(clone, xOffset, yOffset);
  2. 手动创建浮动元素跟随鼠标,在拖拽结束后手动去除。
    虽然这个思路很好,但是在拖拽处理中

在被拖拽的元素上触发的事件

这些事件监听的是被拖动的元素的状态。

  1. dragstart

    • 何时触发: 当用户开始拖拽一个元素时触发。
    • 主要用途: 在这个事件中,你需要调用 event.dataTransfer.setData() 来存储被拖拽元素的数据,以及设置 event.dataTransfer.effectAllowed 来指定允许的拖放效果(copy, move, link 等)。
  2. drag

    • 何时触发: 在整个拖拽过程中,只要鼠标移动,这个事件就会持续触发。
    • 主要用途: 这个事件的开销比较大,通常用于实时更新拖拽时的视觉效果,比如自定义的浮动元素跟随鼠标移动。
  3. dragend

    • 何时触发: 当拖拽操作结束时触发,无论是成功放置还是被取消。
    • 主要用途: 在这个事件中,你可以进行清理工作,比如移除在 dragstart 中添加的浮动元素或恢复样式。

在放置区域上触发的事件

这些事件监听的是鼠标悬停的元素的状态。

  1. dragenter

    • 何时触发: 当被拖拽的元素进入一个有效的放置区域时触发。
    • 主要用途: 用于给放置区域添加视觉反馈,比如改变背景颜色或边框,以提示用户可以放置在此处。
  2. dragover

    • 何时触发: 当被拖拽的元素在放置区域内移动时,这个事件会持续触发。
    • 主要用途: **这是最重要的事件之一。**你必须在这个事件中调用 event.preventDefault() 来阻止浏览器的默认行为(默认是不允许放置),从而将该元素声明为一个有效的放置区域。如果不这样做,drop 事件将永远不会触发。
  3. dragleave

    • 何时触发: 当被拖拽的元素离开一个有效的放置区域时触发。
    • 主要用途: 用于移除 dragenter 事件中添加的视觉反馈,比如恢复放置区域的原始样式。
  4. drop

    • 何时触发: 当用户在有效的放置区域内释放鼠标时触发。
    • 主要用途: 在这个事件中,你需要调用 event.preventDefault() 以避免浏览器打开拖拽内容(例如,如果拖拽的是文件)。然后,通过 event.dataTransfer.getData() 来获取在 dragstart 事件中存储的数据,并执行放置后的逻辑。

事件传染

父容器的拖拽事件自动传递给子元素,比如我的卡片可拖动,内部有两行文字一张图片,那么当我鼠标分别经过这三个子元素时,总共会触发3次DragEnter,这和我们预想中是不一样的。

这和冒泡是不一样的,冒泡是子元素绑定的事件和父容器绑定的事件一起发生,而此处我们没有设置子元素绑定事件。
因此,只能在事件绑定方法中,判断是否是由子元素触发的,如果是直接返回,啥也不做。

回退机制

当拖动到区域外时,我们默认不处理相关数据。这时我们不需要考虑回退机制,因为我们并没有做出直接修改。

合法再移除

而倘若存在两个区域,那么有两个数据列表,我们称为list1和list2。
当1移到2区域时,数据应该转移。

  1. 从list1中移除
  2. 添加到list2中
    这样的逻辑是最简单的,而且不需要考虑非法区域时把元素放回去,因为我们本来就没删掉原来的元素。

而倘若存在多个区域,比如说4个。每多一个区域,就要在其他的每一个区域中加一个判断。

  1. 判断从哪个区域来的,从那个区域里面移除
  2. 添加到当前区域。

正常来说,立即移除应该比较符合用户的思路,这个元素都被我拖拽了,他就不该留在原地。
不过,如果在原地留下虚影,这也是符合用户操作逻辑的

符合逻辑的是:有合法落点时再执行移除和添加
可以看看tiermaker的例子。
tiermaker.com/create/ceet…

PixPin_2025-08-11_20-47-00.png

核心逻辑

拖拽过程只有两个状态:

  1. 非法状态,当前鼠标所在位置不是落点区域。
  2. 合法状态,处于落点区域。
    相关处理:
  3. 非法状态,退回到最后一个合法区域。
  4. 合法状态,移除来源卡片元素,并将该卡片添加到目标落点区域数据中。
  • 判断是否合法
  • 将元素正确地放到落点区域数组中

删除来源元素:当前元素拖动到和来源区域不同的其他区域。(DragEnter)
插入元素(调顺序):当前元素在多个卡片之间来回拖动。(DragOver)

立即移除

倘若我们在元素离开区域1时,就直接把该元素去除,那么相应的,我们至少应该准备一个回退函数,用来处理拖拽到非法区域(比如未定义区域)时的回退逻辑,比如将其放回原位。

  • 任意未定义区域,放回原位(执行回退逻辑)
  • 任意可拖放区域,只处理新增逻辑。

好处是,落点区域完全不关心来源是什么,只关心拖拽的卡片本身。
坏处是,需要手动处理非法区域时的回退逻辑。

定义一个空的全局回退函数。
开始拖拽时(dragstart),覆盖回退函数(因为每个落点区域的逻辑不同)。
问题在于,drop是针对于某个元素监听,全局监听会影响到全部元素(感觉有性能问题)。
于是乎,我们难以检测在非法区域松开鼠标的行为。

事件

DragStart

真正的只触发一次的事件,我们可以用来自定义鼠标跟随元素。
结合Drag事件更新跟随元素的位置。

e.dataTransfer?.setData('text/plain', id.toString());

DragOver

排序功能主要依赖于DragOver事件,里面没有删除元素的逻辑。

DragEnter

删除的逻辑应该放在这里,而不是Leave,元素不该凭空消失。
由于事件会传播给子元素,因此应该判断是否处于容器分界点(而不是子元素)。
因此父容器和子元素之间最好存在空隙(padding之类的),但是子元素之间最好没有空隙。

由于存在间隙,所以从外移到内,一定是经过间隙的,而内部元素可以通过closest('.card')来排除。
然而,内部元素移到空白间隙时也会触发DragEnter事件。因此不应该把一次性执行的逻辑放在这。
因此DragEnter几乎是超低频率的DragOver了。

因此,如果要使用真正的边界进入(或者离开)的逻辑,应该直接计算坐标。
幸运的是,虽然在间隙和元素之间移动会触发Enter,但是在间隙中移动却不会,因此不会有执行多次这样的困扰。

或者,我们使用一个全局变量记录当前所在的区域,Enter触发时,判断当前区域和先前记录的区域是否一样,如果不一样,则触发某些逻辑。
也许记录区域的方法也许更好些。
我们可以直接用isValidDrag信号量来

DragLeave

由于前面提到的,子元素也会触发Leave,所以这个事件的意义不大。
只能通过计算来判断是否处于父容器边缘了。

到此处,我们通过Enter和Leave,监测鼠标是否处于非法位置。
而通过DragEnd事件监听,捕捉松开鼠标的动作。
至此,回退的逻辑已经可以使用了。

还有一些问题,当我们放在区域里面,但是又不处于任何一个卡片上,这时正确的逻辑应该是添加到卡片末尾。
如果使用状态标志,那么状态为合法、处于当前区域中、且不处于某个卡片的区域中。
此时其实可以发现,那不就是处于间隙中吗?没错就是间隙中,因此可以使用父容器的DragEnter来判断,只需要获取最近的卡片元素,如果获取不到,那么就是处在空隙中。
不过,如果要使用这个逻辑,卡片之间不能有空隙。为了美观,我们可以在外面套一层用来控制间隙。
大致是下面这个样子,外面是wrapper,内部是card,wrapper之间不能有间隙(因为间隙会被用于判断添加到末尾) PixPin_2025-08-12_02-28-19.png

  • 跟随的应该是内部的card
  • 相关事件应该绑定到wrapper上。

总结

由于原生拖拽的缺陷太大,因此直接使用原生拖拽完全没有优势。
推荐使用框架生态的拖拽库来解决拖拽问题,写起来也简单,效果还好。
使用原生的写了一遍坑非常多,一边做一边写记录,导致写的很乱,因为时间跨度比较大。
gitee.com/oldsaltfish…