阅读视图

发现新文章,点击刷新页面。

Vue3实现拖拽排序

Vue3 + Element Plus + SortableJS 实现表格拖拽排序功能

📋 目录

功能概述

在管理后台系统中,表格数据的排序功能是一个常见的需求。本文介绍如何使用 Vue3、Element Plus 和 SortableJS 实现一个完整的表格拖拽排序功能,支持:

  • ✅ 通过拖拽图标对表格行进行排序
  • ✅ 实时更新数据顺序
  • ✅ 支持数据过滤后的排序
  • ✅ 切换标签页时自动初始化
  • ✅ 优雅的动画效果

先看实现效果: 在这里插入图片描述

技术栈

  • Vue 3 - 渐进式 JavaScript 框架
  • Element Plus - Vue 3 组件库
  • SortableJS - 轻量级拖拽排序库
  • TypeScript - 类型安全的 JavaScript 超集

实现思路

1. 整体架构

用户拖拽表格行
    ↓
SortableJS 监听拖拽事件
    ↓
触发 onEnd 回调
    ↓
更新 Vue 响应式数据
    ↓
表格自动重新渲染

2. 关键步骤

  1. 安装依赖:引入 SortableJS 库
  2. 获取 DOM:获取表格 tbody 元素
  3. 初始化 Sortable:创建拖拽实例
  4. 处理回调:在拖拽结束时更新数据
  5. 生命周期管理:在适当时机初始化和销毁实例

代码实现

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>

总结

通过本文的介绍,我们实现了一个完整的表格拖拽排序功能。关键点包括:

  1. 正确的实例管理:避免重复创建和内存泄漏
  2. 合适的初始化时机:确保 DOM 已完全渲染
  3. 数据同步更新:手动更新 Vue 响应式数据
  4. 良好的用户体验:指定拖拽手柄,添加动画效果
  5. 完善的错误处理:处理边界情况

这个方案可以轻松应用到其他需要拖拽排序的场景,如菜单管理、分类排序等。希望本文对您有所帮助!


❌