外观
Sortable.js 拖拽排序
2026-04-01
版本: 1.15.x 官网: sortablejs.com
GitHub: SortableJS/Sortable
Sortable.js 是轻量、无依赖的拖拽排序库,支持列表、网格、跨容器拖拽。在 Nebula 前端用于表格行排序、菜单顺序调整等场景。
安装
pnpm add sortablejs
pnpm add -D @types/sortablejs # TypeScript 类型基础用法 — 列表拖拽排序
<template>
<ul ref="listRef" class="sort-list">
<li v-for="item in list" :key="item.id" class="sort-item">
<span class="drag-handle">⋮⋮</span>
{{ item.name }}
</li>
</ul>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import Sortable from 'sortablejs'
const listRef = ref<HTMLElement>()
let sortable: Sortable | null = null
const list = ref([
{ id: 1, name: '菜单项 A', sort: 1 },
{ id: 2, name: '菜单项 B', sort: 2 },
{ id: 3, name: '菜单项 C', sort: 3 },
])
onMounted(() => {
if (!listRef.value) return
sortable = Sortable.create(listRef.value, {
animation: 150, // 拖拽动画时长(ms)
handle: '.drag-handle', // 只有拖拽手柄才能触发(不设置则整行可拖)
ghostClass: 'drag-ghost', // 占位元素的 CSS 类名
chosenClass: 'drag-chosen', // 被拖拽元素的 CSS 类名
onEnd(event) {
const { oldIndex, newIndex } = event
if (oldIndex === undefined || newIndex === undefined) return
// 更新本地数组顺序
const [moved] = list.value.splice(oldIndex, 1)
list.value.splice(newIndex, 0, moved)
// 同步 sort 字段
list.value.forEach((item, idx) => { item.sort = idx + 1 })
// 调用接口持久化排序
saveSortOrder(list.value.map(item => ({ id: item.id, sort: item.sort })))
},
})
})
onBeforeUnmount(() => {
sortable?.destroy()
sortable = null
})
async function saveSortOrder(sortData: { id: number; sort: number }[]) {
try {
await updateSortApi(sortData)
ElMessage.success('排序已保存')
} catch {
ElMessage.error('排序保存失败')
}
}
</script>
<style scoped>
.sort-item {
display: flex;
align-items: center;
padding: 8px 12px;
border: 1px solid #e4e7ed;
margin-bottom: 4px;
border-radius: 4px;
background: #fff;
cursor: default;
}
.drag-handle {
cursor: grab;
margin-right: 8px;
color: #c0c4cc;
font-size: 18px;
line-height: 1;
}
.drag-ghost {
opacity: 0.5;
background: #ecf5ff;
}
.drag-chosen {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
</style>与 vxe-table 结合(表格行拖拽排序)
<template>
<vxe-table ref="tableRef" :data="tableData" row-key>
<vxe-column width="50" title="排序">
<template #default>
<span class="drag-handle" style="cursor: grab; font-size: 18px; color: #c0c4cc">⋮⋮</span>
</template>
</vxe-column>
<vxe-column field="name" title="名称" />
<vxe-column field="sort" title="序号" width="80" />
</vxe-table>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import Sortable from 'sortablejs'
import type { VxeTableInstance } from 'vxe-table'
const tableRef = ref<VxeTableInstance>()
const tableData = ref<any[]>([])
let sortable: Sortable | null = null
onMounted(async () => {
await loadData()
await nextTick()
initSortable()
})
function initSortable() {
// vxe-table 的 tbody 选择器
const tbody = tableRef.value?.$el?.querySelector('.vxe-table--body tbody')
if (!tbody) return
sortable = Sortable.create(tbody as HTMLElement, {
animation: 150,
handle: '.drag-handle',
onEnd({ oldIndex, newIndex }) {
if (oldIndex === undefined || newIndex === undefined || oldIndex === newIndex) return
const data = [...tableData.value]
const [moved] = data.splice(oldIndex, 1)
data.splice(newIndex, 0, moved)
// 重新赋值触发响应式更新
tableData.value = data.map((item, idx) => ({ ...item, sort: idx + 1 }))
saveSortOrder(tableData.value)
},
})
}
onBeforeUnmount(() => {
sortable?.destroy()
})
</script>常用配置项
| 配置项 | 类型 | 说明 |
|---|---|---|
animation | number | 拖拽移动动画时长,推荐 150 |
handle | string | 拖拽触发的 CSS 选择器(不设置整个元素都可拖) |
group | string | object | 跨容器拖拽分组名(相同 group 的容器可互相拖入) |
ghostClass | string | 占位元素的 CSS 类名 |
chosenClass | string | 正在拖拽的元素的 CSS 类名 |
disabled | boolean | 是否禁用拖拽 |
onEnd | function | 拖拽结束回调,oldIndex / newIndex 是移动前后的索引 |
常见问题
| 问题 | 解决方案 |
|---|---|
| 拖拽后数据顺序更新了但视图没变 | 数组操作需触发响应式,直接 splice 或重新赋值 ref |
vxe-table 找不到 tbody | 确保在 onMounted + nextTick 之后初始化 Sortable |
| 页面切换后拖拽异常 | 在 onBeforeUnmount 中调用 sortable.destroy() |
| 拖拽到边界时容器滚动 | 设置 scroll: true(默认已开启) |
