基于 Vue 和 vuedraggable 实现优雅的拖拽列表组件
·
在现代 Web 应用中,拖拽交互已经成为提升用户体验的重要方式。无论是任务管理、列表排序还是元素归类,拖拽功能都能让操作更加直观高效。本文将详细介绍如何使用 Vue 结合 vuedraggable 插件,构建一个功能完善、交互流畅的双向拖拽列表组件,并深入解析其实现原理。
组件功能概述
我们实现的DndList组件(Drag-and-Drop List)是一个双向拖拽列表,具备以下核心功能:
- 左右两个列表区域支持元素的跨列表拖拽
- 列表项支持点击按钮快速转移(左侧列表项可点击删除按钮移至右侧,右侧列表项点击内容可移至左侧)
- 拖拽或点击转移后实时同步列表状态并通知父组件
- 空列表状态提示,提升用户体验
- 丰富的视觉反馈(拖拽中样式变化、悬停效果等)
实现方案详解
技术选型
本组件基于 Vue 框架开发,主要依赖:
- vuedraggable:基于 Sortable.js 的 Vue 拖拽组件,提供了丰富的拖拽相关 API
- SCSS:用于编写组件样式,支持嵌套规则和变量,使样式结构更清晰
组件结构设计
组件采用左右分栏布局,每个栏目包含标题和拖拽区域两部分。下面是模板结构的关键设计:
<div class="dndList">
<!-- 左侧列表 -->
<div :style="{ width: width1 }" class="dndList-list">
<h3>{{ list1Title }}</h3>
<draggable
:set-data="setData"
:list="list1"
group="article"
class="dragArea"
@end="handleDragEnd"
>
<!-- 列表项内容 -->
</draggable>
</div>
<!-- 右侧列表 -->
<div :style="{ width: width2 }" class="dndList-list">
<!-- 结构与左侧类似 -->
</div>
</div>
核心功能实现
1. 拖拽功能配置
vuedraggable 的核心配置项:
list:绑定的数据源数组group:拖拽组名称,同组内的列表可以相互拖拽@end:拖拽结束时的回调函数set-data:自定义拖拽数据的方法
// 设置拖拽数据,避免默认行为干扰
setData(dataTransfer) {
dataTransfer.setData('Text', '')
},
// 拖拽结束后通知父组件
handleDragEnd() {
this.emitListChange()
}
2. 元素转移逻辑
除了拖拽,组件还提供了点击转移的功能,核心方法:
// 从列表1删除元素并添加到列表2
deleteEle(element) {
const index = this.list1.findIndex((item) => item.id === element.id)
if (index !== -1) {
this.list1.splice(index, 1)
// 确保元素不存在于列表2中才添加,避免重复
if (this.isNotInList2(element)) {
this.list2.unshift(element)
}
this.emitListChange()
}
},
// 从列表2删除元素并添加到列表1
pushEle(element) {
// 实现逻辑与deleteEle类似,方向相反
}
3. 状态同步机制
组件通过自定义事件list-change向父组件同步最新的列表状态:
emitListChange() {
this.$emit('list-change', {
list1: [...this.list1],
list2: [...this.list2]
})
}
使用扩展运算符 ([...this.list1]) 创建数组副本,确保父组件能正确检测到数组变化。
样式设计亮点
组件的样式设计注重用户体验和视觉反馈:
-
拖拽状态反馈:
- 拖拽中的元素使用
is-dragging类添加特殊样式 - 拖拽过程中通过
sortable-chosen和sortable-ghost类提供视觉指引
- 拖拽中的元素使用
-
交互反馈:
- 列表项悬停时添加边框高亮和阴影效果
- 删除按钮 hover 时变色,增强可点击感
-
空状态处理:
- 当列表为空时显示友好提示,引导用户操作
- 使用虚线边框和浅色调区分空状态区域
.list-complete-item {
&:hover {
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
&.is-dragging {
background-color: #f0f7ff;
border-color: #99d1ff;
}
}
整体代码实现
<template>
<div class="dndList">
<div :style="{ width: width1 }" class="dndList-list">
<h3>{{ list1Title }}</h3>
<draggable
:set-data="setData"
:list="list1"
group="article"
class="dragArea"
@end="handleDragEnd"
>
<div
v-for="element in list1"
:key="element.id"
class="list-complete-item"
:class="{ 'is-dragging': element.isDragging }"
>
<div class="list-complete-item-handle">
{{ element.id }}[{{ element.author }}] {{ element.title }}
</div>
<div class="delete-btn">
<i
@click="deleteEle(element)"
class="el-icon-delete"
title="移至右侧列表"
/>
</div>
</div>
<div v-if="list1.length === 0" class="empty-state">
列表为空,可从右侧拖拽或点击元素添加
</div>
</draggable>
</div>
<div :style="{ width: width2 }" class="dndList-list">
<h3>{{ list2Title }}</h3>
<draggable
:list="list2"
group="article"
class="dragArea"
@end="handleDragEnd"
>
<div
v-for="element in list2"
:key="element.id"
class="list-complete-item"
:class="{ 'is-dragging': element.isDragging }"
>
<div
class="list-complete-item-handle2"
@click="pushEle(element)"
title="移至左侧列表"
>
{{ element.id }} [{{ element.author }}] {{ element.title }}
</div>
</div>
<div v-if="list2.length === 0" class="empty-state">
列表为空,可从左侧拖拽或点击元素添加
</div>
</draggable>
</div>
</div>
</template>
<script>
import draggable from 'vuedraggable'
export default {
name: 'DndList',
components: { draggable },
props: {
list1: {
type: Array,
default: () => []
},
list2: {
type: Array,
default: () => []
},
list1Title: {
type: String,
default: '列表1'
},
list2Title: {
type: String,
default: '列表2'
},
width1: {
type: String,
default: '48%'
},
width2: {
type: String,
default: '48%'
}
},
methods: {
// 检查元素是否不在列表1中
isNotInList1(element) {
return !this.list1.some((item) => item.id === element.id)
},
// 检查元素是否不在列表2中
isNotInList2(element) {
return !this.list2.some((item) => item.id === element.id)
},
// 从列表1删除元素并添加到列表2
deleteEle(element) {
const index = this.list1.findIndex((item) => item.id === element.id)
if (index !== -1) {
this.list1.splice(index, 1)
if (this.isNotInList2(element)) {
this.list2.unshift(element)
}
this.emitListChange()
}
},
// 从列表2删除元素并添加到列表1
pushEle(element) {
const index = this.list2.findIndex((item) => item.id === element.id)
if (index !== -1) {
this.list2.splice(index, 1)
if (this.isNotInList1(element)) {
this.list1.push(element)
}
this.emitListChange()
}
},
// 设置拖拽数据
setData(dataTransfer) {
dataTransfer.setData('Text', '')
},
// 拖拽结束处理
handleDragEnd() {
this.emitListChange()
},
// 向父组件发射列表变化事件
emitListChange() {
this.$emit('list-change', {
list1: [...this.list1],
list2: [...this.list2]
})
}
}
}
</script>
<style lang="scss" scoped>
.dndList {
background: #fff;
padding: 20px;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
&:after {
content: "";
display: table;
clear: both;
}
.dndList-list {
float: left;
padding: 15px;
background-color: #f5f7fa;
border-radius: 4px;
&:first-of-type {
margin-right: 2%;
}
h3 {
margin: 0 0 15px;
padding-bottom: 10px;
border-bottom: 1px solid #e4e7ed;
font-size: 16px;
color: #1f2d3d;
}
.dragArea {
margin-top: 15px;
min-height: 100px;
padding-bottom: 30px;
}
}
}
.list-complete-item {
cursor: pointer;
position: relative;
font-size: 14px;
padding: 10px 12px;
margin-top: 6px;
border: 1px solid #bfcbd9;
border-radius: 4px;
background-color: #fff;
transition: all 0.3s ease;
&:hover {
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
&.is-dragging {
background-color: #f0f7ff;
border-color: #99d1ff;
}
}
.list-complete-item-handle,
.list-complete-item-handle2 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.list-complete-item-handle {
margin-right: 50px;
}
.delete-btn {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
color: #ff4949;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: #f5222d;
}
}
.empty-state {
text-align: center;
padding: 30px 10px;
color: #8392a5;
font-size: 14px;
border: 1px dashed #c0ccda;
border-radius: 4px;
background-color: #fafafa;
}
// 拖拽相关动画
.list-complete-item.sortable-chosen {
background: #e6f7ff;
border-color: #91d5ff;
}
.list-complete-item.sortable-ghost {
background: #f6ffed;
border-color: #b7eb8f;
opacity: 0.5;
}
.list-complete-enter,
.list-complete-leave-active {
opacity: 0;
transform: translateY(10px);
}
</style>
组件使用方式
该组件设计为可复用组件,通过 props 接收初始数据和配置,通过事件向父组件传递状态变化:
<dnd-list
:list1="leftList"
:list2="rightList"
list1Title="待选文章"
list2Title="已选文章"
@list-change="handleListChange"
/>
总结
本文介绍的DndList组件基于 Vue 和 vuedraggable 实现了双向拖拽功能,具有以下特点:
- 功能完备:支持拖拽和点击两种元素转移方式,满足不同操作习惯
- 用户体验佳:提供丰富的视觉反馈和状态提示,操作直观清晰
- 高可扩展性:通过 props 配置列表宽度、标题等,适应不同场景
- 代码规范:结构清晰,逻辑分明,便于维护和二次开发
该组件可广泛应用于需要进行元素分类、筛选的场景,如文章选集、任务分配、权限配置等。通过本文的解析,希望能帮助开发者更好地理解 Vue 拖拽组件的实现原理,从而构建出更优秀的交互体验。
在实际项目中,还可以根据需求进一步扩展,如添加拖拽排序功能、限制最大拖拽数量、增加元素搜索过滤等,使组件功能更加完善。
更多推荐


所有评论(0)