Vue 3 中如何实现看板组件的拖拽排序与列状态管理?

来源:网络学院作者:上海GEO公司头衔:草根站长
导读:本期聚焦于上海GEO公司创作的《Vue 3 中如何实现看板组件的拖拽排序与列状态管理?》,敬请观看详情。直接给卡片绑定原生拖拽事件,常常会在拖拽结束后发现数组顺序变了,但列里的数据却没有同步,或者刷新后状态丢失。问题不在浏览器的事件机制,而在于数据流没有和拖拽生命周期对齐。本文以Vue 3组合式API为基础,从看板的数据结构设计入手,说明如何用HTML5拖拽事件完成同列和跨列排序,再把折叠、锁定、容量限制这些列状态接入拖拽判断。最终可以抽出一个可复用的useKanban函数,让视图只负责渲染。示例代码尽量简单,不引入第三方拖拽库,方便理解每一步的状态变化。需要注意dragstart、dragover、drop三个事件各自的职责,以及为什么必须阻止dragover默认行为才能触发drop。

实现一个可拖拽看板组件,最容易被忽视的是数据结构。如果只把卡片放在当前列的局部数组里,拖拽跨列时就需要手动同步两个数组,容易漏掉状态更新。因此本文先把整个看板抽象成一个响应式对象,columns 是顶层数组,每列内部再维护 cards 和列状态。这样做以后,拖拽逻辑只需要操作 columns 里的引用,视图会自动更新。

Vue 3 中如何实现看板组件的拖拽排序与列状态管理?

从数据结构开始:让列和卡片保持单一数据源

看板组件常见的数据形态是一个二维数组,每个子数组代表一列,子数组里放卡片。这种结构在简单场景下能跑通,但一旦要加入折叠、锁定、容量限制等列级状态,就不得不把这些状态单独再维护一个对象,数据源被拆成两处。更好的做法是让每一列成为独立对象,列状态和卡片列表放在同一个层级。

下面用 TypeScript 定义一个看板数据模型:

interface Card {
  id: string;
  title: string;
  description?: string;
}

interface Column {
  id: string;
  title: string;
  cards: Card[];
  collapsed: boolean;
  locked: boolean;
  maxCards: number;
}

const board = ref<Column[]>([
  {
    id: 'todo',
    title: '待处理',
    cards: [
      { id: 'c1', title: '梳理接口文档' },
      { id: 'c2', title: '修复登录异常' }
    ],
    collapsed: false,
    locked: false,
    maxCards: 5
  },
  {
    id: 'doing',
    title: '进行中',
    cards: [],
    collapsed: false,
    locked: false,
    maxCards: 4
  }
]);

这里把 board 定义为 ref 而不是 shallowRef,因为后续拖拽时要通过 splice 直接修改深层数组,普通 ref 会触发深层代理,保证视图同步更新。如果使用 reactive 也一样,关键是要让列状态和卡片列表共享同一个响应式根对象。

拖拽事件拆解:dragstart、dragover、drop 各自干什么

HTML5 原生拖拽中,dragstart 在用户开始拖动元素时触发,此时可以往 dataTransfer 里写入数据,同时记录拖拽来源列和卡片索引。dragover 会在目标区域上方持续触发,默认行为是阻止放置,因此必须调用 preventDefault 才能让 drop 有机会执行。真正移动数组的操作放在 drop 里完成。

模板部分需要给每一张卡片设置 draggable="true",给每一列绑定 dragover.prevent 和 drop。示例:

<template>
  <div class="kanban-board">
    <div
      v-for="column in board"
      :key="column.id"
      class="kanban-column"
      @dragover.prevent
      @drop="handleDrop(column.id)"
    >
      <h3>{{ column.title }}</h3>
      <div
        v-for="(card, index) in column.cards"
        :key="card.id"
        class="kanban-card"
        draggable="true"
        @dragstart="handleDragStart($event, column.id, card.id, index)"
      >
        {{ card.title }}
      </div>
    </div>
  </div>
</template>

脚本部分维护一个 dragPayload 响应式对象,记录来源列 ID、卡片 ID 和来源索引。移动卡片时先找到来源列和目标列,从来源列中取出卡片,再插入到目标列末尾。同列排序时会有索引偏移问题,因为 splice 删除后原数组长度已经变化,直接使用旧的 sourceIndex 会插入到错误位置。最简单的方式是不做精确位置插入,只做追加,后续再结合鼠标位置计算插入点。

const dragPayload = reactive({
  sourceColumnId: '',
  cardId: '',
  sourceIndex: -1
});

function handleDragStart(event, columnId, cardId, index) {
  dragPayload.sourceColumnId = columnId;
  dragPayload.cardId = cardId;
  dragPayload.sourceIndex = index;
  event.dataTransfer.effectAllowed = 'move';
  event.dataTransfer.setData('text/plain', cardId);
}

function handleDrop(targetColumnId) {
  const sourceColumn = board.value.find(c => c.id === dragPayload.sourceColumnId);
  const targetColumn = board.value.find(c => c.id === targetColumnId);
  if (!sourceColumn || !targetColumn) return;

  const cardIndex = sourceColumn.cards.findIndex(c => c.id === dragPayload.cardId);
  if (cardIndex === -1) return;

  const [movedCard] = sourceColumn.cards.splice(cardIndex, 1);
  targetColumn.cards.push(movedCard);
}

这样就能完成基本的跨列拖拽和同列追加。如果要在同列中精确控制排序位置,可以在 dragover 中根据鼠标坐标计算插入索引,或者把卡片高度和偏移量存起来再处理。对于大多数看板场景,先实现追加已经满足需求,后续再逐步增强。

列状态管理:让折叠、锁定和容量限制参与拖拽

列状态不只是显示层面的开关,它会影响拖拽的目标合法性和卡片渲染数量。折叠状态可以用计算属性控制卡片列表是否渲染,锁定状态和容量限制则必须在 drop 入口处拦截。不能在 dragover 中只做视觉提示,而把真正的校验漏掉。

先给 handleDrop 增加一个校验函数:

function isDropAllowed(targetColumnId) {
  const targetColumn = board.value.find(c => c.id === targetColumnId);
  if (!targetColumn || targetColumn.locked) return false;
  if (targetColumn.cards.length >= targetColumn.maxCards) return false;
  return true;
}

function handleDrop(targetColumnId) {
  if (!isDropAllowed(targetColumnId)) return;
  const sourceColumn = board.value.find(c => c.id === dragPayload.sourceColumnId);
  const targetColumn = board.value.find(c => c.id === targetColumnId);
  if (!sourceColumn || !targetColumn) return;

  const cardIndex = sourceColumn.cards.findIndex(c => c.id === dragPayload.cardId);
  if (cardIndex === -1) return;

  const [movedCard] = sourceColumn.cards.splice(cardIndex, 1);
  targetColumn.cards.push(movedCard);
}

折叠状态可以放在模板中控制卡片列表是否显示。锁定列则建议在 dragover 阶段就给出视觉反馈,例如改变列背景色,但真正拖入校验仍放在 drop 中。折叠列是否允许拖入需要根据业务决定,如果折叠列只是隐藏卡片区域,那么拖入后用户看不到新卡片,体验较差。更好的做法是目标列如果处于折叠状态,先自动展开再接收卡片。

列状态切换的按钮和计数可以直接绑定到列对象:

<button @click="toggleColumn(column.id)">{{ column.collapsed ? '展开' : '折叠' }}</button>
<span>{{ column.cards.length }}/{{ column.maxCards }}</span>

这里不需要额外写状态同步逻辑,因为 column.collapsed 本身就是响应式属性,按钮点击后修改它即可。锁定状态同理,列标题旁边放一个锁定按钮,切换 column.locked,拖拽校验会自动生效。

抽离 useKanban:让组件只负责渲染

当拖拽逻辑和列状态逻辑都写在组件里时,模板会变得很臃肿,状态更新也难以单独测试。比较推荐的做法是封装一个 useKanban 组合式函数,把 board、dragPayload 以及所有操作方法都返回给组件。这样组件模板中只保留 v-for 和事件绑定,业务逻辑全部集中在组合式函数中。

export function useKanban() {
  const board = ref([]);
  const dragPayload = reactive({
    sourceColumnId: '',
    cardId: '',
    sourceIndex: -1
  });

  function handleDragStart(event, columnId, cardId, index) {
    dragPayload.sourceColumnId = columnId;
    dragPayload.cardId = cardId;
    dragPayload.sourceIndex = index;
    event.dataTransfer.effectAllowed = 'move';
    event.dataTransfer.setData('text/plain', cardId);
  }

  function isDropAllowed(targetColumnId) {
    const targetColumn = board.value.find(c => c.id === targetColumnId);
    if (!targetColumn || targetColumn.locked) return false;
    if (targetColumn.cards.length >= targetColumn.maxCards) return false;
    return true;
  }

  function handleDrop(targetColumnId) {
    if (!isDropAllowed(targetColumnId)) return;
    const sourceColumn = board.value.find(c => c.id === dragPayload.sourceColumnId);
    const targetColumn = board.value.find(c => c.id === targetColumnId);
    if (!sourceColumn || !targetColumn) return;

    const cardIndex = sourceColumn.cards.findIndex(c => c.id === dragPayload.cardId);
    if (cardIndex === -1) return;

    const [movedCard] = sourceColumn.cards.splice(cardIndex, 1);
    targetColumn.cards.push(movedCard);
  }

  function toggleColumn(columnId) {
    const column = board.value.find(c => c.id === columnId);
    if (column) column.collapsed = !column.collapsed;
  }

  function toggleLock(columnId) {
    const column = board.value.find(c => c.id === columnId);
    if (column) column.locked = !column.locked;
  }

  return {
    board,
    handleDragStart,
    handleDrop,
    toggleColumn,
    toggleLock
  };
}

组件中使用时只需要解构出需要的方法和状态,不再关心数据如何移动、容量是否达到上限。后续如果要增加卡片详情、评论数、标签等内容,也只需要扩展 Card 接口和组合式函数,不会影响拖拽主流程。这样看板组件的职责边界就变得清晰,排序逻辑和列状态管理可以独立演进。

Vue 3看板组件拖拽排序列状态管理修改时间:2026-10-06 12:28:09

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/1006/66455.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。