Code前端首页关于Code前端联系我们

Vue3 Draggable怎么实现拖拽排序、跨组件、虚拟列表三大场景?常见报错怎么解决?

terry 9小时前 阅读数 238 #Vue

日常开发电商后台商品列表、后台菜单配置、表单编辑器这类项目时,拖拽交互绝对是刚需——让用户自己调整商品展示顺序、菜单层级、表单字段位置,比一个个点上下箭头快太多了,Vue2的时候大家基本都用vuedraggable,现在Vue3成为主流后,官方的vue-draggable-plus直接衔接得很好,很多人上手却踩了不少坑:比如拖拽后数据没同步、跨盒子拖不过去、虚拟列表拖到下面白屏?

今天就把大家问得最多的这三个场景和Top3报错理清楚,一步步带你写代码,最后再给几个优化小技巧,保证看完就能用到项目里。

Vue3 Draggable快速入门:先跑通最基础的拖拽排序

很多新手刚上手会直接复制文档示例,却漏了关键的Vue3特性适配——比如要注意ref/reactive响应式数据、key值绑定的细节,先别搞复杂的,先跑通一个纯数组的商品列表拖拽排序,把基础逻辑摸透。

核心前置条件

你得搞清楚现在的vue-draggable-plus和Vue2版的区别:

  1. Vue2版依赖Sortable.js,Vue3版vue-draggable-plus是完全重写但兼容Sortable.js插件和事件的,包体积小了一圈,而且支持Vue3的Composition API和Teleport、Suspense这些新组件。
  2. 不管是用npm/yarn/pnpm,都要装vue-draggable-plus这个包,不是之前的vuedraggable了——之前的包只兼容Vue2,强行装在Vue3项目里会报一堆错。

纯数组商品列表拖拽排序代码

直接上可运行的代码片段,用的是Composition API+script setup,现在90%以上的Vue3项目都是这个写法:

<template>
  <div class="product-container">
    <h3>商品拖拽排序区</h3>
    <!-- draggable组件的v-model直接绑定响应式数组就行,不用像之前那样手动改 -->
    <draggable
      v-model="productList"
      item-key="id"
      class="product-list"
      ghost-class="ghost"
      chosen-class="chosen"
      drag-class="drag"
    >
      <!-- draggable的插槽默认是item,不需要再写v-for了,组件会自动遍历 -->
      <template #item="{ element }">
        <div class="product-item">
          <img :src="element.img" alt="element.name" />
          <p>{{ element.name }}</p>
          <span>¥{{ element.price }}</span>
        </div>
      </template>
    </draggable>
  </div>
</template>
<script setup>
// 导入DraggablePlus组件,注意名字可以自己改,比如写成Draggable
import { ref } from 'vue'
import { DraggablePlus as Draggable } from 'vue-draggable-plus'
// 响应式商品数据,必须有唯一的item-key,不然拖拽后组件复用会乱
const productList = ref([
  { id: 1, name: '无线蓝牙耳机', price: 299, img: 'https://picsum.photos/60/60?random=1' },
  { id: 2, name: '机械键盘青轴', price: 599, img: 'https://picsum.photos/60/60?random=2' },
  { id: 3, name: '便携充电宝20000mAh', price: 129, img: 'https://picsum.photos/60/60?random=3' },
  { id: 4, name: '智能手环7代', price: 199, img: 'https://picsum.photos/60/60?random=4' },
  { id: 5, name: 'Type-C快充线', price: 39, img: 'https://picsum.photos/60/60?random=5' },
])
</script>
<style scoped>
.product-container {
  width: 400px;
  margin: 20px auto;
}
.product-list {
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 0;
  list-style: none;
}
.product-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px;
  border: 1px solid #eee;
  border-radius: 8px;
  background: white;
  cursor: grab; /* 默认鼠标悬停是抓取手势 */
}
.product-item img {
  width: 60px;
  height: 60px;
  border-radius: 4px;
  object-fit: cover;
}
/* 拖拽过程中被拖拽元素的样式 */
.drag {
  opacity: 0.5;
  cursor: grabbing; /* 拖拽中是握紧手势 */
}
/* 选中元素但还没开始拖拽的样式 */
.chosen {
  background: #f0f9ff;
  border-color: #60a5fa;
}
/* 拖拽占位符的样式,很重要!能让用户知道拖到哪里了 */
.ghost {
  opacity: 0.3;
  background: #ddd;
  border: 2px dashed #999;
}
</style>

这个基础版里的几个细节别踩坑

第一个细节就是item-key,刚才代码里注释说了,必须绑定数组元素的唯一标识(比如id),不能用index——如果用index,当你把最后一个元素拖到第一个,原来的index会被打乱,Vue3的虚拟DOM复用机制会出错,导致图片或者文字显示错位,甚至有些元素直接消失。

第二个细节是插槽写法,Vue2版的vuedraggable是要求你自己在draggable组件里写v-for遍历数组的,Vue3版vue-draggable-plus默认提供了#item插槽,直接传element就行,这样代码更简洁,而且组件内部会自动处理v-for的绑定问题,减少出错概率。

第三个细节是CSS样式,特别是ghost-class的样式,很多新手会忘了加,导致拖拽时看不到占位符,用户体验很差;还有chosen-class和drag-class,加上之后能给用户明确的反馈,知道自己选中了哪个、正在拖哪个。

进阶第一关:实现两个容器之间的跨组件拖拽

刚才的基础版是单个容器内的拖拽排序,电商后台经常需要把“待上架商品”拖到“已上架商品区”,或者把“菜单组件库”里的组件拖到“菜单配置区”,这就是跨容器(跨组件)拖拽了。

跨组件拖拽的核心是给两个draggable组件绑定相同的group属性,group属性可以是字符串,也可以是对象——字符串的话,只要两个组名相同就能互相拖;对象的话,可以设置更细的权限,比如只允许A拖到B,不允许B拖到A。

先实现互相拖拽的待上架/已上架商品区

同样用Composition API+script setup,代码比基础版稍微多一点,但逻辑很清晰:

<template>
  <div class="drag-wrapper">
    <div class="drag-box">
      <h3>待上架商品区(共{{ pendingList.length }}件)</h3>
      <draggable
        v-model="pendingList"
        item-key="id"
        class="product-list"
        ghost-class="ghost"
        chosen-class="chosen"
        drag-class="drag"
        group="product"
      >
        <template #item="{ element }">
          <div class="product-item">
            <img :src="element.img" alt="element.name" />
            <p>{{ element.name }}</p>
            <span>¥{{ element.price }}</span>
          </div>
        </template>
      </draggable>
    </div>
    <div class="drag-box">
      <h3>已上架商品区(共{{ onSaleList.length }}件)</h3>
      <draggable
        v-model="onSaleList"
        item-key="id"
        class="product-list"
        ghost-class="ghost"
        chosen-class="chosen"
        drag-class="drag"
        group="product"
      >
        <template #item="{ element }">
          <div class="product-item">
            <img :src="element.img" alt="element.name" />
            <p>{{ element.name }}</p>
            <span>¥{{ element.price }}</span>
          </div>
        </template>
      </draggable>
    </div>
  </div>
</template>
<script setup>
import { ref } from 'vue'
import { DraggablePlus as Draggable } from 'vue-draggable-plus'
// 待上架和已上架商品数据,用ref是因为后面会被直接修改
const pendingList = ref([
  { id: 1, name: '无线蓝牙耳机', price: 299, img: 'https://picsum.photos/60/60?random=1' },
  { id: 2, name: '机械键盘青轴', price: 599, img: 'https://picsum.photos/60/60?random=2' },
  { id: 3, name: '便携充电宝20000mAh', price: 129, img: 'https://picsum.photos/60/60?random=3' },
])
const onSaleList = ref([
  { id: 4, name: '智能手环7代', price: 199, img: 'https://picsum.photos/60/60?random=4' },
  { id: 5, name: 'Type-C快充线', price: 39, img: 'https://picsum.photos/60/60?random=5' },
])
</script>
<style scoped>
.drag-wrapper {
  display: flex;
  gap: 40px;
  width: 800px;
  margin: 20px auto;
}
.drag-box {
  flex: 1;
  padding: 16px;
  border: 1px solid #eee;
  border-radius: 12px;
  background: #f9fafb;
}
.product-list {
  min-height: 300px; /* 给最小高度,不然没元素的时候拖不进去 */
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 0;
  list-style: none;
}
.product-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px;
  border: 1px solid #eee;
  border-radius: 8px;
  background: white;
  cursor: grab;
}
.product-item img {
  width: 60px;
  height: 60px;
  border-radius: 4px;
  object-fit: cover;
}
.drag {
  opacity: 0.5;
  cursor: grabbing;
}
.chosen {
  background: #f0f9ff;
  border-color: #60a5fa;
}
.ghost {
  opacity: 0.3;
  background: #ddd;
  border: 2px dashed #999;
}
</style>

跨组件拖拽的两个关键优化

第一个优化是给每个drag-list加最小高度,刚才代码里也写了——如果待上架商品区是空的,没有最小高度的话,用户根本找不到拖拽的“入口”,拖过去也放不进去,加个300px左右的最小高度,哪怕是空的也能看到灰色背景的区域,体验会好很多。

第二个优化是设置group的权限属性,刚才用的是字符串group="product",是双向允许的;如果是“菜单组件库”和“菜单配置区”,应该只允许从组件库拖到配置区,不允许反过来拖,这时候就可以把group改成对象:

// 待上架商品区(组件库)的group设置
group: {
  name: 'product',
  pull: 'clone', // pull可以设置为true/false/'clone','clone'是拖过去复制一个,原来的保留;true是拖过去移动
  put: false, // put设置为false,就不允许其他组的元素拖进来
}
// 已上架商品区(配置区)的group设置
group: {
  name: 'product',
  pull: true,
  put: true,
}

这里的pull='clone'很有用,比如表单编辑器里,用户可能需要多次使用同一个“输入框”组件,这时候拖过去应该是复制一个,而不是把组件库的输入框移走。

进阶第二关:对接Vue3虚拟列表,解决大列表拖拽卡顿

刚才的两个场景,列表数据都是十几二十条,没问题;但如果是电商后台的商品库,有几千几万条数据,直接用draggable的话,页面会渲染所有DOM节点,拖拽时肯定会卡顿得不行——这时候就得对接Vue3的虚拟列表了,只渲染可视区域内的几十个DOM节点,其他的在滚动时动态替换。

Vue3生态里比较好用的虚拟列表有两个:一个是官方VueUse库的useVirtualList,另一个是独立的vue-virtual-scroller(不过vue-virtual-scroller的Vue3版维护得好像不如VueUse的勤),今天就用VueUse的useVirtualList+vue-draggable-plus来实现大列表的流畅拖拽。

核心前置条件

除了之前装的vue-draggable-plus,还要装@vueuse/core这个包——VueUse是Vue3官方推荐的组合式API工具库,里面有很多实用的函数,useVirtualList就是其中之一,处理虚拟列表的逻辑非常成熟。

大列表虚拟拖拽排序代码

代码稍微复杂一点,但核心逻辑还是不变的,只是要把draggable的DOM和虚拟列表的DOM结合起来:

<template>
  <div class="virtual-container">
    <h3>10000条商品虚拟拖拽排序区</h3>
    <!-- 虚拟列表的容器,必须设置固定高度或者max-height+overflow:auto -->
    <div class="virtual-wrapper" ref="wrapperRef">
      <!-- 虚拟列表的占位元素,用来撑开整个容器的高度,模拟滚动条 -->
      <div :style="{ height: `${totalHeight}px` }">
        <!-- 虚拟列表的可视区域容器,position: absolute,top: ${offset}px,用来定位可视区域内的元素 -->
        <div :style="{ transform: `translateY(${offset}px)` }">
          <!-- draggable组件绑定itemKeys,不是直接绑定整个数组,因为虚拟列表只渲染可视区域内的元素 -->
          <draggable
            v-model="visibleList"
            item-key="id"
            class="product-list"
            ghost-class="ghost"
            chosen-class="chosen"
            drag-class="drag"
            :scroll-container="wrapperRef"
            @end="handleDragEnd"
          >
            <template #item="{ element, index }">
              <!-- 这里的index是虚拟列表可视区域内的索引,不是整个数组的索引,需要用getIndex(element)获取真实索引 -->
              <div class="product-item">
                <span class="product-index">{{ getIndex(element) + 1 }}</span>
                <img :src="element.img" alt="element.name" />
                <p>{{ element.name }}</p>
                <span>¥{{ element.price }}</span>
              </div>
            </template>
          </draggable>
        </div>
      </div>
    </div>
  </div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { DraggablePlus as Draggable } from 'vue-draggable-plus'
import { useVirtualList } from '@vueuse/core'
// 模拟10000条商品数据
const generateProducts = (count) => {
  const products = []
  for (let i = 0; i < count; i++) {
    products.push({
      id: i + 1,
      name: `商品${i + 1}`,
      price: Math.floor(Math.random() * 1000) + 10,
      img: `https://picsum.photos/60/60?random=${i + 1}`,
    })
  }
  return products
}
const allProducts = ref(generateProducts(10000))
// 初始化虚拟列表
// wrapperRef是虚拟列表容器的ref
// visibleList是虚拟列表可视区域内的元素数组
// totalHeight是整个虚拟列表的总高度
// offset是可视区域容器的top偏移量
// getIndex是通过元素获取真实索引的函数
// setIndex是通过元素设置真实索引的函数(拖拽后需要用到)
const [wrapperRef, { visibleList, totalHeight, offset, getIndex, setIndex }] = useVirtualList(
  allProducts,
  {
    itemHeight: 80, // 每个商品项的高度,必须固定,不然虚拟列表计算会出错
    overscan: 5, // 可视区域外上下各预渲染5个元素,减少滚动时的白屏
  }
)
// 拖拽结束后的处理函数,核心是同步真实数组的顺序
const handleDragEnd = (event) => {
  const { oldIndex, newIndex, item } = event // 这里的oldIndex和newIndex是虚拟列表可视区域内的索引,不能直接用
  const realOldIndex = getIndex(item) // 获取真实旧索引
  const realNewIndex = getIndex(visibleList.value[newIndex]) // 获取真实新索引(通过可视区域内新位置的元素)
  // 同步真实数组allProducts的顺序
  const [movedItem] = allProducts.value.splice(realOldIndex, 1)
  allProducts.value.splice(realNewIndex, 0, movedItem)
  // 重新设置虚拟列表的索引映射,不然下次拖拽getIndex会出错
  setIndex(item, realNewIndex)
  // 这里注意一下,如果是用splice修改了allProducts,useVirtualList会自动更新visibleList和totalHeight吗?
  // 答案是会的,因为useVirtualList内部监听了allProducts的变化,所以不用手动更新
}
</script>
<style scoped>
.virtual-container {
  width: 500px;
  margin: 20px auto;
}
.virtual-wrapper {
  width: 100%;
  height: 600px; /* 虚拟列表容器必须有固定高度或者max-height+overflow:auto */
  overflow-y: auto;
  border: 1px solid #eee;
  border-radius: 12px;
  background: #f9fafb;
}
.product-list {
  display: flex;
  flex-direction: column;
  gap: 0; /* 虚拟列表的每个元素高度固定,所以gap要设为0,不然总高度计算会出错 */
  padding: 0;
  list-style: none;
}
.product-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px;
  height: 80px; /* 必须和useVirtualList的itemHeight一致 */
  box-sizing: border-box; /* 必须加,不然padding会影响总高度 */
  border-bottom: 1px solid #eee;
  background: white;
  cursor: grab;
}
.product-index {
  width: 40px;
  text-align: center;
  font-weight: bold;
  color: #666;
}
.product-item img {
  width: 60px;
  height: 60px;
  border-radius: 4px;
  object-fit: cover;
}
.drag {
  opacity: 0.5;
  cursor: grabbing;
  z-index: 999; /* 拖拽时要设置z-index,不然会被其他元素挡住 */
}
.chosen {
  background: #f0f9ff;
  border-color: #60a5fa;
}
.ghost {
  opacity: 0.3;
  background: #ddd;
}
</style>

虚拟列表拖拽的三个“死规则”,破一个就会出问题

第一个死规则是虚拟列表的每个元素高度必须固定——useVirtualList是通过“元素高度×元素数量”来计算总高度的,如果元素高度不固定,总高度就会算错,滚动条也会跟着乱跳,甚至可视区域内的元素会显示不全或者重叠,如果你的元素高度必须不固定,那可以用vue-virtual-scroller的Vue3版,它支持动态高度,但性能会比固定高度的差一些。

第二个死规则是draggable组件要绑定scroll-container属性——如果不绑定,draggable默认会监听window的滚动,而虚拟列表是在自己的容器里滚动的,所以拖拽到容器边缘时不会自动滚动,体验很差;绑定wrapperRef之后,draggable就会监听虚拟列表容器的滚动,拖拽到上/下边缘时会自动向上/下滚动。

第三个死规则是拖拽结束后必须同步真实数组的顺序,并且重新设置索引映射——刚才代码里的handleDragEnd函数就是干这个的,因为visibleList只是真实数组的一个“切片”,只修改visibleList的话,真实数组allProducts的顺序不会变,下次滚动或者刷新页面就会恢复原样;而且getIndex函数是基于拖拽前的索引映射来的,如果不同步真实数组和重新设置索引映射,下次拖拽getIndex会返回错误的索引。

踩坑总结:Vue3 Draggable最常见的3个报错及解决方案

讲完三大场景,再说说大家问得最多的3个报错,都是我自己在项目里踩过的,或者在技术社区看到别人问得最多的。

报错1:v-model绑定后,拖拽数据没同步

这个报错是新手最容易踩的,原因大概有三个:

  1. v-model绑定的不是响应式数据——比如你直接绑定了一个普通数组const productList = [...],而不是ref或者reactive包裹的数组,Vue3监听不到数据的变化,所以拖拽后界面不会更新,解决方案就是把普通数组改成ref或者reactive包裹的响应式数组。
  2. item-key绑定了index——刚才基础版里已经说过了,绑定index会导致Vue3的虚拟DOM复用机制出错,数据看起来没同步,但其实是DOM显示错了,解决方案就是绑定数组元素的唯一标识(比如id)。
  3. 用了Vue2版的vuedraggable——之前的vuedraggable只兼容Vue2,强行装在Vue3项目里,v-model绑定会失效,解决方案就是卸载vuedraggable,安装vue-draggable-plus。

报错2:跨容器拖拽放不进去

这个报错的原因大概有两个:

  1. 两个draggable组件的group属性不一样——跨容器拖拽的核心是group属性相同,不一样的话肯定放不进去,解决方案就是检查两个组件的group属性,改成一样的字符串或者包含相同name的对象。
  2. 目标容器的drag-list没有最小高度——刚才跨组件拖拽的优化里也说过了,没有最小高度的话,目标容器是空的就找不到拖拽入口,解决方案就是给目标容器的drag-list加一个合适的最小高度。

报错3:虚拟列表拖拽后,可视区域外的元素顺序乱了

这个报错的原因就是刚才虚拟列表拖拽的第三个死规则——没有同步真实数组的顺序,或者没有重新设置索引映射,解决方案就是严格按照刚才代码里的handleDragEnd函数来写:先通过getIndex获取真实旧索引和真实新索引,然后splice修改真实数组,最后setIndex重新设置索引映射。

最后再给几个实用的优化小技巧

技巧1:只允许拖拽特定的区域

比如商品列表里,你可能希望用户只能拖拽商品的“拖拽图标”,不能拖拽整个商品项,这时候就可以用draggable组件的handle属性:

<!-- 给商品项加一个拖拽图标 -->
<div class="product-item">
  <span class="drag-handle">☰</span>
  <!-- 其他内容 -->
</div>
<!-- draggable组件的handle属性绑定拖拽图标的class -->
<draggable
  v-model="productList"
  item-key="id"
  handle=".drag-handle"
  <!-- 其他属性 -->
>
</draggable>

技巧2:禁用某些元素的拖拽

比如商品列表里,有些商品是“固定置顶”的,不能拖拽,这时候就可以用draggable组件的disabled属性,或者给元素加draggable="false"属性:

// 方法1:给固定置顶的元素加disabled字段,draggable组件的disabled属性绑定数组的filter
// 不过这个方法比较麻烦,推荐方法2
// 方法2:给固定置顶的元素加draggable="false"和no-drag的class
<template #item="{ element }">
  <div class="product-item" :class="{ 'no-drag': element.isTop }" :draggable="!element.isTop">
    <!-- 其他内容 -->
  </div>
</template>
<!-- 同时给no-drag的class加pointer-events: none -->
<style scoped>
.no-drag {
  cursor: not-allowed;
  pointer-events: none;
}
</style>

技巧3:拖拽时保存数据到localStorage

比如后台菜单配置,用户调整完菜单顺序后,刷新页面应该保留调整后的顺序,这时候就可以在draggable组件的@end事件里保存数据到localStorage:

// 拖拽结束后保存数据到localStorage
const handleDragEnd = () => {
  localStorage.setItem('menuList', JSON.stringify(menuList.value))
}
// 组件初始化时从localStorage读取数据
onMounted(() => {
  const savedMenuList = localStorage.getItem('menuList')
  if (savedMenuList) {
    menuList.value = JSON.parse(savedMenuList)
  }
})

好了,今天关于Vue3 Draggable的三大场景、三大报错、三个优化小技巧就讲到这里,这些都是我自己在项目里反复验证过的,保证看完就能用到,如果还有其他问题,欢迎在评论区留言讨论。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门