HTML5 拖放 API 早在 2014 年就定稿了,dragstartdragoverdrop 这几个事件谁都能用。但真要在 Vue 3 项目里用它搭一个能跨列拖拽的看板——比如 Trello 那种,把任务卡从「进行中」拖到「已完成」——你会发现事情没那么简单。你不仅要监听一堆事件,手动维护拖拽源和目标的数据引用,还得处理好拖拽过程中那些微妙的视觉反馈,比如拖拽经过时高亮目标列、松开瞬间更新数组顺序。这套逻辑分散在组件的各个生命周期里,写几次就烦了。

从原生拖放 API 到组合式函数:为什么选择自己封装

市面上的确有针对性更强的 Vue 3 拖拽库,比如 ,底层基于 Sortable.js,几行代码就能让列表变可拖拽。CSDN 上不少文章夸它「开箱即用」「数据驱动设计」,这话没错——但等你真遇到跨看板拖拽、拖拽过程中需要持久化记录位置、或者同一个页面有两块独立看板且交互逻辑不同时,你会发现这类库要么行为太自动(数据深层绑定改起来束手束脚),要么事件回调里拿到的参数不够细。我去年做的一个任务管理系统,最初就是用了 ,项目后期不得不把它的 onChange 回调拆出来重写三层逻辑,最后干脆弃用,自己基于原生 API 封装了一套组合式函数。

组合式 API 的优势很明显:你可以把拖拽过程中涉及的所有状态(当前拖拽元素引用、拖拽目标容器、鼠标偏移、动画中的过渡索引)收进一个 函数里,暴露给组件调用的只是几个 @dragstart @dragover 处理器和一个响应式列表。数据同步逻辑写在函数内部,不污染组件模板,也不依赖第三方库的全局状态。更重要的是,这套封装可以单独写单元测试——把拖拽事件模拟对象传进去,验证列表是否按预期重新排序,这在用 时几乎做不到。

drag and drop kanban board component

核心实现: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 })),不然 drope.dataTransfer.getData('text/plain') 就是空字符串——Vue 3 的响应式系统救不了你这个。而 dragover 必须同步调用 e.preventDefault(),否则 drop 根本不会触发。这两句不是可选项,是原生 API 的硬性握手协议。

ref 和 reactive 的分工很实际

拖拽中唯一需要跨事件共享的,就三样:ref,轻量)、ref,只存 DOM 元素或 ID)、isDraggingcomputed,靠前两者推导)。别把整个任务对象 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 之间能说上话。

drag handle icon CSS animation

集成到看板组件:任务跨列拖拽与排序

现在轮到让 在真实看板里动起来。不是单列表,而是列数组嵌套任务数组——columns: Ref<{ id: string; title: string; tasks: Task[] }[]>。每列 <div draggable="true"> 都得绑定 @dragstart,但 startDrag 本身不认列上下文;它只管“谁被拖了”。真正区分跨列逻辑的,是 onDropcolumnId 参数。

列容器的 drop 区域要主动声明

别指望浏览器自动把 dragover 事件派发给空列。得手动加 @dragover.prevent@drop,否则拖进空白列时 onDrop 根本不会触发。Vue 3 的响应式更新也得小心:直接 pushsplice 会破坏 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 数组是响应式且共享同一份对象引用,把 splicepush 写成条件判断就行。

踩坑实录:拖拽闪烁与跨列数据“幽灵”

第一个坑是 dragenterdragover 的默认行为。不调 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 transitionFLIP 冲突,改用 transform +

代码扔在 你的仓库地址(假链接,替换成你自己的仓库地址)。

拖拽排序听起来挺玄乎,拆到代码层面,不过是一堆事件监听在跑、数组在挪位置。但对用户来说,就只是那一下“顺手”——点住、拖走、放下,齐活。