Vue3实现拖拽排序
2025年11月14日 13:54
Vue3 + Element Plus + SortableJS 实现表格拖拽排序功能
📋 目录
功能概述
在管理后台系统中,表格数据的排序功能是一个常见的需求。本文介绍如何使用 Vue3、Element Plus 和 SortableJS 实现一个完整的表格拖拽排序功能,支持:
- ✅ 通过拖拽图标对表格行进行排序
- ✅ 实时更新数据顺序
- ✅ 支持数据过滤后的排序
- ✅ 切换标签页时自动初始化
- ✅ 优雅的动画效果
先看实现效果:
![]()
技术栈
- Vue 3 - 渐进式 JavaScript 框架
- Element Plus - Vue 3 组件库
- SortableJS - 轻量级拖拽排序库
- TypeScript - 类型安全的 JavaScript 超集
实现思路
1. 整体架构
用户拖拽表格行
↓
SortableJS 监听拖拽事件
↓
触发 onEnd 回调
↓
更新 Vue 响应式数据
↓
表格自动重新渲染
2. 关键步骤
- 安装依赖:引入 SortableJS 库
- 获取 DOM:获取表格 tbody 元素
- 初始化 Sortable:创建拖拽实例
- 处理回调:在拖拽结束时更新数据
- 生命周期管理:在适当时机初始化和销毁实例
代码实现
1. 安装依赖
npm install sortablejs
# 或
pnpm add sortablejs
2. 导入必要的模块
import { ref, nextTick, watch, onMounted } from "vue";
import Sortable from "sortablejs";
import { Operation } from "@element-plus/icons-vue";//图标
3. 定义数据结构
interface TypeItem {
id: string;
name: string;
enabled: boolean;
sortOrder: number;
}
const typeData = ref<TypeItem[]>([
{ id: "1", name: "楼宇性质1", enabled: true, sortOrder: 1 },
{ id: "2", name: "楼宇性质2", enabled: true, sortOrder: 2 },
// ... 更多数据
]);
4. 模板结构
<template>
<el-table ref="typeTableRef" :data="filteredTypeData" stripe row-key="id">
<!-- 排序列:显示拖拽图标 -->
<el-table-column label="排序" width="131">
<template #default>
<el-icon class="drag-handle">
<Operation />
</el-icon>
</template>
</el-table-column>
<!-- 其他列 -->
<el-table-column prop="name" label="名称" />
<el-table-column prop="enabled" label="启用/禁用">
<template #default="{ row }">
<el-switch v-model="row.enabled" />
</template>
</el-table-column>
</el-table>
</template>
5. 核心实现代码
// 表格引用
const typeTableRef = ref<InstanceType<typeof ElTable>>();
// Sortable 实例(用于后续销毁)
let sortableInstance: Sortable | null = null;
/**
* 初始化拖拽排序功能
*/
const initSortable = () => {
// 1. 销毁旧实例,避免重复创建
if (sortableInstance) {
sortableInstance.destroy();
sortableInstance = null;
}
// 2. 等待 DOM 更新完成
nextTick(() => {
// 3. 获取表格的 tbody 元素
const tbody = typeTableRef.value?.$el?.querySelector(
".el-table__body-wrapper tbody"
);
if (!tbody) return;
// 4. 创建 Sortable 实例
sortableInstance = Sortable.create(tbody, {
// 指定拖拽手柄(只能通过拖拽图标来拖拽)
handle: ".drag-handle",
// 动画时长(毫秒)
animation: 300,
// 拖拽结束回调
onEnd: ({ newIndex, oldIndex }) => {
// 5. 更新数据顺序
if (
newIndex !== undefined &&
oldIndex !== undefined &&
filterStatus.value === "all" // 只在"全部"状态下允许排序
) {
// 获取被移动的项
const movedItem = typeData.value[oldIndex];
// 从原位置删除
typeData.value.splice(oldIndex, 1);
// 插入到新位置
typeData.value.splice(newIndex, 0, movedItem);
// 更新排序字段
typeData.value.forEach((item, index) => {
item.sortOrder = index + 1;
});
}
}
});
});
};
6. 生命周期管理
/**
* 监听标签页切换,初始化拖拽
*/
const watchActiveTab = () => {
if (activeTab.value === "type") {
// 延迟初始化,确保表格已完全渲染
setTimeout(() => {
initSortable();
}, 300);
}
};
// 组件挂载时初始化
onMounted(() => {
watchActiveTab();
});
// 监听标签页切换
watch(activeTab, () => {
watchActiveTab();
});
// 监听过滤器变化,重新初始化拖拽
watch(filterStatus, () => {
if (activeTab.value === "type") {
setTimeout(() => {
initSortable();
}, 100);
}
});
7. 样式定义
/* 拖拽手柄样式 */
.drag-handle {
color: #909399;
cursor: move;
font-size: 18px;
transition: color 0.3s;
}
.drag-handle:hover {
color: #1890ff;
}
/* 表格样式 */
.type-table {
margin-top: 0;
}
:deep(.type-table .el-table__header-wrapper) {
background-color: #f9fafc;
}
:deep(.type-table .el-table th) {
background-color: #f9fafc;
font-size: 14px;
font-weight: 500;
color: #33425cfa;
font-family: PingFang SC;
border-bottom: 1px solid #dcdfe6;
}
核心要点
1. 实例管理
问题:如果不管理 Sortable 实例,切换标签页或过滤器时会创建多个实例,导致拖拽行为异常。
解决:使用变量保存实例引用,在创建新实例前先销毁旧实例。
let sortableInstance: Sortable | null = null;
const initSortable = () => {
// 先销毁旧实例
if (sortableInstance) {
sortableInstance.destroy();
sortableInstance = null;
}
// 再创建新实例
// ...
};
2. DOM 获取时机
问题:如果直接获取 DOM,可能表格还未渲染完成,导致获取失败。
解决:使用 nextTick 等待 Vue 完成 DOM 更新,或使用 setTimeout 延迟执行。
nextTick(() => {
const tbody = typeTableRef.value?.$el?.querySelector(
".el-table__body-wrapper tbody"
);
// ...
});
3. 拖拽手柄
问题:如果不指定拖拽手柄,整行都可以拖拽,可能与其他交互冲突(如点击编辑按钮)。
解决:使用 handle 选项指定只有拖拽图标可以触发拖拽。
Sortable.create(tbody, {
handle: ".drag-handle", // 只允许通过 .drag-handle 元素拖拽
// ...
});
4. 数据更新策略
问题:直接操作 DOM 顺序不会更新 Vue 的响应式数据。
解决:在 onEnd 回调中手动更新数据数组的顺序。
onEnd: ({ newIndex, oldIndex }) => {
const movedItem = typeData.value[oldIndex];
typeData.value.splice(oldIndex, 1);
typeData.value.splice(newIndex, 0, movedItem);
// 更新排序字段
typeData.value.forEach((item, index) => {
item.sortOrder = index + 1;
});
}
5. 过滤状态处理
问题:当表格数据被过滤后,拖拽的索引可能不准确。
解决:只在"全部"状态下允许排序,或根据过滤后的数据计算正确的索引。
onEnd: ({ newIndex, oldIndex }) => {
if (filterStatus.value === "all") {
// 只在全部状态下允许排序
// ...
}
}
常见问题
Q1: 拖拽后数据没有更新?
A: 检查是否正确更新了响应式数据。SortableJS 只负责 DOM 操作,不会自动更新 Vue 数据。
Q2: 切换标签页后拖拽失效?
A: 需要在标签页切换时重新初始化 Sortable 实例,因为 DOM 已经重新渲染。
Q3: 拖拽时整行都可以拖,如何限制?
A: 使用 handle 选项指定拖拽手柄元素。
Q4: 拖拽动画不流畅?
A: 调整 animation 参数的值,通常 200-300ms 效果较好。
Q5: 如何保存排序结果?
A: 在 onEnd 回调中,将更新后的数据发送到后端 API。
onEnd: ({ newIndex, oldIndex }) => {
// 更新本地数据
// ...
// 保存到后端
saveSortOrder(typeData.value.map(item => ({
id: item.id,
sortOrder: item.sortOrder
})));
}
完整示例代码
<template>
<div class="type-setting">
<!-- 过滤器 -->
<div class="filter-actions">
<el-button
:type="filterStatus === 'all' ? 'primary' : ''"
@click="filterStatus = 'all'"
>
全部
</el-button>
<el-button
:type="filterStatus === 'enabled' ? 'primary' : ''"
@click="filterStatus = 'enabled'"
>
启用
</el-button>
</div>
<!-- 表格 -->
<el-table
ref="typeTableRef"
:data="filteredTypeData"
stripe
row-key="id"
>
<el-table-column label="排序" width="131">
<template #default>
<el-icon class="drag-handle">
<Operation />
</el-icon>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" />
<el-table-column prop="enabled" label="启用/禁用">
<template #default="{ row }">
<el-switch v-model="row.enabled" />
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">
编辑
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, watch, onMounted } from "vue";
import { ElTable } from "element-plus";
import Sortable from "sortablejs";
import { Operation } from "@element-plus/icons-vue";
interface TypeItem {
id: string;
name: string;
enabled: boolean;
sortOrder: number;
}
const typeData = ref<TypeItem[]>([
{ id: "1", name: "楼宇性质1", enabled: true, sortOrder: 1 },
{ id: "2", name: "楼宇性质2", enabled: true, sortOrder: 2 },
{ id: "3", name: "楼宇性质3", enabled: false, sortOrder: 3 },
]);
const filterStatus = ref<"all" | "enabled" | "disabled">("all");
const typeTableRef = ref<InstanceType<typeof ElTable>>();
let sortableInstance: Sortable | null = null;
const filteredTypeData = computed(() => {
if (filterStatus.value === "all") return typeData.value;
if (filterStatus.value === "enabled") {
return typeData.value.filter(item => item.enabled);
}
return typeData.value.filter(item => !item.enabled);
});
const initSortable = () => {
if (sortableInstance) {
sortableInstance.destroy();
sortableInstance = null;
}
nextTick(() => {
const tbody = typeTableRef.value?.$el?.querySelector(
".el-table__body-wrapper tbody"
);
if (!tbody) return;
sortableInstance = Sortable.create(tbody, {
handle: ".drag-handle",
animation: 300,
onEnd: ({ newIndex, oldIndex }) => {
if (
newIndex !== undefined &&
oldIndex !== undefined &&
filterStatus.value === "all"
) {
const movedItem = typeData.value[oldIndex];
typeData.value.splice(oldIndex, 1);
typeData.value.splice(newIndex, 0, movedItem);
typeData.value.forEach((item, index) => {
item.sortOrder = index + 1;
});
}
}
});
});
};
onMounted(() => {
setTimeout(() => initSortable(), 300);
});
watch(filterStatus, () => {
setTimeout(() => initSortable(), 100);
});
</script>
<style scoped>
.drag-handle {
color: #909399;
cursor: move;
font-size: 18px;
}
.drag-handle:hover {
color: #1890ff;
}
</style>
总结
通过本文的介绍,我们实现了一个完整的表格拖拽排序功能。关键点包括:
- ✅ 正确的实例管理:避免重复创建和内存泄漏
- ✅ 合适的初始化时机:确保 DOM 已完全渲染
- ✅ 数据同步更新:手动更新 Vue 响应式数据
- ✅ 良好的用户体验:指定拖拽手柄,添加动画效果
- ✅ 完善的错误处理:处理边界情况
这个方案可以轻松应用到其他需要拖拽排序的场景,如菜单管理、分类排序等。希望本文对您有所帮助!