Vue 基础体系 · 第 40/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue 拖拽交互:排序、跨容器、触摸、键盘和状态一致性

拖拽并不是“监听一个 drag 事件,然后交换两个数组元素”这么简单。一个完整的拖拽交互至少包含五个问题:

  1. 排序:同一容器内如何确定插入位置。
  2. 跨容器:拖动中的项目如何从一个列表移动到另一个列表。
  3. 触摸:没有鼠标按钮和悬停状态时,如何识别拖动意图。
  4. 键盘:无法或不便使用指针时,如何完成同样的移动操作。
  5. 状态一致性:界面、Vue 响应式状态、服务端持久化结果和异步请求发生冲突时,谁是可信来源。

这几个问题相互关联。排序算法决定数组如何变化,数组变化决定 Vue 如何重新渲染,渲染结果又影响下一次命中测试;触摸和键盘只是不同的输入方式,但最终都应该调用同一套“移动项目”的状态操作。


一、先定义拖拽模型:项目、容器和位置

设有多个容器,每个容器中有一个有序项目序列:

Cj=[xj,0,xj,1,,xj,nj1]C_j = [x_{j,0}, x_{j,1}, \dots, x_{j,n_j-1}]

其中:

  • CjC_j 表示第 jj 个容器;
  • xx 表示一个可拖拽项目;
  • 项目顺序由数组下标表示;
  • 一个项目在同一时刻只能属于一个容器。

项目通常至少包含稳定的 id

type Card = {
  id: string
  title: string
}

type Column = {
  id: string
  title: string
  cards: Card[]
}

这里的 id 不是显示文本,也不是当前数组下标。它必须在项目移动、排序、重新渲染甚至从服务端重新加载后仍然稳定。

状态的基本不变量是:

x,count(x)=1\forall x,\quad \operatorname{count}(x)=1

也就是说,每个项目必须在所有容器中出现且只出现一次。移动操作不能通过“先插入、稍后删除”暴露一个中间状态,否则渲染或持久化逻辑可能观察到重复项目。

第二个不变量是顺序唯一:

position(x)=(columnId,index)\operatorname{position}(x)=(columnId,index)

一个项目的位置由容器 ID 和容器内下标共同决定。跨容器拖拽改变两部分,同容器排序只改变下标。


二、原生 HTML 拖放和 Pointer Events 不是同一种方案

浏览器提供了 HTML Drag and Drop API:

<div draggable="true">任务 A</div>

它通常通过这些事件工作:

  • dragstart
  • dragover
  • drop
  • dragend

接收放置的元素一般需要在 dragover 中调用:

event.preventDefault()

否则浏览器通常不会把它视为可放置目标。

原生拖放的优点是桌面浏览器集成度高,拖动反馈和 DataTransfer 都由浏览器部分处理。但它有几个边界:

  • 触摸设备上的行为不完全统一;
  • 拖动预览图由浏览器控制,定制成本较高;
  • dragover 的频率和命中行为依赖浏览器;
  • 键盘拖拽并不会自动获得完整的可访问交互;
  • 拖动状态容易和 Vue 状态分离。

因此,如果一个组件同时要求鼠标、触摸、触控笔、跨容器、键盘和精确的状态控制,通常会使用 Pointer Events 自己实现拖动识别。

Pointer Events 将鼠标、触摸和触控笔统一为:

  • pointerdown
  • pointermove
  • pointerup
  • pointercancel

这不是 Vue 专属能力,而是现代浏览器平台能力。Vue 的职责是绑定事件、保存状态并根据状态渲染界面。


三、排序的核心:把“命中元素”转换为“插入下标”

拖拽时,指针通常落在某个卡片的矩形区域中。卡片本身是一个区域,而数组需要一个离散位置,因此必须定义转换规则。

对目标卡片 xix_i,设其矩形上下边界为 topbottom,中点为:

mi=topi+bottomi2m_i = \frac{top_i+bottom_i}{2}

若指针纵坐标为 yy

  • y<miy < m_i:插入到 xix_i 前面;
  • ymiy \ge m_i:插入到 xix_i 后面。

例如目标列表为:

[A, B, C]

指针落在 B 的上半部分,插入位置是:

[A, X, B, C]

指针落在 B 的下半部分,插入位置是:

[A, B, X, C]

这个规则比“只找最近卡片”更稳定,因为它直接对应用户看到的视觉分界线。

同容器排序的完整算例

初始状态:

待办:[A, B, C]
进行中:[D]

用户拖动 B

  1. 从待办数组删除 B
待办:[A, C]
  1. 指针落在 C 上半部,目标下标为 1
待办:[A, B, C]
  1. 指针落在 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)
}

在真实组件中,最好不要将 sourcetarget 从事件回调中长期保存。拖动过程中列表可能已经因为其他操作发生变化,应根据稳定 ID 重新定位当前状态。

拖动状态可以表示为:

type PointerDrag = {
  pointerId: number
  card: Card
  originColumnId: string
  originIndex: number
  currentColumnId: string
  startX: number
  startY: number
  active: boolean
}

其中:

  • originColumnIdoriginIndex 用于取消时恢复;
  • currentColumnId 表示拖动项目当前属于哪个容器;
  • active 区分“按下但尚未确认拖动”和“已经开始拖动”。

“按下后立即移动项目”会导致普通点击也改变列表,因此触摸和鼠标都应该设置一个移动阈值:

d=(xx0)2+(yy0)2d=\sqrt{(x-x_0)^2+(y-y_0)^2}

dd 小于例如 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 调用栈内立刻完成。

这带来两个事实:

  1. 当前事件中读取的 DOM 位置可能仍是旧布局;
  2. 下一次 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:移动被撤销。

状态机的价值不是形式漂亮,而是让每个事件都有明确前置条件。例如:

  • pointerupIdle 状态应被忽略;
  • EscapePointerDragging 状态应恢复;
  • ArrowDownKeyboardDragging 状态才改变位置;
  • 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 时,系统中断或用户取消会留下脏状态。

诊断方法是:

  1. 开始拖动;
  2. 在移动过程中切换标签页或触发浏览器手势;
  3. 回到页面后观察是否仍显示拖动态;
  4. 再次按下卡片,检查是否还能正常开始新拖动。

失败四:把拖动预览和正式数据混在一起

如果拖动项目通过 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、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。