需求背景与核心目标
在基于 Vue2 和 Element UI 构建的可编辑表格组件中,需实现以下核心目标:
- 仅允许点击表格第一列(序号列)的拖拽手柄触发行拖曳,禁止点击其他列触发拖拽;
- 拖曳过程中视觉反馈清晰,无禁止符号、无卡顿;
- 拖曳结束后同步更新表格数据,并能将排序结果提交至后端;
- 兼容 Element UI 表格的原有功能(如分页、编辑、表单验证等),无样式和事件冲突。
效果


一、安装 sortablejs
bash
npm i sortablejs @types/sortablejs --save
二、在表格组件文件中引入
javascript
<script>
......其他引入
import Sortable from 'sortablejs';
</script>
三、具体使用方法
1、模板中
在 Element UI 表格的序号列中添加拖拽手柄 DOM,为拖拽触发提供专属区域:
html
<!-- 序号 + 拖拽手柄 -->
<el-table-column
v-if="showIndex || enableDragSort"
column-key="index"
label="序号"
width="55"
align="left"
>
<template slot-scope="scope">
<div
class="drag-handle-wrapper"
:data-key="scope.row[rowKeyField] || scope.$index"
>
<span v-if="enableDragSort" class="drag-handle-icon">
<i class="el-icon-d-caret icon-class"/>
</span>
<span class="index-number">{{ scope.$index + 1 }}</span>
</div>
</template>
</el-table-column>
2、逻辑代码
定义一些需要的变量
javascript
props:{
// 行唯一标识字段(默认id)
rowKeyField: {
type: String,
default: 'id'
}
},
data() {
return {
sortableObj: null,
// 拖拽初始化标记
dragInitialized: false,
};
},
在组件挂载阶段初始化 Sortable 实例,重点解决 Element UI 表格的事件拦截、拖拽区域限制问题,销毁前清理掉:
javascript
mounted() {
if (this.enableDragSort) {
this.$nextTick(() => {
// 延迟初始化,确保DOM完全渲染
setTimeout(() => {
this.initSortable();
}, 500);
});
}
},
beforeDestroy() {
// 销毁Sortable实例,避免内存泄漏
this.destroySortable()
},
methods: {
// 初始化拖拽
initSortable() {
if (this.dragInitialized) {
this.destroySortable();
}
if (!this.enableDragSort || !this.$refs.sortTable || !this.tableData || this.tableData.length <= 1) {
return;
}
try {
const tbody = this.$refs.sortTable.$el.querySelector(".el-table__body-wrapper tbody");
if (!tbody) {
console.error("无法找到tbody元素");
return;
}
this.sortableObj = Sortable.create(tbody, {
animation: 150,
draggable: ".el-table__row",
handle: ".drag-handle-wrapper, .drag-handle-icon",
ghostClass: "sortable-ghost",
chosenClass: "sortable-chosen",
dragClass: "sortable-drag",
dataIdAttr: "data-key",
// 简化事件处理
onStart: (evt) => {
// 阻止默认行为但允许传播
evt.originalEvent.preventDefault();
evt.item.style.opacity = "0.5";
},
onEnd: (evt) => {
evt.item.style.opacity = "";
const { oldIndex, newIndex } = evt;
if (oldIndex === newIndex) return;
// 更新数据
const currentData = [...this.tableData];
const movedItem = currentData.splice(oldIndex, 1)[0];
currentData.splice(newIndex, 0, movedItem);
// 触发更新
this.$emit("sort-change", {
oldIndex,
newIndex,
sortedData: currentData,
sortedIds: currentData.map((item) => item[this.rowKeyField]),
});
// 重新初始化
this.$nextTick(() => {
this.destroySortable();
setTimeout(() => {
this.initSortable();
}, 100);
});
},
// 禁用所有过滤,确保拖拽能进行
filter: "",
preventOnFilter: false,
});
this.dragInitialized = true;
} catch (error) {
console.error("Sortable初始化失败:", error);
}
},
// 销毁Sortable实例
destroySortable() {
if (this.sortableObj) {
try {
this.sortableObj.destroy();
} catch (error) {
console.error('销毁Sortable实例失败:', error);
}
this.sortableObj = null;
}
this.dragInitialized = false;
},
}
3、样式
通过 CSS 限制拖拽交互区域,确保仅手柄可触发拖拽,同时优化视觉体验:
css
<style lang="scss" scoped>
// 修复可能影响拖拽的Element UI样式
::v-deep .el-table__body-wrapper {
user-select: none;
}
::v-deep .el-table__row {
-webkit-user-drag: element;
}
</style>
<style lang="scss">
// 全局拖拽样式(必须放在scoped外面)
.drag-handle-wrapper {
display: flex;
align-items: center;
cursor: grab !important;
padding: 8px 0;
&:active {
cursor: grabbing !important;
}
.drag-handle-icon {
margin-right: 5px;
opacity: 0.6;
transition: opacity 0.2s;
cursor: grab !important;
&:hover {
opacity: 1;
}
&:active {
cursor: grabbing !important;
}
}
}
.icon-class{
width: 14px;
vertical-align: middle;
&:hover {
color: $primary;
}
}
// 修复Element UI表格的拖拽问题
.el-table {
// 允许拖拽
.el-table__body-wrapper {
cursor: default !important;
tbody {
cursor: default !important;
}
}
// 表格行样式
.el-table__row {
cursor: default !important;
&.sortable-ghost {
opacity: 0.5;
background: #f5f7fa !important;
}
&.sortable-chosen {
background: #e8f4ff !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&.sortable-drag {
opacity: 0.8;
background: #e8f4ff !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
}
// 禁止符号修复
* {
&[draggable="true"] {
-webkit-user-drag: element !important;
user-drag: element !important;
}
}
</style>
四、父组件中使用
使用封装好的表格组件
html
<EditTable
......其他配置项
:enableDragSort="hasEditPermission"
rowKeyField="id"
@sort-change="handleSortChange"
/>
接收返回的排序结果
javascript
// 处理排序变化(可选)
handleSortChange({ oldIndex, newIndex, sortedData, sortedIds }) {
sortedData.forEach((item, index) => {
item.sort_order = index + 1;
});
this.tableData = sortedData
}
五、遇到的问题与解决方案
1. 拖拽事件被 Element UI 拦截(onStart/onEnd 不触发)
问题原因:Element UI 表格内部会阻止鼠标事件传播,导致 Sortable 无法捕获完整的拖拽事件;
解决方案:启用forceFallback: true强制使用原生拖拽,取消stopImmediatePropagation()事件拦截,调用doLayout()强制刷新表格布局。
2. 整行均可拖拽,无法限制仅手柄触发
问题原因:Sortable 默认允许点击可拖拽元素任意区域触发拖拽,Element UI 表格行的事件穿透导致范围失控;
解决方案:
通过handle配置精确指定拖拽手柄选择器;
启用filter过滤非手柄区域;
样式层面设置整行pointer-events: none,仅序号列开放事件。
3. 拖拽时出现禁止符号
问题原因:拖拽目标元素的pointer-events为none、选择器匹配失败或 fallback 配置不当;
解决方案:
确保拖拽手柄的pointer-events: auto;
降低fallbackTolerance至 1,减少触发门槛;
为拖拽中的行设置高z-index,避免被遮挡。
4. 内存泄漏风险
问题原因:组件销毁时未销毁 Sortable 实例,导致 DOM 事件残留;
解决方案:在beforeDestroy钩子中调用destroySortable()销毁实例。
启用forceFallback绕过 Element UI 事件拦截,是解决拖拽事件不触发的核心;
结合handle和filter配置 + 样式层面的pointer-events限制,可精准控制拖拽触发区域;
组件生命周期中及时销毁 Sortable 实例,避免内存泄漏;
拖拽后的数据源同步需通过$emit通知父组件,保证数据单向流的规范性。