Vue 基础体系 · 第 40/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Vue 拖拽交互:排序、跨容器、触摸、键盘和状态一致性
拖拽并不是“监听一个 drag 事件,然后交换两个数组元素”这么简单。一个完整的拖拽交互至少包含五个问题:
- 排序:同一容器内如何确定插入位置。
- 跨容器:拖动中的项目如何从一个列表移动到另一个列表。
- 触摸:没有鼠标按钮和悬停状态时,如何识别拖动意图。
- 键盘:无法或不便使用指针时,如何完成同样的移动操作。
- 状态一致性:界面、Vue 响应式状态、服务端持久化结果和异步请求发生冲突时,谁是可信来源。
这几个问题相互关联。排序算法决定数组如何变化,数组变化决定 Vue 如何重新渲染,渲染结果又影响下一次命中测试;触摸和键盘只是不同的输入方式,但最终都应该调用同一套“移动项目”的状态操作。
一、先定义拖拽模型:项目、容器和位置
设有多个容器,每个容器中有一个有序项目序列:
其中:
- 表示第 个容器;
- 表示一个可拖拽项目;
- 项目顺序由数组下标表示;
- 一个项目在同一时刻只能属于一个容器。
项目通常至少包含稳定的 id:
type Card = {
id: string
title: string
}
type Column = {
id: string
title: string
cards: Card[]
}
这里的 id 不是显示文本,也不是当前数组下标。它必须在项目移动、排序、重新渲染甚至从服务端重新加载后仍然稳定。
状态的基本不变量是:
也就是说,每个项目必须在所有容器中出现且只出现一次。移动操作不能通过“先插入、稍后删除”暴露一个中间状态,否则渲染或持久化逻辑可能观察到重复项目。
第二个不变量是顺序唯一:
一个项目的位置由容器 ID 和容器内下标共同决定。跨容器拖拽改变两部分,同容器排序只改变下标。
二、原生 HTML 拖放和 Pointer Events 不是同一种方案
浏览器提供了 HTML Drag and Drop API:
<div draggable="true">任务 A</div>
它通常通过这些事件工作:
dragstartdragoverdropdragend
接收放置的元素一般需要在 dragover 中调用:
event.preventDefault()
否则浏览器通常不会把它视为可放置目标。
原生拖放的优点是桌面浏览器集成度高,拖动反馈和 DataTransfer 都由浏览器部分处理。但它有几个边界:
- 触摸设备上的行为不完全统一;
- 拖动预览图由浏览器控制,定制成本较高;
dragover的频率和命中行为依赖浏览器;- 键盘拖拽并不会自动获得完整的可访问交互;
- 拖动状态容易和 Vue 状态分离。
因此,如果一个组件同时要求鼠标、触摸、触控笔、跨容器、键盘和精确的状态控制,通常会使用 Pointer Events 自己实现拖动识别。
Pointer Events 将鼠标、触摸和触控笔统一为:
pointerdownpointermovepointeruppointercancel
这不是 Vue 专属能力,而是现代浏览器平台能力。Vue 的职责是绑定事件、保存状态并根据状态渲染界面。
三、排序的核心:把“命中元素”转换为“插入下标”
拖拽时,指针通常落在某个卡片的矩形区域中。卡片本身是一个区域,而数组需要一个离散位置,因此必须定义转换规则。
对目标卡片 ,设其矩形上下边界为 top 和 bottom,中点为:
若指针纵坐标为 :
- :插入到 前面;
- :插入到 后面。
例如目标列表为:
[A, B, C]
指针落在 B 的上半部分,插入位置是:
[A, X, B, C]
指针落在 B 的下半部分,插入位置是:
[A, B, X, C]
这个规则比“只找最近卡片”更稳定,因为它直接对应用户看到的视觉分界线。
同容器排序的完整算例
初始状态:
待办:[A, B, C]
进行中:[D]
用户拖动 B:
- 从待办数组删除
B:
待办:[A, C]
- 指针落在
C上半部,目标下标为1:
待办:[A, B, C]
- 指针落在
C下半部,目标下标为2:
待办:[A, C, B]
如果实现不是“拖动开始时先移除,再计算插入下标”,而是对原数组直接计算,就必须处理源下标导致的偏移。例如从下标 1 移到下标 3 时,先删除源项目后,目标下标要减一。先移除拖动项目可以减少这一类分支。
四、跨容器拖拽本质上是一次原子移动
跨容器移动不是复制项目:
target.cards.push(card)
如果忘记从源容器删除,项目就会同时出现在两个容器中。
正确的逻辑应当在一个状态操作中完成:
function moveCard(
card: Card,
source: Column,
target: Column,
targetIndex: number
) {
const sourceIndex = source.cards.findIndex(item => item.id === card.id)
if (sourceIndex >= 0) {
source.cards.splice(sourceIndex, 1)
}
target.cards.splice(targetIndex, 0, card)
}
在真实组件中,最好不要将 source 和 target 从事件回调中长期保存。拖动过程中列表可能已经因为其他操作发生变化,应根据稳定 ID 重新定位当前状态。
拖动状态可以表示为:
type PointerDrag = {
pointerId: number
card: Card
originColumnId: string
originIndex: number
currentColumnId: string
startX: number
startY: number
active: boolean
}
其中:
originColumnId和originIndex用于取消时恢复;currentColumnId表示拖动项目当前属于哪个容器;active区分“按下但尚未确认拖动”和“已经开始拖动”。
“按下后立即移动项目”会导致普通点击也改变列表,因此触摸和鼠标都应该设置一个移动阈值:
当 小于例如 6 像素时,只视为点击;超过阈值后才进入拖动状态。这个数值是交互经验参数,不是浏览器规范。
五、Vue 3 中的完整示例
下面的组件使用:
- Vue 3;
<script setup lang="ts">;- Composition API;
- Pointer Events;
- 同容器排序;
- 跨容器移动;
- 键盘拾取、移动、放下和取消;
- 拖动取消后的恢复;
aria-live状态播报。
它不依赖第三方拖拽库,适合用来理解核心机制。
KanbanBoard.vue
<script setup lang="ts">
import { nextTick, ref } from 'vue'
type Card = {
id: string
title: string
}
type Column = {
id: string
title: string
cards: Card[]
}
type PointerDrag = {
pointerId: number
card: Card
originColumnId: string
originIndex: number
currentColumnId: string
startX: number
startY: number
active: boolean
}
type KeyboardDrag = {
card: Card
originColumnId: string
originIndex: number
}
const board = ref<HTMLElement | null>(null)
const columns = ref<Column[]>([
{
id: 'todo',
title: '待办',
cards: [
{ id: 'a', title: '设计页面' },
{ id: 'b', title: '编写接口' },
{ id: 'c', title: '补充测试' }
]
},
{
id: 'doing',
title: '进行中',
cards: [
{ id: 'd', title: '实现组件' }
]
},
{
id: 'done',
title: '已完成',
cards: [
{ id: 'e', title: '初始化项目' }
]
}
])
const pointerDrag = ref<PointerDrag | null>(null)
const keyboardDrag = ref<KeyboardDrag | null>(null)
const announcement = ref('')
function findColumn(columnId: string) {
return columns.value.find(column => column.id === columnId)
}
function findCard(cardId: string) {
for (const column of columns.value) {
const index = column.cards.findIndex(card => card.id === cardId)
if (index >= 0) {
return { column, index, card: column.cards[index] }
}
}
return undefined
}
function takeCard(cardId: string) {
const found = findCard(cardId)
if (!found) {
return undefined
}
found.column.cards.splice(found.index, 1)
return {
card: found.card,
columnId: found.column.id,
index: found.index
}
}
function insertCard(card: Card, columnId: string, index: number) {
const column = findColumn(columnId)
if (!column) {
throw new Error(`目标容器不存在:${columnId}`)
}
const safeIndex = Math.max(0, Math.min(index, column.cards.length))
column.cards.splice(safeIndex, 0, card)
}
function announce(card: Card, column: Column, index: number) {
announcement.value =
`${card.title},位于“${column.title}”第 ${index + 1} 项,共 ${column.cards.length} 项`
}
function focusCard(cardId: string) {
nextTick(() => {
const escapedId =
typeof CSS !== 'undefined' && CSS.escape
? CSS.escape(cardId)
: cardId
document
.querySelector<HTMLElement>(`[data-card-id="${escapedId}"]`)
?.focus()
})
}
function beginPointer(event: PointerEvent, cardId: string) {
if (!event.isPrimary || pointerDrag.value || keyboardDrag.value) {
return
}
const found = findCard(cardId)
if (!found) {
return
}
event.preventDefault()
pointerDrag.value = {
pointerId: event.pointerId,
card: found.card,
originColumnId: found.column.id,
originIndex: found.index,
currentColumnId: found.column.id,
startX: event.clientX,
startY: event.clientY,
active: false
}
board.value?.setPointerCapture(event.pointerId)
}
function activatePointerDrag(drag: PointerDrag) {
const removed = takeCard(drag.card.id)
if (!removed) {
pointerDrag.value = null
return
}
drag.active = true
drag.currentColumnId = removed.columnId
}
function getDropPosition(clientX: number, clientY: number) {
const element = document.elementFromPoint(clientX, clientY)
if (!element) {
return undefined
}
const cardElement = (element as HTMLElement).closest<HTMLElement>(
'[data-card-id]'
)
const columnElement = (element as HTMLElement).closest<HTMLElement>(
'[data-column-id]'
)
const columnId =
cardElement?.dataset.columnId ?? columnElement?.dataset.columnId
if (!columnId) {
return undefined
}
const column = findColumn(columnId)
if (!column) {
return undefined
}
if (!cardElement) {
return {
columnId,
index: column.cards.length
}
}
const cardId = cardElement.dataset.cardId
const currentIndex = column.cards.findIndex(card => card.id === cardId)
if (currentIndex < 0) {
return undefined
}
const rect = cardElement.getBoundingClientRect()
const insertAfter = clientY >= rect.top + rect.height / 2
return {
columnId,
index: currentIndex + (insertAfter ? 1 : 0)
}
}
function movePointerCard(clientX: number, clientY: number) {
const drag = pointerDrag.value
if (!drag || !drag.active) {
return
}
const position = getDropPosition(clientX, clientY)
if (!position) {
return
}
const target = findColumn(position.columnId)
if (!target) {
return
}
insertCard(drag.card, position.columnId, position.index)
drag.currentColumnId = position.columnId
const currentIndex = target.cards.findIndex(
card => card.id === drag.card.id
)
announce(drag.card, target, currentIndex)
/*
* 项目现在已经重新插入数组。
* 下一次 pointermove 会重新通过 elementFromPoint 获取最新 DOM 位置,
* 因此不依赖上一次事件缓存的矩形。
*/
}
function onPointerMove(event: PointerEvent) {
const drag = pointerDrag.value
if (!drag || event.pointerId !== drag.pointerId) {
return
}
if (!drag.active) {
const dx = event.clientX - drag.startX
const dy = event.clientY - drag.startY
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance < 6) {
return
}
activatePointerDrag(drag)
if (!drag.active) {
return
}
}
movePointerCard(event.clientX, event.clientY)
}
function restorePointerDrag() {
const drag = pointerDrag.value
if (!drag || !drag.active) {
pointerDrag.value = null
return
}
const current = findCard(drag.card.id)
if (current) {
current.column.cards.splice(current.index, 1)
}
insertCard(drag.card, drag.originColumnId, drag.originIndex)
const origin = findColumn(drag.originColumnId)
if (origin) {
announce(drag.card, origin, drag.originIndex)
}
pointerDrag.value = null
focusCard(drag.card.id)
}
function finishPointerDrag() {
const drag = pointerDrag.value
if (!drag) {
return
}
if (!drag.active) {
pointerDrag.value = null
return
}
const current = findCard(drag.card.id)
if (current) {
announce(drag.card, current.column, current.index)
}
pointerDrag.value = null
focusCard(drag.card.id)
}
function onPointerUp(event: PointerEvent) {
if (pointerDrag.value?.pointerId !== event.pointerId) {
return
}
finishPointerDrag()
}
function onPointerCancel(event: PointerEvent) {
if (pointerDrag.value?.pointerId !== event.pointerId) {
return
}
restorePointerDrag()
}
function startKeyboardDrag(cardId: string) {
if (pointerDrag.value || keyboardDrag.value) {
return
}
const found = findCard(cardId)
if (!found) {
return
}
const removed = takeCard(cardId)
if (!removed) {
return
}
keyboardDrag.value = {
card: removed.card,
originColumnId: removed.columnId,
originIndex: removed.index
}
announcement.value =
`${removed.card.title}已拾取。使用方向键移动,空格放下,Escape 取消。`
focusCard(cardId)
}
function moveKeyboardCard(direction: 'up' | 'down' | 'left' | 'right') {
const drag = keyboardDrag.value
if (!drag) {
return
}
const current = findCard(drag.card.id)
/*
* 键盘拖动开始时项目已从数组移除,因此 current 不存在。
* 当前容器通过单独状态保存。
*/
let currentColumnId: string
if (current) {
currentColumnId = current.column.id
} else {
const columnsContainingNoCard = columns.value.filter(column =>
column.cards.every(card => card.id !== drag.card.id)
)
/*
* 这里不能通过“找不到项目”推断当前容器。
* 生产代码应在 KeyboardDrag 中保存 currentColumnId。
*/
void columnsContainingNoCard
return
}
void currentColumnId
}
</script>
<template>
<section
ref="board"
class="board"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerCancel"
>
<article
v-for="column in columns"
:key="column.id"
class="column"
:data-column-id="column.id"
>
<h2>{{ column.title }}</h2>
<div
class="card-list"
:data-column-id="column.id"
role="list"
>
<button
v-for="card in column.cards"
:key="card.id"
class="card"
:data-card-id="card.id"
:data-column-id="column.id"
type="button"
role="listitem"
@pointerdown.stop="beginPointer($event, card.id)"
>
{{ card.title }}
</button>
<p v-if="column.cards.length === 0" class="empty">
将项目放到这里
</p>
</div>
</article>
<p class="sr-only" aria-live="polite">
{{ announcement }}
</p>
</section>
</template>
<style scoped>
.board {
display: flex;
gap: 16px;
align-items: flex-start;
}
.column {
width: 240px;
padding: 12px;
border: 1px solid #d9d9d9;
border-radius: 8px;
background: #f7f7f7;
}
.card-list {
min-height: 80px;
display: grid;
gap: 8px;
padding: 8px;
border-radius: 6px;
}
.card {
padding: 10px;
text-align: left;
border: 1px solid #ccc;
border-radius: 6px;
background: white;
cursor: grab;
touch-action: none;
}
.card:active {
cursor: grabbing;
}
.empty {
color: #777;
font-size: 14px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
</style>
上面的代码已经展示了指针拖动的完整主路径,但键盘移动函数还需要保存键盘拖动项目的当前容器。下面给出修正后的键盘部分。为了避免把两个输入系统错误地合并,键盘拖动应使用自己的状态:
type KeyboardDrag = {
card: Card
originColumnId: string
originIndex: number
currentColumnId: string
}
修改 startKeyboardDrag:
keyboardDrag.value = {
card: removed.card,
originColumnId: removed.columnId,
originIndex: removed.index,
currentColumnId: removed.columnId
}
然后用以下实现替换 moveKeyboardCard:
function moveKeyboardCard(direction: 'up' | 'down' | 'left' | 'right') {
const drag = keyboardDrag.value
if (!drag) {
return
}
const currentColumn = findColumn(drag.currentColumnId)
if (!currentColumn) {
return
}
let targetColumn = currentColumn
let targetIndex = 0
const currentIndex = currentColumn.cards.length
if (direction === 'up') {
targetIndex = Math.max(0, currentIndex - 1)
} else if (direction === 'down') {
targetIndex = Math.min(currentColumn.cards.length, currentIndex + 1)
} else {
const columnIndex = columns.value.findIndex(
column => column.id === currentColumn.id
)
const nextColumnIndex =
direction === 'left' ? columnIndex - 1 : columnIndex + 1
targetColumn = columns.value[nextColumnIndex]
if (!targetColumn) {
return
}
targetIndex = Math.min(currentColumn.cards.length, targetColumn.cards.length)
}
insertCard(drag.card, targetColumn.id, targetIndex)
drag.currentColumnId = targetColumn.id
const insertedIndex = targetColumn.cards.findIndex(
card => card.id === drag.card.id
)
announce(drag.card, targetColumn, insertedIndex)
/*
* 为了让下一次移动仍然能从数组中取出项目,
* 先删除再插入只是一个内部状态过程。
*/
targetColumn.cards.splice(insertedIndex, 1)
}
不过这里暴露出一个重要事实:键盘和指针拖拽都需要“拖动项目当前位置”,不能仅靠项目是否存在于数组中推导。更清晰的方式是把“计算新位置”和“提交移动”分开。
实际项目中建议使用如下数据结构:
type ActiveDrag = {
card: Card
origin: { columnId: string; index: number }
current: { columnId: string; index: number }
input: 'pointer' | 'keyboard'
}
拖动时可以把项目从正式列表中移除,并将它作为独立的拖动预览渲染;也可以保留项目并用占位元素表示原位置。两种方案都可以成立,但不能一部分逻辑假设“项目已移除”,另一部分逻辑又假设“项目仍在数组中”。
下面给出更稳定的键盘实现。它保留项目在数组中,只通过一个移动函数完成原子重排:
function moveCardBy(
cardId: string,
targetColumnId: string,
targetIndex: number
) {
const source = findCard(cardId)
const target = findColumn(targetColumnId)
if (!source || !target) {
return false
}
const [card] = source.column.cards.splice(source.index, 1)
/*
* 如果源容器和目标容器相同,删除源项目会让目标下标左移。
*/
let adjustedIndex = targetIndex
if (source.column.id === target.id && source.index < targetIndex) {
adjustedIndex -= 1
}
adjustedIndex = Math.max(0, Math.min(adjustedIndex, target.cards.length))
target.cards.splice(adjustedIndex, 0, card)
return true
}
键盘状态可以不移除项目,而是在按下空格时记录当前项目,然后方向键直接调用 moveCardBy:
type KeyboardDrag2 = {
cardId: string
originColumnId: string
originIndex: number
}
const keyboardDrag2 = ref<KeyboardDrag2 | null>(null)
function beginKeyboard(cardId: string) {
const found = findCard(cardId)
if (!found) {
return
}
keyboardDrag2.value = {
cardId,
originColumnId: found.column.id,
originIndex: found.index
}
announcement.value =
`${found.card.title}已拾取。使用方向键移动,空格放下,Escape 取消。`
}
function moveKeyboard(direction: 'up' | 'down' | 'left' | 'right') {
const drag = keyboardDrag2.value
if (!drag) {
return
}
const current = findCard(drag.cardId)
if (!current) {
return
}
const currentColumnIndex = columns.value.findIndex(
column => column.id === current.column.id
)
let targetColumn = current.column
let targetIndex = current.index
if (direction === 'up') {
targetIndex = Math.max(0, current.index - 1)
} else if (direction === 'down') {
targetIndex = Math.min(
current.column.cards.length - 1,
current.index + 1
)
} else {
const nextColumnIndex =
direction === 'left'
? currentColumnIndex - 1
: currentColumnIndex + 1
const nextColumn = columns.value[nextColumnIndex]
if (!nextColumn) {
return
}
targetColumn = nextColumn
targetIndex = Math.min(current.index, nextColumn.cards.length)
}
if (!moveCardBy(drag.cardId, targetColumn.id, targetIndex)) {
return
}
const updated = findCard(drag.cardId)
if (updated) {
announce(updated.card, updated.column, updated.index)
focusCard(drag.cardId)
}
}
function finishKeyboard() {
const drag = keyboardDrag2.value
if (!drag) {
return
}
keyboardDrag2.value = null
const current = findCard(drag.cardId)
if (current) {
announce(current.card, current.column, current.index)
focusCard(drag.cardId)
}
}
function cancelKeyboard() {
const drag = keyboardDrag2.value
if (!drag) {
return
}
moveCardBy(drag.cardId, drag.originColumnId, drag.originIndex)
keyboardDrag2.value = null
focusCard(drag.cardId)
}
卡片的键盘事件可以这样绑定:
<button
v-for="card in column.cards"
:key="card.id"
:data-card-id="card.id"
:data-column-id="column.id"
type="button"
@keydown.space.prevent="
keyboardDrag2 ? finishKeyboard() : beginKeyboard(card.id)
"
@keydown.enter.prevent="
keyboardDrag2 ? finishKeyboard() : beginKeyboard(card.id)
"
@keydown.escape.prevent="cancelKeyboard"
@keydown.arrowup.prevent="moveKeyboard('up')"
@keydown.arrowdown.prevent="moveKeyboard('down')"
@keydown.arrowleft.prevent="moveKeyboard('left')"
@keydown.arrowright.prevent="moveKeyboard('right')"
>
{{ card.title }}
</button>
这里的交互约定是:
- 空格或 Enter:拾取或放下;
- 上下方向键:同一容器内移动;
- 左右方向键:移动到相邻容器;
- Escape:取消并恢复到拾取前位置。
这不是浏览器对拖拽键盘行为的自动保证,而是组件作者设计的交互协议。关键是:键盘移动最终仍然调用 moveCardBy,不能复制一套只针对键盘的数组逻辑。
六、触摸拖动的关键不是“监听 touch”,而是处理手势冲突
触摸屏上没有 hover,用户按下卡片后还可能想滚动页面。因此组件不能把所有 pointerdown 都当成拖动。
最小触发流程是:
pointerdown
↓
记录起点,不改变列表
↓
pointermove
↓
移动距离超过阈值?
├─ 否:仍可能是点击或滚动
└─ 是:进入拖动
↓
命中目标并更新顺序
↓
pointerup 提交
CSS 中的 touch-action 会影响浏览器默认手势处理:
.card {
touch-action: none;
}
touch-action: none 可以让组件完全接管该元素上的平移手势,但副作用是用户无法从卡片区域正常滚动页面。对于卡片拖动区域较大的界面,这可能造成体验问题。
更细致的做法是只让拖动手柄禁止默认手势:
.drag-handle {
touch-action: none;
}
.card-body {
touch-action: auto;
}
然后只有手柄上的 pointerdown 才启动拖拽。这样卡片内容区域仍然可以滚动、选择文本或点击链接。
pointercancel 也必须处理。系统来电、浏览器手势接管、页面切换或触摸序列被中断时,可能收不到正常的 pointerup。如果只处理 pointerup,失败表现通常是:
- 页面永久显示“正在拖动”;
- 后续点击全部被当成拖动操作;
- 拖动项目没有归位;
- 指针捕获状态和组件状态不一致。
因此取消路径必须和 Escape 取消路径一样,恢复或清理状态。
七、命中测试和 DOM 渲染存在一个时序问题
指针事件发生在当前 DOM 上,而 Vue 数组修改是响应式更新。调用:
column.cards.splice(index, 0, card)
后,Vue 会安排组件更新,但 DOM 不一定在当前 JavaScript 调用栈内立刻完成。
这带来两个事实:
- 当前事件中读取的 DOM 位置可能仍是旧布局;
- 下一次
pointermove才可能观察到新顺序。
因此命中测试函数不能长期缓存所有卡片的 getBoundingClientRect() 结果。更安全的策略是:
- 每次
pointermove重新获取命中元素; - 每次重新读取目标卡片的矩形;
- 必要时使用
await nextTick()后再读取 DOM; - 不要使用拖动开始时保存的卡片位置。
例如:
async function moveAndMeasure() {
moveCardBy('b', 'doing', 1)
await nextTick()
const element = document.querySelector('[data-card-id="b"]')
if (element) {
const rect = element.getBoundingClientRect()
console.log(rect.top)
}
}
nextTick() 是 Vue 提供的 DOM 更新等待机制。它能等待当前批次的 Vue 更新完成,但不能保证图片加载、字体切换、动画或其他外部布局变化已经完成。
如果列表使用 CSS 动画,拖动中还可能出现“数组顺序已经改变,但元素仍在动画位置”的情况。此时需要决定:
- 拖动中禁用排序动画;
- 只显示占位符,拖动项目脱离正常布局;
- 或者把动画结束事件纳入状态机。
不能一边按照数组顺序计算,一边按照动画中的视觉位置猜测而不做协调。
八、Vue 的 key 决定了重排是否可靠
列表渲染应使用稳定 ID:
<div v-for="card in cards" :key="card.id">
{{ card.title }}
</div>
不要使用数组下标:
<div v-for="(card, index) in cards" :key="index">
{{ card.title }}
</div>
假设初始列表为:
下标 0:A
下标 1:B
使用下标作为 key 时,移动后变为:
下标 0:B
下标 1:A
Vue 可能认为:
key=0的节点仍然是“第 0 个节点”,只更新文本;key=1的节点仍然是“第 1 个节点”。
如果节点内部有输入框、焦点、局部组件状态或未提交编辑内容,状态就可能跟着位置而不是跟着项目移动。
稳定 ID 表达的是:
这个 DOM 节点属于项目 A
而数组下标表达的是:
这个 DOM 节点暂时位于第 0 个位置
排序交互需要前一种语义。
九、键盘可访问性不等于给元素加 tabindex
一个可键盘使用的拖拽组件至少要解决三个问题:
1. 项目是否可获得焦点
使用原生 <button> 通常比给普通 div 增加 tabindex="0" 更可靠,因为按钮已经具备焦点、键盘激活和语义基础。
2. 用户是否知道当前状态
视觉上可以增加:
.card[data-dragging='true'] {
outline: 2px solid #3578e5;
}
但视觉样式不能替代屏幕阅读器提示。aria-live="polite" 可以播报:
“编写接口,位于进行中第 2 项,共 3 项”
应避免只播报“移动成功”,因为用户还需要知道移动后的容器和位置。
3. 键盘操作是否有明确的提交和取消
键盘拖动最好有明确的两阶段:
普通状态
└─ Space/Enter → 已拾取
├─ Arrow → 预览并移动
├─ Space/Enter → 放下
└─ Escape → 恢复
如果方向键直接永久提交,而没有“拾取”状态,用户无法区分普通导航和拖拽操作,也难以取消误操作。
可访问性属性应反映真实语义,而不是随意添加属性。某些早期拖拽 ARIA 属性已经不再是推荐方向,不能仅依赖一个 aria-grabbed 字段解决完整交互。焦点管理、状态播报、键盘协议和视觉反馈必须共同完成任务。
十、拖动状态应该显式建模
拖拽组件常见的错误,是使用多个布尔变量:
const isDragging = ref(false)
const isTouching = ref(false)
const isKeyboardDragging = ref(false)
const hasDropTarget = ref(false)
这些变量可以组合出大量非法状态,例如:
isDragging = false
isKeyboardDragging = true
hasDropTarget = true
但键盘拖动并没有指针命中目标,hasDropTarget 的含义就不明确。
更适合的方式是把状态建模为有限状态机:
stateDiagram-v2
[*] --> Idle
Idle --> PointerPending: pointerdown
PointerPending --> Idle: pointerup
PointerPending --> PointerDragging: 超过移动阈值
PointerDragging --> Dropped: pointerup
PointerDragging --> Cancelled: pointercancel/Escape
Idle --> KeyboardDragging: Space/Enter
KeyboardDragging --> KeyboardDragging: 方向键
KeyboardDragging --> Dropped: Space/Enter
KeyboardDragging --> Cancelled: Escape
Dropped --> Idle
Cancelled --> Idle
关键状态含义如下:
Idle:没有活跃拖动;PointerPending:指针按下,但尚未超过阈值;PointerDragging:正在进行指针拖动;KeyboardDragging:项目已被键盘拾取;Dropped:移动已提交;Cancelled:移动被撤销。
状态机的价值不是形式漂亮,而是让每个事件都有明确前置条件。例如:
pointerup在Idle状态应被忽略;Escape在PointerDragging状态应恢复;ArrowDown在KeyboardDragging状态才改变位置;pointerdown不能在已有键盘拖动时重新开始。
十一、拖拽和持久化之间要区分本地状态与服务端状态
用户放下项目后,通常还要保存排序结果。例如发送:
{
"cardId": "b",
"fromColumn": "todo",
"toColumn": "doing",
"toIndex": 1
}
本地界面可以先乐观更新:
用户放下
↓
立即更新 Vue 状态
↓
界面显示新顺序
↓
发送服务端请求
这样交互不会等待网络。但这会产生异步失败路径:
本地:B 已移动到进行中
服务端:请求失败
此时有三种常见策略:
策略一:失败回滚
保存移动前快照,请求失败时恢复:
const before = structuredClone(columns.value)
moveCardBy('b', 'doing', 1)
try {
await saveOrder()
} catch {
columns.value = before
announcement.value = '保存失败,已恢复原顺序'
}
这种方式简单,但如果失败期间用户又完成了其他操作,直接恢复整个快照会覆盖后续合法操作。
策略二:重新从服务端加载
请求失败后重新获取服务端状态:
try {
await saveOrder()
} catch {
columns.value = await fetchBoard()
announcement.value = '保存失败,已重新加载服务端顺序'
}
它更接近服务端权威模型,但会让用户刚刚完成的其他本地修改消失。
策略三:操作日志和版本校验
发送操作以及客户端观察到的版本:
{
"baseVersion": 41,
"operation": {
"cardId": "b",
"toColumn": "doing",
"toIndex": 1
}
}
服务端发现当前版本不是 41 时拒绝操作,客户端再重新加载并提示冲突。这比直接保存完整数组更安全,因为完整数组可能覆盖其他用户刚刚完成的排序。
生产系统至少应考虑:
- 服务端是否是最终权威;
- 请求失败后是否回滚;
- 多标签页或多人协作是否可能并发修改;
- 用户连续拖动时请求是否按顺序提交;
- 服务端是否支持版本号或条件更新。
Vue 的响应式系统只能保证组件内状态变化能触发更新,不能自动解决网络并发和服务端冲突。
十二、连续拖动请求中的竞态
设用户快速完成两次移动:
操作 1:A → 位置 3
操作 2:A → 位置 1
客户端按顺序发送请求,但网络返回可能是:
请求 2 先完成
请求 1 后完成
如果服务端把每次请求都当作“当前排序的最终结果”,旧请求可能覆盖新请求。
可以使用递增客户端序号:
let latestOperation = 0
async function persist(operation: unknown) {
const sequence = ++latestOperation
try {
await saveToServer(operation)
if (sequence !== latestOperation) {
return
}
announcement.value = '顺序已保存'
} catch (error) {
if (sequence !== latestOperation) {
return
}
console.error(error)
announcement.value = '保存失败'
}
}
这只能防止客户端把旧请求结果当成最新反馈,不能阻止服务端接受旧请求。真正的服务端一致性仍需要:
- 版本号;
- 操作序列;
- 幂等请求 ID;
- 或服务端按资源版本拒绝过期更新。
如果接口支持 AbortController,也可以取消尚未完成的旧请求,但“取消客户端等待”不等于“服务端一定没有执行”。因此不能把请求取消当成完整的并发一致性方案。
十三、几个典型失败实现及其原因
失败一:使用数组下标作为拖动身份
draggingIndex.value = index
排序一次后,原来的项目可能已经不在这个下标。后续移动会操作错误项目。
应保存:
draggingId.value = card.id
然后每次根据 ID 查找当前位置。
失败二:只监听 mouseover
element.addEventListener('mouseover', ...)
触摸设备没有稳定的悬停概念,键盘也不会产生鼠标悬停事件。即使桌面浏览器能工作,触摸和键盘路径仍然断裂。
失败三:只处理 pointerup
没有处理 pointercancel 和 Escape 时,系统中断或用户取消会留下脏状态。
诊断方法是:
- 开始拖动;
- 在移动过程中切换标签页或触发浏览器手势;
- 回到页面后观察是否仍显示拖动态;
- 再次按下卡片,检查是否还能正常开始新拖动。
失败四:把拖动预览和正式数据混在一起
如果拖动项目通过 CSS transform 显示在指针附近,同时数组也在实时重排,可能出现:
视觉项目位置:由 transform 决定
插入命中位置:由数组 DOM 决定
两套坐标系不同,用户会看到项目在某处,但放下后出现在另一处。
要么使用“脱离列表的拖动预览 + 占位符”,要么让项目留在列表布局中,不要同时使用互相独立的定位逻辑。
失败五:空容器没有命中区域
如果容器没有项目,卡片矩形不存在:
[空数组]
此时 elementFromPoint 只能命中容器背景或外层元素。空容器必须有可见或至少有稳定尺寸的放置区域:
.card-list {
min-height: 80px;
}
并且要给它设置 data-column-id,命中后才知道目标容器。
失败六:直接保存整个数组而没有版本判断
await fetch('/api/board', {
method: 'PUT',
body: JSON.stringify(columns.value)
})
如果有其他客户端同时改动,最后到达的完整数组可能覆盖别人的更新。完整数组适合简单单用户场景;多人或多标签页场景应优先使用操作语义和版本校验。
十四、调试拖拽问题的有效顺序
拖拽问题通常不是单一事件失效,而是状态、DOM 和网络三者不同步。可以按以下顺序定位:
1. 先记录状态转换
console.table({
phase: pointerDrag.value?.active ? 'dragging' : 'pending',
cardId: pointerDrag.value?.card.id,
currentColumn: pointerDrag.value?.currentColumnId
})
确认是否经历了:
pointerdown → pending → dragging → pointerup
2. 再记录命中结果
const hit = getDropPosition(event.clientX, event.clientY)
console.log(hit)
如果目标容器 ID 错误,问题在 DOM 标记或 closest() 路径;如果目标下标错误,问题在卡片矩形和中点判断。
3. 检查数组不变量
const allCards = columns.value.flatMap(column => column.cards)
const ids = allCards.map(card => card.id)
console.assert(
new Set(ids).size === ids.length,
'存在重复卡片 ID'
)
同时检查项目总数是否变化:
console.assert(allCards.length === expectedCount)
4. 最后检查持久化
确认:
- 本地移动是否成功;
- 请求 payload 是否表示正确的目标位置;
- 服务端返回的版本是否被记录;
- 失败时是否回滚或重新加载;
- 旧请求是否可能覆盖新请求。
先排除本地状态问题,再排除 DOM 命中问题,最后排查网络,通常比直接查看最终页面更快。
十五、实现方案的取舍
使用原生 HTML Drag and Drop
适合:
- 桌面浏览器为主;
- 需要浏览器
DataTransfer; - 不要求精细的触摸体验;
- 能接受浏览器拖动预览差异。
风险是触摸和键盘需要额外补齐,且拖动行为不完全由 Vue 状态控制。
使用 Pointer Events 自行实现
适合:
- 同时支持鼠标、触摸和触控笔;
- 需要精确命中和跨容器;
- 需要自定义拖动预览;
- 希望所有输入最终调用同一套状态操作。
代价是需要自己处理阈值、指针捕获、取消、命中测试、滚动冲突、焦点和可访问性。
使用第三方拖拽组件
适合:
- 团队希望缩短实现时间;
- 交互模型接近库提供的模型;
- 能接受库的状态结构、事件语义和版本约束。
引入库并不会自动解决服务端一致性、错误回滚和键盘语义。使用前仍应确认:
- 是否基于原生 Drag and Drop 还是 Pointer Events;
- 是否真正支持触摸;
- 是否支持跨容器;
- 是否支持键盘;
- 是否有可访问性实现;
- 是否要求特定 Vue 或构建工具版本。
十六、最终的数据流应保持单向且可验证
一个可维护的拖拽组件,可以把数据流固定为:
用户输入
↓
识别拖动意图
↓
计算目标位置
↓
调用统一移动函数
↓
更新 Vue 状态
↓
Vue 重新渲染 DOM
↓
播报和视觉反馈
↓
持久化操作
↓
成功确认或失败恢复
其中最重要的是统一移动函数:
moveCardBy(cardId, targetColumnId, targetIndex)
鼠标、触摸和键盘不应该分别修改数组。它们只负责产生不同形式的目标位置:
- 指针:由屏幕坐标和卡片中点计算;
- 触摸:仍由 Pointer Events 提供坐标,但要先通过移动阈值;
- 键盘:由方向键直接产生目标容器和目标下标。
只要所有输入都经过同一套状态变更逻辑,就可以统一保证:
- 项目不会重复;
- 源容器会正确删除;
- 目标下标会正确调整;
- Vue 使用稳定
key; - 取消可以恢复;
- 持久化可以记录同一种操作语义。
拖拽的难点不在于“如何让一个元素跟随指针”,而在于把连续坐标、离散顺序、多种输入、异步保存和取消恢复收敛为一个可验证的状态模型。排序、跨容器、触摸和键盘只是这个模型的不同入口;状态一致性才是组件能否长期稳定运行的基础。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 长列表虚拟化:可视区、动态高度、滚动锚点和性能
- 下一篇:Vue 封装浏览器 API:ResizeObserver、IntersectionObserver 和剪贴板
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论