⚡ 一个Vue自定义指令搞定丝滑拖拽列表,告别复杂组件封装

注:本示例在录制过程中受限于录屏设备的帧率和性能,可能导致播放时不够流畅。实际操作时,动画会更加流畅,敬请知悉。

动画图

🚀 浏览项目的完整代码及示例可以点击这里 https://github.com/Teernage/vue3-drag-directive,如果对你有帮助欢迎Star。

快速开始:https://teernage.github.io/vue3-drag-directive/

🌟 前言:为什么不用现成的拖拽库?

你有没有遇到过这种情况:产品经理突然跑过来说"这个列表能不能拖拽排序啊?就像iPhone桌面那样!"

这时候你可能会想:

  • “用Sortable.js吧!” —— 但是包体积20KB+,还要适配Vue
  • “Vue Draggable很成熟啊!” —— 确实成熟,但依赖重,定制性差
  • “Element Plus的拖拽组件…” —— 样式耦合严重,难以定制

🤔 第三方库的痛点

  • 🎨 想要炫酷动画? 库:不好意思,我只有基础款
  • 📦 包太大了吧? 为了拖个列表,bundle增加50KB,就像买坦克送外卖
  • 💄 样式打架了 库的CSS和你的UI框架各种冲突,改到怀疑人生
  • 🔧 业务逻辑复杂 想加个权限判断?抱歉,请适配我的API

自己写指令的好处

  • 轻如鸿毛:几百行代码搞定,比一张图片还小
  • 🎭 想咋动画咋动画:FLIP、弹跳、渐变,你说了算
  • 🎯 完美契合业务:权限、状态、回调,想怎么玩怎么玩

🛠️ 用法像吃泡面一样简单

<script setup>
import { vDragList } from '您指令代码放置的位置' (文章最后会提供源码)
</script>

<template>
      <div
 		 v-drag-list="{ list: dataList, canDrag: true , dragItemClass: 'app-item'}"
 		 @drag-mode-start="onDragModeStart"
 		 @drag-mode-end="onDragModeEnd"
    	>
		  <AddAppItem />
 		 <AppItem v-for="item in dataList" :data-id="item.id" class='app-item' />
	 </div>
</template>

只要两步(比学会用筷子还容易):

  1. 给容器加 v-drag-list,顺手把 list 数据和 canDrag(能不能拖)告诉它。

  2. 每个拖拽元素要做三件事:

    1. 绑定 data-id,方便拖拽过程中根据id获取对应的数据(相当于给每个元素贴个身份证)
    2. 添加 app-item 类名,指令通过此类名识别可拖拽元素(就像给能拖的元素贴个"我能拖"的标签)
    3. 然后就没有然后了,坐等拖拽功能自动生效!

🚀 浏览当前vue示例代码完整版可以点击这里 https://github.com/Teernage/vue3-drag-directive/tree/main/examples/vue-demo,如果对你有帮助欢迎Star。

🧩 支持的配置和事件

  • list:列表数据源,指令帮你排序。
  • canDrag:能不能拖,支持业务自定义(比如搜索时禁止拖拽)。
  • dragItemClass:拖拽元素(如上例子的AppItem)的类名,默认为app-item
  • drag-mode-start:拖拽开始,列表进入“战斗模式”。
  • drag-mode-end:拖拽结束,顺序变了,数据也带给你。

🏃♂️ 拖拽的交互体验

  • 拖起来:鼠标一按,列表项“腾空而起”,跟着鼠标走。
  • 自动让位:拖着拖着,其他小伙伴自动闪开,给你让道。
  • 松手定乾坤:一松鼠标,列表项稳稳落地,顺序自动调整。
  • 数据同步:你不用操心,新的顺序自动传给你。

🧑💻 实现思路揭秘(不怕你笑)

🚀 实现列表元素拖拽的前提

  • 确保列表元素中有draggable属性,这样才能使用拖拽功能
<div draggable="true"></div>
  • 元素需要绑定dragstart、dragenter、dragend三个事件,才可以实现一个拖拽流程

🏗️ 实现形式

自定义指令:为了尽量减少修改原有列表组件,增加组件可维护性,我将实现拖拽的功能逻辑封装成一条自定义指令

事件委托:实现拖拽的功能,本质上就是要每个元素实现拖拽事件,这样才能够实现拖拽功能,但是如果我们直接给所有的元素节点绑定事件的话会导致性能问题,所以我们采用事件委托的方式将事件绑定到元素节点的父元素上(即元素的父容器),子元素通过冒泡的方式来触发拖拽事件

在vue的自定义指令中有一个mounted生命周期函数,在这里可以获取到绑定这条自定义指令的dom元素,因为我们是采用事件委托的方式,所以自定义指令是作用在列表最外层,所以我们获取到的是所有列表元素节点的父节点

然后我们给父节点绑定三个事件、分别是dragstart、dragenter、dragend

🎪 事件处理

dragstart:
坑点1:透明度设置的时机问题

问题现象:当我们拖拽开始的时候,浏览器会生成一个拖拽元素快照跟随鼠标,这就是拖拽效果,我们要实现元素拖拽起来的时候,当前拖拽的元素在列表中消失,所以我们需要在拖拽开始的时候给拖拽元素加上一个透明度为0的样式,但是这个时候会发现连拖拽效果也一起消失了,透明度都为0,为什么?

原因:这是因为浏览器生成元素快照的时机是在 dragstart 事件回调代码执行完成后,但在 dragstart 事件结束之前(像当前事件回调这个宏任务中的一个微任务),如果在dragstart 事件回调中直接就设置透明度,那会导致原来的元素就设置成透明,当拖拽开始回调执行完之后生成快照,这时候的元素快照就是透明的,所以啥也看不见

解决方式:使用setTimeout来实现拖拽元素透明度的设置,因为setTimeout是一个宏任务,会在下一次事件循环中才执行,这样的话浏览器就可以生成快照再应用样式,就可以实现拖拽项从原列表消失,浮起并跟随鼠标

  // ❌ 错误做法:直接设置透明度
element.style.opacity = '0'  // 连拖拽效果都没了!

  function handleDragStart() {
     // ✅ 正确做法:延迟设置
    setTimeout(() => {
      element.style.opacity = '0'  // 完美!
    })
  }
坑点2:文本选择的干扰

问题现象: 当我们选中列表外的一些字体或者元素上的文字进行拖拽的时候,就会导致拖拽功能异常

原因:浏览器对可选中内容(如文字、图片)存在原生拖放行为,当用户点击元素时,浏览器会优先执行默认的文本选中或图片拖拽,导致跟自定义拖拽逻辑冲突。

解决: 在拖拽开始的时候对选中的文字进行去除

function handleDragStart(e) {
    clearSelection()
    ...
}

function clearSelection() {
  const selection = window.getSelection && window.getSelection()
  if (selection && selection.removeAllRanges) {
    selection.removeAllRanges()
  }
}

细节:拖拽时候的鼠标样式

e.dataTransfer.effectAllowed = 'move'
坑点3:嵌套列表拖拽

问题现象:在嵌套列表场景中,当父子列表都应用了拖拽指令时,拖拽子列表的元素会意外触发父列表的拖拽逻辑,导致父列表中的元素也被误操作而消失。

原因:子列表元素的拖拽事件通过DOM事件冒泡机制传播到父列表容器,意外激活了父列表的拖拽处理逻辑。

解决:通过为每个拖拽列表分配唯一标识符,在拖拽开始时验证触发元素的归属关系,确保只有当前列表的直接子元素才能触发拖拽操作

实现方式

  • 为每个拖拽列表生成唯一的列表ID
  • 在拖拽开始时检查触发元素是否属于当前列表
  • 非当前列表元素的拖拽事件将被忽略,避免跨列表干扰
function initDragList(el, data, dragItemClass) {
  // 生成一个唯一的ID,用于标识这个拖拽列表
  const listId = `drag-list-${Date.now()}-${Math.floor(Math.random() * 1000)}`
  // 设置拖拽列表的唯一ID
  el.dataset.dragListId = listId
  
  function handleDragStart(e) {
    ...
    const target = e.target

    if (!target || target.closest(`[data-drag-list-id]`).dataset.dragListId !== listId) {
      return
    }
    ....
  }
}

嵌套列表实现效果图:
注:本示例在录制过程中受限于录屏设备的帧率和性能,可能导致播放时不够流畅。实际操作时,动画会更加流畅,敬请知悉。
请添加图片描述

坑点4:快速连续拖拽时序混乱

问题现象:当用户对同一个元素快速执行多次拖拽操作时,会出现最后一次拖拽完成后,被拖拽的元素突然消失不见的异常现象。

原因:这是由于异步时序问题导致的。在dragStart事件中通常会使用setTimeout来延迟添加拖拽样式,当用户快速连续拖拽时,前一次拖拽的dragEnd事件已经执行完毕(清理了拖拽状态),但前一次dragStart中的延时回调仍在事件队列中等待执行。如果不进行状态检查,这个延时回调会错误地应用拖拽样式,导致元素在视觉上"消失"。

解决方案:在setTimeout的延时回调中增加拖拽状态检查,只有当前拖拽操作仍在进行时才应用拖拽样式,避免过期的异步回调干扰当前状态。

实现方式

function handleDragStart(e) {
  // 设置拖拽状态
  el._isDragging = true
  
  setTimeout(() => {
    // 检查拖拽状态,解决快速连续拖拽的时序问题:
    // 当前一次拖拽已结束(dragEnd已执行)但其dragStart中的延时回调仍在队列中时,
    // 若不检查状态,会错误应用拖拽样式导致元素视觉上"消失"
    if (el._isDragging) {
      dragItem.classList.add(DRAGGING_CLASS)
    }
  })
  
  // ...其他拖拽逻辑
}

function handleDragEnd(e) {
  // 清理拖拽状态
  el._isDragging = false
  dragItem.classList.remove(DRAGGING_CLASS)
  // ...其他清理逻辑
}

核心:通过异步回调状态校验机制,解决了快速连续操作场景下的时序竞态问题,确保拖拽样式的应用和清理严格按照实际拖拽状态进行,避免了元素异常消失的用户体验问题。

dragenter:

这个事件会在拖拽快照移动到其他元素身上的时候触发,我们将在这个事件中完成元素的位置更替

我们在拖拽开始的时候记录正在拖拽元素dom,在enter事件中获取目标元素,然后判断二者在列表中的索引大小,如果拖拽元素的索引小于目标元素的索引,那么需要将拖拽元素插入到目标元素的后面,反之则插入到前面

  function handleDragEnter(e) {
    preventDefault(e)
    
    const target = e.target.closest('.app-item')

    if (!target || target === currentDragNode || target === el) {
      return
    }
    
    const children = Array.from(el.children)
    const sourceIndex = children.indexOf(currentDragNode)
    const targetIndex = children.indexOf(target)

    if (sourceIndex < targetIndex) {
      list.insertBefore(currentDragNode, target.nextElementSibling)
    } else {
      list.insertBefore(currentDragNode, target)
    }
  }
坑点5:跨列表拖拽干扰

问题现象:在嵌套列表环境中,当将一个列表的元素拖拽到另一个列表上方时,目标列表中的元素会意外发生位移排序,这与预期的拖拽行为不符。理想情况下,拖拽操作应当仅在源列表内生效,不应影响其他列表的元素排列。

原因:内层列表元素绑定了dragenter事件,而内层列表容器作为外层列表的子元素,外层列表同样监听了dragenter事件。当拖拽内层元素经过其他列表区域时,会同时触发目标列表及其所有子列表的dragenter事件处理器,造成多个列表同时响应拖拽操作,导致拖拽行为失控。

解决方案:通过全局拖拽状态管理,记录当前正在进行拖拽操作的列表ID,在其他列表的事件处理函数中进行拖拽来源校验,确保只有拖拽源列表能够响应拖拽事件。

function handleDragEnter(e) {
  preventDefault(e)

  // 如果有拖拽正在进行,并且不是当前列表,则忽略
  if (currentDraggingListId && currentDraggingListId !== listId) {
    return
  }
  
  // 继续处理当前列表内的拖拽逻辑
  // ...
}

核心思路:利用全局拖拽状态标识符(currentDraggingListId)实现拖拽操作的独占性控制,确保在多列表场景下,只有发起拖拽的源列表能够处理拖拽事件,其他列表自动忽略,从而避免跨列表干扰问题。

dragend:

这是一个拖拽操作的最后一环节,这时候我们获取拖拽结束后的列表数据

坑点6:默认事件的"捣乱

问题现象:当我们从拖拽一个元素到其他位置放开鼠标,会发现元素不会马上移动到目标位置,而是会出现拖拽效果的快照先飞回元素原来的位置再到目标位置的一个动效bug,为什么会这样?

原因:这是因为浏览器的元素默认不允许其他元素拖拽到自身,如果我们拖拽到其他元素身上,那么就会让我们"先回去"的样式即飞回去,然后再到目标位置(因为dom顺序改了,所以最终还是会到目标位置)。

解决: 取消默认事件,不仅要取消列表上的dragenter和dragend事件中的默认事件,还要取消全局dragenter和dragend的默认事件。

  function preventDefault(e) {
    e.preventDefault()
  }
  
  function handleDragEnter(e) {
     preventDefault(e)
  }
  
  function handleDragEnter(e) {
     preventDefault(e)
  }
  
 window.removeEventListener('dragenter', this.preventDefault)
 window.removeEventListener('dragover', this.preventDefault)
 window.removeEventListener('dragend', this.preventDefault)
坑点7:拖拽手柄实现中的事件混淆陷阱

问题现象

  1. 手柄快照问题

当配置了拖拽手柄(如 dragHandleClass),拖拽时如果事件目标是手柄本身,只有手柄的 DOM 快照会跟随鼠标,而整个拖拽项变透明或消失,导致拖拽体验异常。

  1. 嵌套列表手柄强制问题

在嵌套列表中,父级(第一层)配置了手柄拖拽,子级(第二层)未配置手柄,本应可直接拖拽子级。但实际上,子级也被强制只能通过手柄拖拽,导致子列表拖拽受限。

原因

  • 事件目标混淆
    • mousedowndragstart 事件中的 e.target 含义不同:

    • mousedown 的 e.target 是用户实际点击的元素(可准确判断是否为手柄)。

    • dragstart 的 e.target 是被设置了 draggable=“true” 的元素,与实际点击位置无关。

    • 误区:如果把 draggable=“true” 设置在手柄元素上,dragstart 只会拖动手柄本身,导致拖拽项消失,只剩手柄快照。

// ❌ 错误理解
function handleDragStart(e) {
  // e.target 这里是 draggable 元素,不是点击的手柄!
  if (!e.target.classList.contains('drag-handle')) {
    return; // 这个判断是错误的
  }
}

// ✅ 正确理解  
function handleMouseDown(e) {
  // e.target 这里才是真实点击的元素
  if (!e.target.classList.contains('drag-handle')) {
    e.preventDefault();
    return false;
  }
}

  • 事件冒泡与父子层级混乱

    • 事件冒泡:父级的 mousedown 事件会捕获所有子元素的点击。
    • 父级的手柄判断逻辑会阻止所有非手柄的拖拽操作,即使这些操作发生在子级列表内。
    • 没有区分事件目标属于哪一层,导致父级“越权”影响子级。

解决:

  1. 手柄快照问题
  • 应确保设置 draggable=“true” 的是整个拖拽项,而不是拖拽项中的手柄
  • 通过 mousedown事件判断当前点击的是否为拖拽手柄,从而来判断是否可以拖拽。
  1. 嵌套列表手柄强制问题
  • 在父级事件处理时,增加层级判断:只有当事件目标是父级列表的直接子元素时,才进行手柄校验。
  • 具体做法:判断 dragItem.parentElement === el,这样父级只管理自己那一层,子级互不干扰。

自定义拖拽手柄示例:https://teernage.github.io/vue3-drag-directive/guide/advanced-usage.html

🔄 指令更新

当列表数据更新的时候会触发update生命周期函数,在这里进行旧事件的销毁,事件的重新注册

🚀 浏览当前拖拽指令源代码完整版可以点击这里 https://github.com/Teernage/vue3-drag-directive/blob/main/src/directive.ts,如果对你有帮助欢迎Star。

🎬 元素列表结构的动画处理:FLIP

采用的是flip动画思想:设置改变列表结构的动画

  • First:元素初始时的具体信息
  • Last:元素结束时的位置信息
  • Invert:倒置。虽然元素到了结束时的节点位置,但是视觉上我们并没有看到,此时要设计让元素动画从 First 通过动画的方式变换到 Last,刚好我们又记录了动画的开始和结束信息,因此我们可以利用自己熟悉的动画方式来完成 Invert
  • Play:动画开始执行。在代码上通常 Invert 表示传参,Play 表示具体的动画执行。

First的记录时机:给列表注册事件的时候记录每个dom的初始位置 Last的记录时机:在enter事件的时候中记录整个列表中所有dom的位置 Invert执行时机在记录last之后:所有dom的first起始位置和最后的位置相减得到dis值,给每个dom赋值上

dom.style.transform = `translate(${deltaX}px, ${deltaY}px)`

之后,所有的位置都会回到fist初始位置

Play执行时机在invert的下一帧,让所有dom设置上

this.dom.style.transition = `transform ${this.durationTime}`
this.dom.style.transform = 'none'

这样所有的dom就会从上一帧的初始位置在this.durationTime时间内运动到目标位置。

FLIP 动画的核心是:虽然 DOM 结构的变化(如元素插入到列表末尾)是即时完成的,但通过在不同渲染帧中处理视觉效果(先用 transform 保持视觉位置,再移除 transform 产生动画),让浏览器能渲染出元素从原位置到新位置的平滑过渡效果,这就是FLIP (First-Last-Invert-Play) 技术。

注意点:在进行FLIP动画时,要对非拖拽元素(即正在执行FLIP动画的元素)设置 pointer-events: none(即不能响应事件)。这样可以防止在拖拽过程中,其他运动中的元素在正在拖拽元素下方移动,从而触发 dragenter、dragover 等拖拽事件。这种触发可能导致动画效果的重新播放,从而引发卡顿现象。通过禁用这些元素的事件响应,可以提升拖拽动画的流畅性。

🚀 浏览当前flip动效源代码完整版可以点击这里 https://github.com/Teernage/vue3-drag-directive/blob/main/src/util/flip.ts,如果对你有帮助欢迎Star。

💻 完整自定义指令代码:

🚀 浏览项目的完整代码及示例可以点击这里 https://github.com/Teernage/vue3-drag-directive,如果对你有帮助欢迎Star。

🎉 总结

这个自定义拖拽指令就像一个贴心的小助手:

  • 📦 轻量级:几百行代码搞定
  • 🎨 可定制:想要什么动画效果,随你折腾
  • 🚀 高性能:事件委托 + FLIP动画,丝滑如德芙
  • 🛡️ 稳定性:各种边界情况都考虑到了,不会"掉链子
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值