Kanban看板开发实战:从零实现拖拽交互与状态管理
先看效果,再看代码
我最近在重构一个内部项目管理工具,老板非要加个 Kanban 看板功能。说实话,一开始我是拒绝的——拖拽、列间移动、状态同步,光想想就头大。但折腾了两天后发现,用现成的库其实没那么难,关键是要选对方案。下面直接上我亲测有效的实现方式。
核心依赖我选了 react-beautiful-dnd,虽然它已经停止维护,但在中小型项目里依然稳如老狗。如果你用 Vue,可以试试 vue-slicksort 或者 sortablejs 搭配自定义逻辑,但 React 生态下目前还没找到比它更顺手的(别杠,我知道有 DnD Kit,但配置太重,小项目没必要)。
核心代码就这几行
先看最简结构。假设我们有三列:待办、进行中、已完成。每列是一个 Droppable,每个卡片是一个 Draggable。数据结构长这样:
const initialData = {
columns: {
todo: { id: 'todo', title: '待办', taskIds: ['task-1', 'task-2'] },
inProgress: { id: 'inProgress', title: '进行中', taskIds: [] },
done: { id: 'done', title: '已完成', taskIds: [] }
},
tasks: {
'task-1': { id: 'task-1', content: '修复登录页样式' },
'task-2': { id: 'task-2', content: '对接用户API' }
},
columnOrder: ['todo', 'inProgress', 'done']
};
然后主组件这么写:
import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd';
function KanbanBoard({ data }) {
const [state, setState] = useState(data);
const onDragEnd = (result) => {
const { source, destination } = result;
if (!destination) return;
const start = state.columns[source.droppableId];
const finish = state.columns[destination.droppableId];
if (start === finish) {
// 同一列内排序
const newTaskIds = Array.from(start.taskIds);
newTaskIds.splice(source.index, 1);
newTaskIds.splice(destination.index, 0, result.draggableId);
const newColumn = { ...start, taskIds: newTaskIds };
setState({
...state,
columns: { ...state.columns, [newColumn.id]: newColumn }
});
return;
}
// 跨列移动
const startTaskIds = Array.from(start.taskIds);
startTaskIds.splice(source.index, 1);
const newStart = { ...start, taskIds: startTaskIds };
const finishTaskIds = Array.from(finish.taskIds);
finishTaskIds.splice(destination.index, 0, result.draggableId);
const newFinish = { ...finish, taskIds: finishTaskIds };
setState({
...state,
columns: {
...state.columns,
[newStart.id]: newStart,
[newFinish.id]: newFinish
}
});
};
return (
<DragDropContext onDragEnd={onDragEnd}>
{state.columnOrder.map(colId => {
const column = state.columns[colId];
const tasks = column.taskIds.map(taskId => state.tasks[taskId]);
return (
<Droppable key={column.id} droppableId={column.id}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className="kanban-column"
>
<h3>{column.title}</h3>
{tasks.map((task, index) => (
<Draggable key={task.id} draggableId={task.id} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className="kanban-card"
>
{task.content}
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
);
})}
</DragDropContext>
);
}
配套的 CSS 别太花哨,重点是给拖拽元素留出足够空间,避免卡顿:
.kanban-column {
width: 300px;
background: #f8f9fa;
padding: 12px;
margin-right: 16px;
min-height: 500px;
border-radius: 4px;
}
.kanban-card {
background: white;
padding: 12px;
margin-bottom: 8px;
border-radius: 4px;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
cursor: grab;
}
踩坑提醒:这三点一定注意
第一,key 值必须稳定。我一开始用数组索引当 key,结果拖到一半列表乱序。记住:Draggable 的 key 和 draggableId 必须是任务的唯一 ID,不能是 index。
第二,跨列移动时要深拷贝。上面代码里用了 Array.from() 和展开运算符,就是为了避免直接修改原 state。React 的 immutable 更新规则在这里特别重要,否则拖拽完 UI 不更新。
第三,移动端 touch 事件可能失效。react-beautiful-dnd 默认只支持鼠标,如果要支持手机,得额外加 polyfill 或者换库。我后来在移动端干脆禁用了拖拽,直接点卡片改状态——用户体验反而更好,毕竟手机上拖来拖去太反人类。
这个场景最好用
我发现 Kanban 看板最适合状态流转明确的场景。比如 bug 跟踪:新建 → 处理中 → 已修复 → 已验证。每个列代表一个状态,拖动就是改变状态,后端只需要接收一个 status 字段更新就行。
但如果是自由分组(比如按优先级、按负责人),那就别硬套 Kanban。这时候用可折叠的列表 + 标签筛选更合适。我之前强行把 Kanban 用在需求池里,结果产品经理天天抱怨“为什么不能把两个需求合并成一个列”,最后还得返工。
高级技巧:拖拽时加个 loading 态
用户拖动卡片时,如果后端要实时保存,最好给个视觉反馈。我的做法是在 onDragStart 里加个全局 loading 标志,配合 CSS 让卡片半透明:
const [isDragging, setIsDragging] = useState(false);
const onDragStart = () => setIsDragging(true);
const onDragEnd = (result) => {
setIsDragging(false);
// ...原有逻辑
// 这里可以加个防抖,避免频繁请求
if (result.destination) {
saveToServer(result); // 伪代码
}
};
然后在卡片上加 class:
.kanban-card.is-dragging {
opacity: 0.6;
transform: rotate(2deg);
}
注意:别在 onDragEnd 里直接调 API,万一失败了用户都不知道。我建议先更新本地状态,再异步同步到服务器,失败了 toast 提示“保存失败,已恢复本地状态”。
后续还能怎么玩
其实 Kanban 还能结合更多功能:比如卡片内嵌评论、截止日期提醒、自动归档过期任务。不过这些属于业务逻辑了,核心拖拽框架搭好后,加起来并不难。
另外,如果你用 Next.js,记得把 react-beautiful-dnd 放到动态导入里,避免 SSR 报错:
import dynamic from 'next/dynamic';
const KanbanBoard = dynamic(() => import('../components/KanbanBoard'), { ssr: false });
以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,后续会继续分享这类博客。有更优的实现方式欢迎评论区交流——比如你们是怎么处理大量数据(1000+ 卡片)下的性能问题的?我试过虚拟滚动,但和拖拽库冲突,至今没完美解法。

暂无评论