Kanban看板开发实战:从零实现拖拽交互与状态管理

A. 兴慧 工具 阅读 2,736
赞 11 收藏
二维码
手机扫码查看
反馈

先看效果,再看代码

我最近在重构一个内部项目管理工具,老板非要加个 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,结果拖到一半列表乱序。记住:DraggablekeydraggableId 必须是任务的唯一 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+ 卡片)下的性能问题的?我试过虚拟滚动,但和拖拽库冲突,至今没完美解法。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论