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

从数据结构开始:让列和卡片保持单一数据源
看板组件常见的数据形态是一个二维数组,每个子数组代表一列,子数组里放卡片。这种结构在简单场景下能跑通,但一旦要加入折叠、锁定、容量限制等列级状态,就不得不把这些状态单独再维护一个对象,数据源被拆成两处。更好的做法是让每一列成为独立对象,列状态和卡片列表放在同一个层级。
下面用 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 接口和组合式函数,不会影响拖拽主流程。这样看板组件的职责边界就变得清晰,排序逻辑和列状态管理可以独立演进。