Vue3拖拽排序:我试了三种方案,最后还是选了最老的那个
本文收录专栏「一个人用AI做工具矩阵」,关于智枢矩阵表单设计器的技术深潜系列。
做表单设计器,拖拽排序是绕不过去的坎——用户得能拖动字段调整顺序,体验才像那么回事。
我在智枢矩阵的表单设计器里试了三种方案:原生 HTML5 Drag & Drop、@vueuse/integrations 的 useSortable、vuedraggable。最后选了最老的那个,但不是因为懒。
一、需求场景:表单设计器的字段排序
智枢矩阵的表单设计器是一个三列布局:左侧字段库、中间预览区、右侧属性面板。用户在中间预览区拖拽字段调整顺序。
核心需求很简单:
1. 拖拽手柄排序,不是整行可拖(防止误触)
2. 拖拽过程有视觉反馈(占位、动画)
3. 排序结果实时同步到 Vue 响应式数据
4. 支持 20+ 种字段类型,每种渲染逻辑不同
这个需求不算复杂,但三种方案的体验差异比想象中大。
二、方案一:原生 HTML5 Drag & Drop
1. 实现思路
浏览器原生支持拖拽:给元素加 draggable="true",监听 dragstart、dragover、drop 事件,手动维护排序逻辑。
// 拖拽开始,记录源索引
const dragIndex = ref(null)
const handleDragStart = (e, index) => {
dragIndex.value = index
e.dataTransfer.effectAllowed = 'move'
}
// 拖拽经过,阻止默认行为允许放置
const handleDragOver = (e) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
// 放下,交换位置
const handleDrop = (e, dropIndex) => {
e.preventDefault()
const from = dragIndex.value
if (from === null || from === dropIndex) return
const list = [...props.fields]
const [moved] = list.splice(from, 1)
list.splice(dropIndex, 0, moved)
emit('update:fields', list)
dragIndex.value = null
}
2. 放弃原因
三个致命问题:
• 移动端完全不可用:HTML5 Drag & Drop 在移动端基本不触发,触摸事件得自己写一整套
• 拖拽手柄支持差:要实现"只有手柄可拖",需要自己判断 e.target,还要处理事件冒泡
• 没有动画过渡:元素直接跳到新位置,没有任何过渡效果,体验很生硬
原生方案的代码量看着不多,但要把体验做到位(动画、手柄、移动端),工作量反而最大。
三、方案二:@vueuse/integrations 的 useSortable
1. 实现思路
VueUse 是 Vue 生态里很活跃的工具库,@vueuse/integrations 封装了 Sortable.js,提供 useSortable composable。
import { useSortable } from '@vueuse/integrations/useSortable'
import type { SortableOptions } from 'sortablejs'
const list = ref([...props.fields])
const sortableOptions: SortableOptions = {
animation: 200,
handle: '.drag-handle',
ghostClass: 'sortable-ghost',
onEnd: (evt) => {
const { oldIndex, newIndex } = evt
if (oldIndex === undefined || newIndex === undefined) return
const item = list.value.splice(oldIndex, 1)[0]
list.value.splice(newIndex, 0, item)
emit('update:fields', list.value)
}
}
useSortable(el, list, sortableOptions)
2. 放弃原因
useSortable 本质还是 Sortable.js,但封装层带来两个问题:
• 响应式同步不够直观:Sortable.js 操作的是 DOM,Vue 管的是数据,两者不同步是经典坑。useSortable 用 onEnd 回调手动同步,和直接用 Sortable.js 没区别
• 模板写法不够声明式:得用 ref 拿 DOM 元素,在 onMounted 里初始化,模板里还是普通的 v-for,拖拽逻辑和渲染逻辑分离
用了一段时间,发现和直接写 Sortable.js 体验差不多,只是省了几行 import。
四、方案三:vuedraggable(最终选择)
1. 为什么选它
vuedraggable 是 Sortable.js 的 Vue 组件封装,虽然看着"老",但它在 Vue3 下做到了一件事:让拖拽排序像写 v-for 一样声明式。
对比一下模板写法的差异:
<!-- useSortable:普通 v-for + ref 绑定 -->
<div ref="el">
<div v-for="item in list" :key="item.label">
{{ item.label }}
</div>
</div>
<!-- vuedraggable:组件本身就是可排序列表 -->
<draggable
v-model="list"
:animation="200"
handle=".drag-handle"
ghost-class="sortable-ghost"
item-key="label"
>
<template #item="{ element }">
<div>{{ element.label }}</div>
</template>
</draggable>
vuedraggable 把 Sortable.js 的 DOM 操作和 Vue 的响应式数据绑在一起,不用手动同步。
2. 实际项目中的完整写法
智枢矩阵表单设计器的 FormPreview 组件,核心代码如下:
<script setup>
import draggable from 'vuedraggable'
const props = defineProps({
fields: { type: Array, default: () => [] },
selectedField: { type: Object, default: null },
fieldTypes: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:fields', 'select-field', 'delete-field'])
</script>
<template>
<draggable
:model-value="fields"
@update:model-value="emit('update:fields', $event)"
:animation="200"
handle=".drag-handle"
ghost-class="sortable-ghost"
class="space-y-3"
item-key="label"
>
<template #item="{ element, index }">
<div
class="preview-field"
:class="{ 'selected': selectedField === element }"
@click="emit('select-field', element)"
>
<div class="flex items-start gap-3">
<!-- 拖拽手柄 -->
<div class="drag-handle text-gray-300 hover:text-gray-500 mt-1
cursor-grab active:cursor-grabbing">
⋮⋮
</div>
<!-- 字段内容:20+种类型的渲染 -->
<div class="flex-1 min-w-0">
<!-- 字段标题行 -->
<div class="flex items-center gap-2 mb-2">
<el-icon :size="16" class="text-blue-500">
<component :is="fieldTypes.find(f => f.type === element.type)?.icon" />
</el-icon>
<span class="font-medium text-gray-800">{{ element.label }}</span>
<span v-if="element.required" class="text-red-500">*</span>
</div>
<!-- 根据字段类型渲染不同组件 -->
<el-input v-if="element.type === 'text'" ... />
<el-select v-else-if="element.type === 'select'" ... />
<el-radio-group v-else-if="element.type === 'radio'" ... />
<!-- ... 更多字段类型 -->
</div>
<!-- 删除按钮 -->
<el-button @click.stop="emit('delete-field', index)" text>
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</template>
</draggable>
</template>
<style scoped>
.sortable-ghost {
opacity: 0.4;
background: #E8F0FE;
border-radius: 8px;
}
</style>
3. 几个关键配置的解释
**1):model-value + @update:model-value 而非 v-model**
因为 fields 是 props,Vue3 不允许直接修改 props。用 :model-value 传入,排序变化时通过 emit 通知父组件更新:
:model-value="fields"
@update:model-value="emit('update:fields', $event)"
父组件 Builder.vue 中监听:
// html
<FormPreview
:fields="form.fields"
@update:fields="form.fields = $event"
/>
这是 Vue3 单向数据流的标准做法,vuedraggable 的文档没强调这一点,但实际项目里是必选写法。
**2)handle=".drag-handle" — 手柄拖拽**
如果不设 handle,整个字段区域都可以拖,但在表单设计器里,字段内部有输入框、选择器等交互组件,整行可拖会导致操作冲突。
设了 handle 后,只有鼠标按住 ⋮⋮ 手柄才能拖拽,点输入框、下拉框等正常交互不受影响。
**3)item-key="label" — 列表 Diff 的唯一标识**
Vue 的虚拟 DOM diff 需要唯一 key 来追踪元素。item-key 就是 vuedraggable 里的 :key。这里用字段的 label 做标识,因为同一表单内字段名不会重复。
如果用 index 做 key,拖拽排序时 Vue 会原地复用 DOM 节点而不是移动,导致输入框的值错位。
**4)ghost-class="sortable-ghost" — 占位样式**
拖拽时,原位置会出现一个"幽灵"占位元素。给它加半透明 + 蓝色背景,让用户知道"这个位置是空的,松手就会填上":
.sortable-ghost {
opacity: 0.4;
background: #E8F0FE;
border-radius: 8px;
}
**5):animation="200" — 过渡动画时长**
拖拽放下后,其他元素平滑移动到新位置,200ms 是比较舒服的节奏——太快看不出过渡,太慢感觉卡顿。
五、踩坑记录
1. 嵌套拖拽的事件穿透
表单设计器里字段可能分组(分割线分组),如果分组本身也可拖拽,内外层 draggable 的事件会穿透。
解决方法:内层 draggable 加 group 配置隔离作用域,或者给内层加 @pointerdown.stop 阻止事件冒泡。
目前智枢矩阵还没做分组拖拽,只做字段级别的排序,暂时没踩这个坑,但后续如果要加分组功能,这是第一个要处理的。
2. 条件显示字段与拖拽的冲突
表单支持字段条件显示(某个字段满足条件才显示),v-if 会导致拖拽索引错乱——Sortalbe.js 按 DOM 索引操作,但 Vue 可能隐藏了部分元素。
解决方法:条件隐藏不用 v-if,改用 CSS visibility: hidden + height: 0,让 DOM 结构稳定。或者用 computed 过滤出可见字段再传给 draggable,排序时再映射回原数组。
智枢矩阵目前用的是第二种:设计模式下所有字段可见,预览模式才做条件过滤,避免了索引错乱。
3. 移动端触摸兼容
vuedraggable 底层是 Sortable.js,自带触摸支持,但有个细节:移动端长按才能触发拖拽,和长按弹出菜单冲突。
解决方法:给 draggable 加 :delay="100" 和 :delay-on-touch-only="true",触摸端延迟 100ms 触发拖拽,避开系统长按事件。
六、三种方案最终对比
|
维度 |
原生 Drag & Drop |
useSortable |
vuedraggable |
|
移动端支持 |
❌ 不支持 |
✅ 支持 |
✅ 支持 |
|
拖拽手柄 |
需自己实现 |
配置 handle |
配置 handle |
|
过渡动画 |
需自己实现 |
配置 animation |
配置 animation |
|
Vue 响应式同步 |
手动维护 |
onEnd 手动同步 |
自动同步 |
|
模板声明式 |
❌ 命令式 |
❌ 半声明式 |
✅ 声明式 |
|
额外依赖 |
无 |
@vueuse/integrations + sortablejs |
vuedraggable(含 sortablejs) |
|
包体积增量 |
0 |
~30KB |
~35KB |
|
学习成本 |
高 |
中 |
低 |
结论很简单:如果项目已经用了 Sortable.js 生态(本质上三个方案都是),vuedraggable 是在 Vue3 里体验最好的封装。它牺牲了 5KB 的体积,换来了声明式模板和自动响应式同步,这笔账划算。
原生方案只在"零依赖"这个场景下有意义,但移动端不支持直接判死刑。useSortable 夹在中间,既没省掉手动同步,又加了 @vueuse 的依赖,定位尴尬。
线上体验:zhishujuzhen.com
你觉得 Vue3 里做拖拽排序还有什么更好的方案?评论区聊聊,我还没看到特别惊艳的新轮子。
更多推荐


所有评论(0)