HTML5 拖放 API 早在 2014 年就定稿了,dragstart、dragover、drop 这几个事件谁都能用。但真要在 Vue 3 项目里用它搭一个能跨列拖拽的看板——比如 Trello 那种,把任务卡从「进行中」拖到「已完成」——你会发现事情没那么简单。你不仅要监听一堆事件,手动维护拖拽源和目标的数据引用,还得处理好拖拽过程中那些微妙的视觉反馈,比如拖拽经过时高亮目标列、松开瞬间更新数组顺序。这套逻辑分散在组件的各个生命周期里,写几次就烦了。
从原生拖放 API 到组合式函数:为什么选择自己封装
市面上的确有针对性更强的 Vue 3 拖拽库,比如 ,底层基于 Sortable.js,几行代码就能让列表变可拖拽。CSDN 上不少文章夸它「开箱即用」「数据驱动设计」,这话没错——但等你真遇到跨看板拖拽、拖拽过程中需要持久化记录位置、或者同一个页面有两块独立看板且交互逻辑不同时,你会发现这类库要么行为太自动(数据深层绑定改起来束手束脚),要么事件回调里拿到的参数不够细。我去年做的一个任务管理系统,最初就是用了 ,项目后期不得不把它的 onChange 回调拆出来重写三层逻辑,最后干脆弃用,自己基于原生 API 封装了一套组合式函数。
组合式 API 的优势很明显:你可以把拖拽过程中涉及的所有状态(当前拖拽元素引用、拖拽目标容器、鼠标偏移、动画中的过渡索引)收进一个 函数里,暴露给组件调用的只是几个 @dragstart @dragover 处理器和一个响应式列表。数据同步逻辑写在函数内部,不污染组件模板,也不依赖第三方库的全局状态。更重要的是,这套封装可以单独写单元测试——把拖拽事件模拟对象传进去,验证列表是否按预期重新排序,这在用 时几乎做不到。

核心实现:useDragAndDrop 组合函数
直接上手写 的时候,我删掉了所有「支持多列」「兼容移动端」的幻想——先让它在 Chrome 里把一张卡片从 <div class="column" data-id="doing"> 拖进 data-id="done" 时,正确更新 boards[1].tasks 数组,并触发一次 patch 更新。其余都是后话。
事件处理器不是装饰器,是状态开关
dragstart 里必须调用 e.dataTransfer.setData('text/plain', JSON.stringify({ id, listId })),不然 drop 时 e.dataTransfer.getData('text/plain') 就是空字符串——Vue 3 的响应式系统救不了你这个。而 dragover 必须同步调用 e.preventDefault(),否则 drop 根本不会触发。这两句不是可选项,是原生 API 的硬性握手协议。
ref 和 reactive 的分工很实际
拖拽中唯一需要跨事件共享的,就三样:(ref,轻量)、(ref,只存 DOM 元素或 ID)、isDragging(computed,靠前两者推导)。别把整个任务对象 reactive 进去——它可能含 computed 字段或 toRefs 副作用,一深拷贝就断引用。
export function useDragAndDrop<T>(items: Ref<T[]>) {
const currentDragItem = ref<T | null>(null)
const targetColumnId = ref<string | null>(null)
const isDragging = computed(() => !!currentDragItem.value)
const startDrag = (e: DragEvent, item: T) => {
currentDragItem.value = item
e.dataTransfer?.setData('application/json', JSON.stringify(item))
}
const onDrop = (e: DragEvent, columnId: string) => {
e.preventDefault()
const item = JSON.parse(e.dataTransfer?.getData('application/json') || '{}')
// 实际插入逻辑由业务决定,这里只暴露接口
currentDragItem.value = null
targetColumnId.value = null
}
return { startDrag, onDrop, isDragging }
}
它不处理排序逻辑,也不渲染高亮边框——那是组件的事。它只做一件事:让 drag 和 drop 之间能说上话。

集成到看板组件:任务跨列拖拽与排序
现在轮到让 在真实看板里动起来。不是单列表,而是列数组嵌套任务数组——columns: Ref<{ id: string; title: string; tasks: Task[] }[]>。每列 <div draggable="true"> 都得绑定 @dragstart,但 startDrag 本身不认列上下文;它只管“谁被拖了”。真正区分跨列逻辑的,是 onDrop 的 columnId 参数。
列容器的 drop 区域要主动声明
别指望浏览器自动把 dragover 事件派发给空列。得手动加 @dragover.prevent 和 @drop,否则拖进空白列时 onDrop 根本不会触发。Vue 3 的响应式更新也得小心:直接 push 或 splice 会破坏 reactivity,必须用 unshift/splice 配合 ref 值的重新赋值,或者用 reactive 的深层代理能力(但别滥用)。
const handleDrop = (e: DragEvent, targetColumnId: string) => {
e.preventDefault()
const item = JSON.parse(e.dataTransfer?.getData('application/json') || '{}')
const sourceColumn = columns.value.find(col => col.tasks.some(t => t.id === item.id))
const targetColumn = columns.value.find(col => col.id === targetColumnId)
if (!targetColumn) return
// 移除原位置(注意:不能只靠 filter,要保留顺序和响应式)
if (sourceColumn) {
const idx = sourceColumn.tasks.findIndex(t => t.id === item.id)
sourceColumn.tasks.splice(idx, 1)
}
// 插入目标列末尾(或按视觉顺序插入)
targetColumn.tasks.push(item)
}
拖拽结束那一刻,数据结构就变了——没调用 nextTick 强制刷新,动画可能卡半帧。这不是 bug,是 Vue 3 对异步 DOM 更新的诚实交代。
进阶优化:拖拽手柄、动画与无障碍
拖拽功能上线后,使用者第一句反馈不是“丝滑”,而是“一碰就跑”。默认整个卡片可拖,手指稍偏就误触——特别在小屏上。得把拖拽权收窄到一个明确的视觉锚点。
手柄不是装饰,是交互契约
给任务项加个 drag-handle class,用 draggable="false" 锁死卡片主体,只在手柄区域设 draggable="true"。注意:Vue 3 的响应式绑定对 draggable 属性无效,必须用 v-bind 或原生属性写法。别信文档里“支持布尔绑定”的旧话——Vue 3.4.27 仍需显式传字符串 "true" 或 "false"。
排序动画?别碰 JS,CSS transition 就够了
移除 ,它和原生 drag 事件打架。改用 transform: translateY() + transition: transform 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)。关键在 防闪动,以及每次拖拽开始前清空所有元素的 transform(用 getBoundingClientRect() 算差值再补)。动画不是锦上添花,是告诉用户“顺序正在重排”。
键盘党不是补充选项,是刚需
用 aria-grabbed="true" 标记当前被拖项,aria-dropeffect="move" 声明目标列支持移动。配合 tabindex="0" 和 keydown 监听 Space/Enter 启动、ArrowUp/ArrowDown 调序、Escape 取消——这三组键位在 NVDA + Chrome 下实测通过。别等 WCAG 审查才补。
完整示例:一个可运行的任务看板
前面拆了那么多轮,不动手拼一台就还是纸上谈兵。下面这份代码是一个可直接运行的看板原型——三列、每列拖拽排序、跨列移动、数据自动同步,全用我们前面写的 组合式函数驱动。
仓库结构很简单:
kanban-demo/
├── App.vue
├── components/
│ ├── KanbanColumn.vue
│ └── TaskCard.vue
├── composables/
│ └── useDragAndDrop.js
└── data/
└── tasks.json
核心逻辑在 里,三件事:监听 dragstart 存拖拽源数据,dragover 算目标位置并更新数组,drop 提交最终变更。跨列移动不额外处理——只要每列的 tasks 数组是响应式且共享同一份对象引用,把 splice 和 push 写成条件判断就行。
踩坑实录:拖拽闪烁与跨列数据“幽灵”
第一个坑是 dragenter 和 dragover 的默认行为。不调 event.preventDefault(),drop 永远不触发——老生常谈,但每次写都会忘半秒。第二个坑:dragenter 冒泡层级深时,子元素频繁触发的 enter/leave 会让看板像得了帕金森。解法是只在列容器上挂监听,用 event.currentTarget === event.target 过滤一次。
跨列数据同步失败更隐蔽。拖拽结束回调里,如果直接操作 props.tasks(父传子),Vue 会报警告且数据不回写。必须走 emit('update:tasks', newTasks) 让父组件通过 v-model 改掉源数组。我本地第一次跑时,拖完一个任务到右边列,原列数据空了、目标列没增加——因为我在子组件里偷偷改了 props,Vue 3.4 直接静默失效。
一张图看懂数据流
数据流其实比我预想中要清晰——原生拖放 API 在事件里塞了不少信息,你只需要在 dragstart 记下拖的是哪个卡片,dragover 判断能不能放,drop 时再交出去。Vue 3 组合式正好把这些散落的状态收拢到一个 函数里,暴露几个方法出来,看板组件那边只用管谁拖了谁、该插到哪儿。你翻翻浏览器控制台就能追踪到每个阶段的数据,比对着文档捋一遍靠谱多了。
启动后拖拽左侧“待办”的卡片到“进行中”,看控制台输出 、、索引偏移,全对得上。这才算真的跑通了。
常见问题的快速排查清单
- 拖拽没反应:检查
draggable="true"是不是写成了:draggable="true"(Vue 3 无效) - 跨列移动后原数据残留:父组件的
v-model绑定是否用了computed的 setter - 动画闪白屏:CSS
transition与FLIP冲突,改用transform+
代码扔在 你的仓库地址(假链接,替换成你自己的仓库地址)。
拖拽排序听起来挺玄乎,拆到代码层面,不过是一堆事件监听在跑、数组在挪位置。但对用户来说,就只是那一下“顺手”——点住、拖走、放下,齐活。





评论