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

513 阅读10分钟

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

动画1.gif

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

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

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

这时候你可能会想:

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

🤔 第三方库的痛点

  • 🎨 想要炫酷动画? 库:不好意思,我只有基础款

  • 📦 包太大了吧? 为了拖个列表,bundle增加50KB,就像买坦克送外卖

  • 💄 样式打架了 库的CSS和你的UI框架各种冲突,改到怀疑人生

  • 🔧 业务逻辑复杂 想加个权限判断?抱歉,请适配我的API

自己写指令的好处

  • 轻如鸿毛:200行代码搞定,比一张图片还小

  • 🎭 想咋动画咋动画:FLIP、弹跳、渐变,你说了算

  • 🎯 完美契合业务:权限、状态、回调,想怎么玩怎么玩

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

<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>

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

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

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

    1. 绑定 data-id,方便拖拽过程中根据id获取对应的数据(相当于给每个元素贴个身份证)

    2. 添加 app-item 类名,指令通过此类名识别可拖拽元素(就像给能拖的元素贴个"我能拖"的标签)

    3. 然后就没有然后了,坐等拖拽功能自动生效!

🚀 浏览当前vue示例代码完整版可以点击这里 github.com/Teernage/vu…,如果对你有帮助欢迎Star。

在线演示: 示例的指令源码以cdn链接的形式引入

🧩 支持的配置和事件

  • 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'

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

dragend:

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

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

细节:当我们从拖拽一个元素到其他位置放开鼠标,会发现元素不会马上移动到目标位置,而是会出现拖拽效果的快照先飞回元素原来的位置再到目标位置的一个动效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)

🔄 指令更新

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

坑点:快速连续拖拽导致元素消失

细节: 当我们快速连续拖拽时,就会导致元素消失,为什么?

原因:第一次拖拽结束后,数据还没更新,用户又迅速开始了第二次拖拽。第二次拖拽开始之后,这时自定义指令的 updated 才被触发,卸载了旧事件并重新初始化,导致第二次拖拽结束之后找不到拖拽的dom(被初始化了), 所以没法给拖拽元素去除拖拽样式(透明度为0),所以就会导致元素消失了

解决: 在拖拽进行中的时候,阻止指令的更新操作

// 问题:拖拽过程中指令更新会重置状态
async updated(el, binding) {
  if (el._isDragging) return  // 拖拽中不允许更新!
  // ...
}

🚀 浏览当前拖拽指令源代码完整版可以点击这里 github.com/Teernage/vu…,如果对你有帮助欢迎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动效源代码完整版可以点击这里 github.com/Teernage/vu…,如果对你有帮助欢迎Star。

💻 完整自定义指令代码:

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

🎉 总结

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

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