vue3 + element plus -- table表格使用sortablejs实现表格拖拽换位功能
table表格实现表格拖拽换位功能
思考:
1、项目框架vue3 + element plus
2、使用sortablejs实现表格拖拽换位功能
3、表格列也的是动态的一个数组来保存
安装sortablejs
npm install sortablejs --save
使用
代码主要是通过个人思路提需求使用插件来生成的。行和列都可拖拽换位。
<template><div class="content"><h1>sortablejs实现表格拖拽</h1><div class="table"><el-tableref="boxRef":data="tableData"style="width: 100%"striperow-key="id"class="draggable-table":fit="true"border><el-table-columnv-for="item in tableList":key="item.type":prop="item.prop":label="item.label"show-overflow-tooltipmin-width="120":width="null"></el-table-column></el-table></div><div><el-button type="primary" @click="addRow">添加一行</el-button></div></div>
</template><script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, nextTick, watch } from 'vue'
import Sortable from 'sortablejs';//sortable实例
const rowSortable:any = ref(null)
const columnSortable:any = ref(null)const tableList = reactive([{type: '1',label: '姓名',prop: 'name',},{type: '2',label: '日期',prop: 'date',},{type: '3',label: '地址',prop: 'address',},
])const tableData = reactive([{date: '2016-05-03',name: 'Tom1',id: '1',address: 'No. 189, Grove St, Los Angeles',},{date: '2016-05-02',name: 'Tom2',id: '2',address: 'No. 189, Grove St, Los Angeles',},{date: '2016-05-04',name: 'Tom3',id: '3',address: 'No. 189, Grove St, Los Angeles',},{date: '2016-05-01',name: 'Tom4',id: '4',address: 'No. 189, Grove St, Los Angeles',},
])// 添加一行数据
const addRow = () => {const newId = String(tableData.length + 1);tableData.push({date: `2023-${Math.floor(Math.random() * 12) + 1}-${Math.floor(Math.random() * 28) + 1}`,name: `User${newId}`,id: newId,address: `Address ${newId}, Some City`,});// 添加新行后重新初始化排序nextTick(() => {initRowSortable();initColumnSortable();});
}//获取容器dom元素
const boxRef:any = ref(null)// 初始化行排序功能
const initRowSortable = () => {const tbody = boxRef.value.$el.querySelector('.el-table__body-wrapper tbody');if (!tbody) return;if (rowSortable.value) {rowSortable.value.destroy();}rowSortable.value = Sortable.create(tbody, {animation: 150,ghostClass: 'sortable-ghost',onEnd: ({ newIndex, oldIndex }:{ newIndex: number; oldIndex: number}) => {if (newIndex === oldIndex) return;const currRow = tableData.splice(oldIndex, 1)[0];tableData.splice(newIndex, 0, currRow);}});
}// 初始化列排序功能
const initColumnSortable = () => {const headerRow = boxRef.value.$el.querySelector('.el-table__header-wrapper tr');if (!headerRow) return;if (columnSortable.value) {columnSortable.value.destroy();}columnSortable.value = Sortable.create(headerRow, {animation: 150,ghostClass: 'sortable-ghost',handle: '.cell',onEnd: ({ newIndex, oldIndex }:{ newIndex: number; oldIndex: number}) => {if (newIndex === oldIndex) return;const currColumn = tableList.splice(oldIndex, 1)[0];tableList.splice(newIndex, 0, currColumn);}});
}// 监听数据变化,重新初始化排序
watch([tableData, tableList], () => {nextTick(() => {initRowSortable();initColumnSortable();});
});onMounted(() => {nextTick(() => {initRowSortable();initColumnSortable();});
});onUnmounted(() => {if (rowSortable.value) {rowSortable.value.destroy();}if (columnSortable.value) {columnSortable.value.destroy();}
});
</script><style lang="scss">
.content {min-height: calc(100vh - 150px);.table {margin-bottom: 20px;}
}.draggable-table {position: relative;.el-table {width: 100% !important;table-layout: fixed !important;.el-table__cell {padding: 8px 12px !important;height: auto !important;line-height: 1.5;.cell {overflow: hidden;text-overflow: ellipsis;white-space: normal !important;word-break: break-word;}}}/* 表头样式 */.el-table__header-wrapper {th {position: relative;cursor: move;user-select: none;background-color: #f5f7fa;.cell {position: relative;}&:hover {background-color: #e6f1fc;&::after {content: "⋮⋮";position: absolute;right: 16px;top: 50%;transform: translateY(-50%) rotate(90deg);color: #409EFF;font-size: 14px;opacity: 0.8;z-index: 1;}}}}/* 表格行样式 */.el-table__row {position: relative;cursor: move;transition: all 0.2s;td:first-child {position: relative;}&:hover {background-color: #f5f7fa;td:first-child::before {content: "⋮⋮";position: absolute;left: -15px;top: 50%;transform: translateY(-50%) rotate(90deg);color: #409EFF;font-size: 14px;opacity: 0.8;z-index: 1;}}}.sortable-ghost {background-color: #e6f1fc !important;opacity: 0.8;}.el-table__body-wrapper {overflow-x: hidden !important;}
}
</style>
新要求左侧添加一个行的增加,在当前行的前面或者后面可选则
<template><div class="content"><h1>sortablejs实现表格拖拽</h1><div class="table"><el-tableref="boxRef":data="tableData"style="width: 100%"striperow-key="id"class="draggable-table":fit="true"border><!-- 添加操作列,不参与拖拽 --><el-table-columnwidth="50"label=""class-name="operation-column"fixed="left"><template #header><div class="non-draggable-header">操作</div></template><template #default="scope"><div class="add-row-icon" @click="showAddRowOptions(scope.row, scope.$index)"><i class="el-icon-plus">+</i></div></template></el-table-column><el-table-columnv-for="item in tableList":key="item.type":prop="item.prop":label="item.label"show-overflow-tooltipmin-width="120":width="null"></el-table-column></el-table></div><div><el-button type="primary" @click="addRow">添加一行</el-button></div></div>
</template><script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, nextTick, watch } from 'vue'
import Sortable from 'sortablejs';
import { ElMessageBox } from 'element-plus';//sortable实例
const rowSortable:any = ref(null)
const columnSortable:any = ref(null)// 显示添加行选项
const showAddRowOptions = (row: any, index: number) => {ElMessageBox.confirm('请选择添加位置','添加行',{confirmButtonText: '在此行之前',cancelButtonText: '在此行之后',type: 'info',showClose: true,distinguishCancelAndClose: true,}).then(() => {// 在当前行之前添加addRowAt(index);}).catch((action) => {if (action === 'cancel') {// 在当前行之后添加addRowAt(index + 1);}});
};// 在指定位置添加行
const addRowAt = (index: number) => {const newId = String(Date.now()); // 使用时间戳作为唯一IDconst newRow = {date: `2023-${Math.floor(Math.random() * 12) + 1}-${Math.floor(Math.random() * 28) + 1}`,name: `User${newId.slice(-4)}`,id: newId,address: `Address ${newId.slice(-4)}, Some City`,};tableData.splice(index, 0, newRow);// 添加新行后重新初始化排序nextTick(() => {initRowSortable();});
};const tableList = reactive([{type: '1',label: '姓名',prop: 'name',},{type: '2',label: '日期',prop: 'date',},{type: '3',label: '地址',prop: 'address',},
])const tableData = reactive([{date: '2016-05-03',name: 'Tom1',id: '1',address: 'No. 189, Grove St, Los Angeles',},{date: '2016-05-02',name: 'Tom2',id: '2',address: 'No. 189, Grove St, Los Angeles',},{date: '2016-05-04',name: 'Tom3',id: '3',address: 'No. 189, Grove St, Los Angeles',},{date: '2016-05-01',name: 'Tom4',id: '4',address: 'No. 189, Grove St, Los Angeles',},
])// 添加一行数据
const addRow = () => {const newId = String(tableData.length + 1);tableData.push({date: `2023-${Math.floor(Math.random() * 12) + 1}-${Math.floor(Math.random() * 28) + 1}`,name: `User${newId}`,id: newId,address: `Address ${newId}, Some City`,});// 添加新行后重新初始化排序nextTick(() => {initRowSortable();initColumnSortable();});
}//获取容器dom元素
const boxRef:any = ref(null)// 初始化行排序功能
const initRowSortable = () => {const tbody = boxRef.value.$el.querySelector('.el-table__body-wrapper tbody');if (!tbody) return;if (rowSortable.value) {rowSortable.value.destroy();}rowSortable.value = Sortable.create(tbody, {animation: 150,ghostClass: 'sortable-ghost',onEnd: ({ newIndex, oldIndex }:{ newIndex: number; oldIndex: number}) => {if (newIndex === oldIndex) return;const currRow = tableData.splice(oldIndex, 1)[0];tableData.splice(newIndex, 0, currRow);}});
}// 初始化列排序功能
const initColumnSortable = () => {const headerRow = boxRef.value.$el.querySelector('.el-table__header-wrapper tr');if (!headerRow) return;if (columnSortable.value) {columnSortable.value.destroy();}// 获取所有表头单元格const headerCells = headerRow.querySelectorAll('th');// 确保第一个单元格不可拖动if (headerCells.length > 0) {headerCells[0].classList.add('operation-column-header');headerCells[0].setAttribute('data-no-sortable', 'true');}columnSortable.value = Sortable.create(headerRow, {animation: 150,ghostClass: 'sortable-ghost',handle: '.cell',filter: '.operation-column-header, .non-draggable-header', // 添加过滤器,防止操作列被拖动preventOnFilter: true, // 阻止在过滤的元素上触发拖动onStart: function(evt:any) {// 如果是第一列,取消拖动if (evt.oldIndex === 0) {this.options.disabled = true;setTimeout(() => {this.options.disabled = false;}, 0);return false;}},onMove: function(evt:any) {// 阻止拖动到第一列位置const targetIndex = evt.related ? Array.from(evt.related.parentNode.children).indexOf(evt.related) : 0;if (targetIndex === 0) {return false;}},onEnd: ({ newIndex, oldIndex }:{ newIndex: number; oldIndex: number}) => {if (newIndex === oldIndex || oldIndex === 0 || newIndex === 0) return;// 由于第一列是操作列,需要调整索引const adjustedNewIndex = newIndex > 0 ? newIndex - 1 : 0;const adjustedOldIndex = oldIndex > 0 ? oldIndex - 1 : 0;// 只有当操作的不是第一列时才进行排序if (oldIndex > 0) {const currColumn = tableList.splice(adjustedOldIndex, 1)[0];tableList.splice(adjustedNewIndex, 0, currColumn);}}});
}// 监听数据变化,重新初始化排序
watch([tableData, tableList], () => {nextTick(() => {initRowSortable();initColumnSortable();});
});onMounted(() => {nextTick(() => {initRowSortable();initColumnSortable();});
});onUnmounted(() => {if (rowSortable.value) {rowSortable.value.destroy();}if (columnSortable.value) {columnSortable.value.destroy();}
});
</script><style lang="scss">
.content {min-height: calc(100vh - 150px);.table {margin-bottom: 20px;}
}/* 操作列样式 */
.add-row-icon {cursor: pointer;// border: 1px solid #eee;i {font-size: 26px;color: #ccc;font-style: normal;}&:hover {i {color: #67c23a;}}
}/* 不可拖动的表头样式 */
.non-draggable-header {cursor: default !important;
}/* 确保操作列不显示拖动指示器 */
.el-table__header-wrapper th.operation-column-header,
.el-table__header-wrapper th:first-child {cursor: default !important;&:hover::after {display: none !important;content: none !important;}.cell {cursor: default !important;}
}/* 操作列样式 */
.operation-column {.cell {cursor: default !important;}
}.draggable-table {position: relative;.el-table {width: 100% !important;table-layout: fixed !important;.el-table__cell {padding: 8px 12px !important;height: auto !important;line-height: 1.5;.cell {overflow: hidden;text-overflow: ellipsis;white-space: normal !important;word-break: break-word;}}}/* 表头样式 */.el-table__header-wrapper {th {position: relative;cursor: move;user-select: none;background-color: #f5f7fa;.cell {position: relative;}&:hover {background-color: #e6f1fc;&::after {content: "⋮⋮";position: absolute;right: 16px;top: 50%;transform: translateY(-50%) rotate(90deg);color: #409EFF;font-size: 14px;opacity: 0.8;z-index: 1;}}}}/* 表格行样式 */.el-table__row {position: relative;cursor: move;transition: all 0.2s;td:first-child {position: relative;}&:hover {background-color: #f5f7fa;td:first-child::before {content: "⋮⋮";position: absolute;left: -15px;top: 50%;transform: translateY(-50%) rotate(90deg);color: #409EFF;font-size: 14px;opacity: 0.8;z-index: 1;}}}.sortable-ghost {background-color: #e6f1fc !important;opacity: 0.8;}.el-table__body-wrapper {overflow-x: hidden !important;}
}
</style>
Sortable.js 使用指南
目录
- 简介
- 安装
- 基本用法
- 配置选项
- 事件回调
- 常见示例
- 最佳实践
简介
Sortable.js 是一个功能强大的 JavaScript 库,用于实现拖拽排序功能。它支持现代浏览器,不依赖 jQuery 或其他库,可以在任何 HTML 元素上创建可拖拽排序的列表。
主要特点
- 支持触摸设备
- 支持跨列表拖拽
- 支持嵌套拖拽列表
- 无依赖
- 支持 Vue、React、Angular 等框架
安装
NPM 安装
npm install sortablejs
直接引入
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
基本用法
HTML 结构
<ul id="sortable"><li>Item 1</li><li>Item 2</li><li>Item 3</li>
</ul>
JavaScript 初始化
// 基本初始化
const el = document.getElementById('sortable');
const sortable = Sortable.create(el);// 带配置的初始化
const sortable = Sortable.create(el, {animation: 150,ghostClass: 'sortable-ghost'
});
配置选项
核心选项
选项 | 类型 | 默认值 | 描述 |
---|---|---|---|
group | String | Object | undefined | 用于跨列表拖拽的组名 |
sort | Boolean | true | 是否允许列表内排序 |
disabled | Boolean | false | 是否禁用排序 |
animation | Number | 0 | 动画过渡时间(毫秒) |
handle | String | null | 拖拽把手选择器 |
filter | String | Function | null | 不可拖拽项的选择器或函数 |
外观选项
选项 | 类型 | 默认值 | 描述 |
---|---|---|---|
ghostClass | String | null | 拖动时克隆元素的类名 |
chosenClass | String | null | 选中元素的类名 |
dragClass | String | null | 拖动中元素的类名 |
行为选项
选项 | 类型 | 默认值 | 描述 |
---|---|---|---|
delay | Number | 0 | 延迟拖动的时间(毫秒) |
delayOnTouchOnly | Boolean | false | 是否仅在触摸设备上使用延迟 |
preventOnFilter | Boolean | true | 是否阻止被过滤元素的默认事件 |
touchStartThreshold | Number | 0 | 触摸时移动多少像素才开始拖动 |
事件回调
主要事件
Sortable.create(el, {// 开始拖动onStart: function(evt) {const { item, from } = evt;},// 拖动中onMove: function(evt, originalEvent) {const { dragged, related, to } = evt;// 返回 false 可以阻止移动},// 改变列表onChange: function(evt) {const { newIndex, oldIndex } = evt;},// 添加到列表onAdd: function(evt) {const { item, from, to } = evt;},// 更新列表onUpdate: function(evt) {const { item, newIndex, oldIndex } = evt;},// 从列表移除onRemove: function(evt) {const { item, from, to } = evt;},// 拖动结束onEnd: function(evt) {const { item, from, to, newIndex, oldIndex } = evt;}
});
常见示例
基本列表排序
Sortable.create(document.getElementById('list'), {animation: 150,ghostClass: 'blue-background-class'
});
带把手的排序
Sortable.create(document.getElementById('list'), {handle: '.my-handle',animation: 150
});
跨列表拖拽
Sortable.create(document.getElementById('list1'), {group: 'shared',animation: 150
});Sortable.create(document.getElementById('list2'), {group: 'shared',animation: 150
});
禁止特定项目拖拽
Sortable.create(document.getElementById('list'), {filter: '.disabled',preventOnFilter: false
});
嵌套列表
Sortable.create(document.getElementById('nested'), {group: 'nested',animation: 150,fallbackOnBody: true,swapThreshold: 0.65
});
最佳实践
1. 性能优化
- 使用
animation
时设置合适的动画时长 - 大列表考虑使用虚拟滚动
- 避免在拖动时进行复杂计算
2. 用户体验
- 添加适当的视觉反馈(如
ghostClass
) - 使用
handle
明确可拖动区域 - 设置合适的
delay
防止意外拖动
3. 错误处理
- 在事件回调中添加错误处理
- 使用
try/catch
包装关键操作 - 保存重要数据的备份
4. 可访问性
- 添加适当的 ARIA 属性
- 确保键盘可访问性
- 提供替代操作方式
5. 调试技巧
- 使用
setData
和getData
跟踪数据变化 - 利用浏览器开发工具监控事件
- 添加日志记录关键操作
常见问题解决
1. 拖动不生效
- 检查元素是否可见
- 确认选择器是否正确
- 验证
handle
和filter
设置
2. 性能问题
- 减少列表项数量
- 优化事件处理器
- 使用防抖/节流
3. 移动设备问题
- 调整
touchStartThreshold
- 设置适当的
delay
- 确保触摸区域足够大
更多资源
- 官方文档
- API 参考
- 示例集合