Vue3 + Element Plus + SortableJS 实现表格拖拽排序功能
📋 目录
- 功能概述
- 技术栈
- 实现思路
- 代码实现
- 核心要点
- 常见问题
- 总结
功能概述
在管理后台系统中,表格数据的排序功能是一个常见的需求。本文介绍如何使用 Vue3、Element Plus 和 SortableJS 实现一个完整的表格拖拽排序功能,支持:
- ✅ 通过拖拽图标对表格行进行排序
- ✅ 实时更新数据顺序
- ✅ 支持数据过滤后的排序
- ✅ 切换标签页时自动初始化
- ✅ 优雅的动画效果
先看实现效果:
技术栈
- Vue 3 - 渐进式 JavaScript 框架
- Element Plus - Vue 3 组件库
- SortableJS - 轻量级拖拽排序库
- TypeScript - 类型安全的 JavaScript 超集
实现思路
1. 整体架构
1用户拖拽表格行 2 ↓ 3SortableJS 监听拖拽事件 4 ↓ 5触发 onEnd 回调 6 ↓ 7更新 Vue 响应式数据 8 ↓ 9表格自动重新渲染 10
2. 关键步骤
- 安装依赖:引入 SortableJS 库
- 获取 DOM:获取表格 tbody 元素
- 初始化 Sortable:创建拖拽实例
- 处理回调:在拖拽结束时更新数据
- 生命周期管理:在适当时机初始化和销毁实例
代码实现
1. 安装依赖
1npm install sortablejs 2# 或 3pnpm add sortablejs 4
2. 导入必要的模块
1import { ref, nextTick, watch, onMounted } from "vue"; 2import Sortable from "sortablejs"; 3import { Operation } from "@element-plus/icons-vue";//图标 4
3. 定义数据结构
1interface TypeItem { 2 id: string; 3 name: string; 4 enabled: boolean; 5 sortOrder: number; 6} 7 8const typeData = ref<TypeItem[]>([ 9 { id: "1", name: "楼宇性质1", enabled: true, sortOrder: 1 }, 10 { id: "2", name: "楼宇性质2", enabled: true, sortOrder: 2 }, 11 // ... 更多数据 12]); 13
4. 模板结构
1<template> 2 <el-table ref="typeTableRef" :data="filteredTypeData" stripe row-key="id"> 3 <!-- 排序列:显示拖拽图标 --> 4 <el-table-column label="排序" width="131"> 5 <template #default> 6 <el-icon class="drag-handle"> 7 <Operation /> 8 </el-icon> 9 </template> 10 </el-table-column> 11 12 <!-- 其他列 --> 13 <el-table-column prop="name" label="名称" /> 14 <el-table-column prop="enabled" label="启用/禁用"> 15 <template #default="{ row }"> 16 <el-switch v-model="row.enabled" /> 17 </template> 18 </el-table-column> 19 </el-table> 20</template> 21
5. 核心实现代码
1// 表格引用 2const typeTableRef = ref<InstanceType<typeof ElTable>>(); 3 4// Sortable 实例(用于后续销毁) 5let sortableInstance: Sortable | null = null; 6 7/** 8 * 初始化拖拽排序功能 9 */ 10const initSortable = () => { 11 // 1. 销毁旧实例,避免重复创建 12 if (sortableInstance) { 13 sortableInstance.destroy(); 14 sortableInstance = null; 15 } 16 17 // 2. 等待 DOM 更新完成 18 nextTick(() => { 19 // 3. 获取表格的 tbody 元素 20 const tbody = typeTableRef.value?.$el?.querySelector( 21 ".el-table__body-wrapper tbody" 22 ); 23 24 if (!tbody) return; 25 26 // 4. 创建 Sortable 实例 27 sortableInstance = Sortable.create(tbody, { 28 // 指定拖拽手柄(只能通过拖拽图标来拖拽) 29 handle: ".drag-handle", 30 31 // 动画时长(毫秒) 32 animation: 300, 33 34 // 拖拽结束回调 35 onEnd: ({ newIndex, oldIndex }) => { 36 // 5. 更新数据顺序 37 if ( 38 newIndex !== undefined && 39 oldIndex !== undefined && 40 filterStatus.value === "all" // 只在"全部"状态下允许排序 41 ) { 42 // 获取被移动的项 43 const movedItem = typeData.value[oldIndex]; 44 45 // 从原位置删除 46 typeData.value.splice(oldIndex, 1); 47 48 // 插入到新位置 49 typeData.value.splice(newIndex, 0, movedItem); 50 51 // 更新排序字段 52 typeData.value.forEach((item, index) => { 53 item.sortOrder = index + 1; 54 }); 55 } 56 } 57 }); 58 }); 59}; 60
6. 生命周期管理
1/** 2 * 监听标签页切换,初始化拖拽 3 */ 4const watchActiveTab = () => { 5 if (activeTab.value === "type") { 6 // 延迟初始化,确保表格已完全渲染 7 setTimeout(() => { 8 initSortable(); 9 }, 300); 10 } 11}; 12 13// 组件挂载时初始化 14onMounted(() => { 15 watchActiveTab(); 16}); 17 18// 监听标签页切换 19watch(activeTab, () => { 20 watchActiveTab(); 21}); 22 23// 监听过滤器变化,重新初始化拖拽 24watch(filterStatus, () => { 25 if (activeTab.value === "type") { 26 setTimeout(() => { 27 initSortable(); 28 }, 100); 29 } 30}); 31
7. 样式定义
1/* 拖拽手柄样式 */ 2.drag-handle { 3 color: #909399; 4 cursor: move; 5 font-size: 18px; 6 transition: color 0.3s; 7} 8 9.drag-handle:hover { 10 color: #1890ff; 11} 12 13/* 表格样式 */ 14.type-table { 15 margin-top: 0; 16} 17 18:deep(.type-table .el-table__header-wrapper) { 19 background-color: #f9fafc; 20} 21 22:deep(.type-table .el-table th) { 23 background-color: #f9fafc; 24 font-size: 14px; 25 font-weight: 500; 26 color: #33425cfa; 27 font-family: PingFang SC; 28 border-bottom: 1px solid #dcdfe6; 29} 30
核心要点
1. 实例管理
问题:如果不管理 Sortable 实例,切换标签页或过滤器时会创建多个实例,导致拖拽行为异常。
解决:使用变量保存实例引用,在创建新实例前先销毁旧实例。
1let sortableInstance: Sortable | null = null; 2 3const initSortable = () => { 4 // 先销毁旧实例 5 if (sortableInstance) { 6 sortableInstance.destroy(); 7 sortableInstance = null; 8 } 9 // 再创建新实例 10 // ... 11}; 12
2. DOM 获取时机
问题:如果直接获取 DOM,可能表格还未渲染完成,导致获取失败。
解决:使用 nextTick 等待 Vue 完成 DOM 更新,或使用 setTimeout 延迟执行。
1nextTick(() => { 2 const tbody = typeTableRef.value?.$el?.querySelector( 3 ".el-table__body-wrapper tbody" 4 ); 5 // ... 6}); 7
3. 拖拽手柄
问题:如果不指定拖拽手柄,整行都可以拖拽,可能与其他交互冲突(如点击编辑按钮)。
解决:使用 handle 选项指定只有拖拽图标可以触发拖拽。
1Sortable.create(tbody, { 2 handle: ".drag-handle", // 只允许通过 .drag-handle 元素拖拽 3 // ... 4}); 5
4. 数据更新策略
问题:直接操作 DOM 顺序不会更新 Vue 的响应式数据。
解决:在 onEnd 回调中手动更新数据数组的顺序。
1onEnd: ({ newIndex, oldIndex }) => { 2 const movedItem = typeData.value[oldIndex]; 3 typeData.value.splice(oldIndex, 1); 4 typeData.value.splice(newIndex, 0, movedItem); 5 // 更新排序字段 6 typeData.value.forEach((item, index) => { 7 item.sortOrder = index + 1; 8 }); 9} 10
5. 过滤状态处理
问题:当表格数据被过滤后,拖拽的索引可能不准确。
解决:只在"全部"状态下允许排序,或根据过滤后的数据计算正确的索引。
1onEnd: ({ newIndex, oldIndex }) => { 2 if (filterStatus.value === "all") { 3 // 只在全部状态下允许排序 4 // ... 5 } 6} 7
常见问题
Q1: 拖拽后数据没有更新?
A: 检查是否正确更新了响应式数据。SortableJS 只负责 DOM 操作,不会自动更新 Vue 数据。
Q2: 切换标签页后拖拽失效?
A: 需要在标签页切换时重新初始化 Sortable 实例,因为 DOM 已经重新渲染。
Q3: 拖拽时整行都可以拖,如何限制?
A: 使用 handle 选项指定拖拽手柄元素。
Q4: 拖拽动画不流畅?
A: 调整 animation 参数的值,通常 200-300ms 效果较好。
Q5: 如何保存排序结果?
A: 在 onEnd 回调中,将更新后的数据发送到后端 API。
1onEnd: ({ newIndex, oldIndex }) => { 2 // 更新本地数据 3 // ... 4 5 // 保存到后端 6 saveSortOrder(typeData.value.map(item => ({ 7 id: item.id, 8 sortOrder: item.sortOrder 9 }))); 10} 11
完整示例代码
1<template> 2 <div class="type-setting"> 3 <!-- 过滤器 --> 4 <div class="filter-actions"> 5 <el-button 6 :type="filterStatus === 'all' ? 'primary' : ''" 7 @click="filterStatus = 'all'" 8 > 9 全部 10 </el-button> 11 <el-button 12 :type="filterStatus === 'enabled' ? 'primary' : ''" 13 @click="filterStatus = 'enabled'" 14 > 15 启用 16 </el-button> 17 </div> 18 19 <!-- 表格 --> 20 <el-table 21 ref="typeTableRef" 22 :data="filteredTypeData" 23 stripe 24 row-key="id" 25 > 26 <el-table-column label="排序" width="131"> 27 <template #default> 28 <el-icon class="drag-handle"> 29 <Operation /> 30 </el-icon> 31 </template> 32 </el-table-column> 33 <el-table-column prop="name" label="名称" /> 34 <el-table-column prop="enabled" label="启用/禁用"> 35 <template #default="{ row }"> 36 <el-switch v-model="row.enabled" /> 37 </template> 38 </el-table-column> 39 <el-table-column label="操作"> 40 <template #default="{ row }"> 41 <el-button type="primary" link @click="handleEdit(row)"> 42 编辑 43 </el-button> 44 </template> 45 </el-table-column> 46 </el-table> 47 </div> 48</template> 49 50<script setup lang="ts"> 51import { ref, nextTick, watch, onMounted } from "vue"; 52import { ElTable } from "element-plus"; 53import Sortable from "sortablejs"; 54import { Operation } from "@element-plus/icons-vue"; 55 56interface TypeItem { 57 id: string; 58 name: string; 59 enabled: boolean; 60 sortOrder: number; 61} 62 63const typeData = ref<TypeItem[]>([ 64 { id: "1", name: "楼宇性质1", enabled: true, sortOrder: 1 }, 65 { id: "2", name: "楼宇性质2", enabled: true, sortOrder: 2 }, 66 { id: "3", name: "楼宇性质3", enabled: false, sortOrder: 3 }, 67]); 68 69const filterStatus = ref<"all" | "enabled" | "disabled">("all"); 70const typeTableRef = ref<InstanceType<typeof ElTable>>(); 71let sortableInstance: Sortable | null = null; 72 73const filteredTypeData = computed(() => { 74 if (filterStatus.value === "all") return typeData.value; 75 if (filterStatus.value === "enabled") { 76 return typeData.value.filter(item => item.enabled); 77 } 78 return typeData.value.filter(item => !item.enabled); 79}); 80 81const initSortable = () => { 82 if (sortableInstance) { 83 sortableInstance.destroy(); 84 sortableInstance = null; 85 } 86 87 nextTick(() => { 88 const tbody = typeTableRef.value?.$el?.querySelector( 89 ".el-table__body-wrapper tbody" 90 ); 91 if (!tbody) return; 92 93 sortableInstance = Sortable.create(tbody, { 94 handle: ".drag-handle", 95 animation: 300, 96 onEnd: ({ newIndex, oldIndex }) => { 97 if ( 98 newIndex !== undefined && 99 oldIndex !== undefined && 100 filterStatus.value === "all" 101 ) { 102 const movedItem = typeData.value[oldIndex]; 103 typeData.value.splice(oldIndex, 1); 104 typeData.value.splice(newIndex, 0, movedItem); 105 typeData.value.forEach((item, index) => { 106 item.sortOrder = index + 1; 107 }); 108 } 109 } 110 }); 111 }); 112}; 113 114onMounted(() => { 115 setTimeout(() => initSortable(), 300); 116}); 117 118watch(filterStatus, () => { 119 setTimeout(() => initSortable(), 100); 120}); 121</script> 122 123<style scoped> 124.drag-handle { 125 color: #909399; 126 cursor: move; 127 font-size: 18px; 128} 129 130.drag-handle:hover { 131 color: #1890ff; 132} 133</style> 134
总结
通过本文的介绍,我们实现了一个完整的表格拖拽排序功能。关键点包括:
- ✅ 正确的实例管理:避免重复创建和内存泄漏
- ✅ 合适的初始化时机:确保 DOM 已完全渲染
- ✅ 数据同步更新:手动更新 Vue 响应式数据
- ✅ 良好的用户体验:指定拖拽手柄,添加动画效果
- ✅ 完善的错误处理:处理边界情况
这个方案可以轻松应用到其他需要拖拽排序的场景,如菜单管理、分类排序等。希望本文对您有所帮助!
《Vue3实现拖拽排序》 是转载文章,点击查看原文。