返回探索
指南VibeFix 编辑部更新于 2026年10月18日

卡片拖起来才顺滑:vibe 项目的拖拽看板实战

一张卡片从 A 列拖到 B 列第 3 位,如何拖得顺滑、落得可回滚、刷新不断序?2026 选型先避开已归档的 react-beautiful-dnd,fractional indexing 让一次拖动只写一行,dnd-kit 最小闭环 + 乐观并发 + 移动端手势隔离 + 12 项上线清单,一周做出 Trello 八成功力的看板。

题图:Trello 看板界面截图,多列任务卡片

先划两条界,免得和站内已有的指南撞车:本文不讲多人同时在线拖卡片时的光标同步与 CRDT 合并算法——那是《多人实时协同编辑指南》的地盘;也不讲数据表格的行列展示与批量编辑,请移步《数据表格指南》。本文只回答一个具体问题:一张卡片从 A 列拖到 B 列第 3 位,如何做到拖得顺滑、落得可回滚、刷新后不断序?

看板拖拽是 vibe 项目里"看着简单、处处是坑"的典型功能。独立开发者用 AI 生成一个看板界面只需要十分钟,但当真实用户开始用它管理几十上百张卡片时,坑会一个个冒出来:拖动时掉帧卡顿、手指松开后卡片"弹回"原位、刷新页面顺序错乱、两个人同时拖同一张卡丢更新。用户的心智基准是 Trello 和 Linear 级别的顺滑——卡片要"粘"在手指上,落点要可预测,松手后要立刻有反馈。本文按实战顺序讲透:选型避坑、排序持久化、dnd-kit 实战搭建、并发冲突、移动端与无障碍、性能、上线检查清单。每一节都是可直接落地的代码与决策,没有"视情况而定"的废话。

一、为什么看板拖拽"看着简单、处处是坑"

先给"顺滑"一个可量化的定义,否则你和用户永远在鸡同鸭讲。一个合格的看板拖拽要同时满足三条:

  • 拖拽帧率:手指/鼠标移动时,卡片镜像的跟随延迟要低于 50ms,视觉上就是"粘在手上"。掉帧的根因几乎永远是"拖动时触发了整列重渲染"——拖动事件每秒触发几十次,任何在 onDragMove 里 setState 全量数据的写法都会让页面抽搐。
  • 落点可预测:用户把卡片拖到两张卡之间松手,落点必须就是那条缝隙。如果落点算法(collision detection)选错,卡片会"跳"到意料之外的位置,用户会觉得这个产品"不听话"。看板场景里,推荐用基于矩形中心点距离的算法,而不是矩形相交面积算法——后者在列很长、卡片很密时抖动明显。
  • 操作可撤销:松手后如果服务端写入失败,界面必须能回滚到拖动前的位置,而不是让卡片"卡"在错误的位置。这意味着拖拽操作在前端必须是一个"乐观更新 + 确认/回滚"的完整事务,而不是一次 fire-and-forget 的 setState。

vibe 项目最常见的四个翻车现场,对号入座:第一,拖动掉帧——把拖拽状态放在了全局 store 里,每动一像素整棵树重渲染;第二,松手弹回——乐观更新写了本地,但服务端返回失败,前端没有回滚逻辑,卡片视觉上跳回去,用户以为"没拖动成功"又拖一次,结果服务端其实写入了两次;第三,刷新断序——顺序只存在前端内存里,根本没有持久化到数据库,或者持久化了但并发写入时互相覆盖;第四,多人同拖丢更新——两个人同时拖同一张卡,后写入的把先写入的静默覆盖,没有任何一方收到提示。接下来的章节逐个拆解这些坑的填法。

二、2026 年选型决策:先避开归档库

2026 年做技术选型,第一件事不是"哪个最好用",而是"哪个已经死了"。拖拽库这个赛道过去两年发生了一件大事,直接决定了你的选型下限。

react-beautiful-dnd 已于 2025-04-30 被 Atlassian 正式弃用,GitHub 仓库归档。这是个硬事实:npm 包被标记 deprecated,仓库只读归档,不再接受任何更新,包括安全修复。如果你让 AI 给你生成看板代码,它很可能因为训练数据的惯性吐出 react-beautiful-dnd 的写法——新项目再用它,就是主动背上技术债。已经在用的老项目也不用恐慌,社区有 1:1 的平替方案。

2026 年的四个现实选项,我按"新项目 / 老项目 / 特殊需求"给你排好:

库状态适合谁注意
dnd-kit活跃维护,新项目首选绝大多数 vibe 项目模块化设计,collision 算法可换,触屏友好;API 比 r-b-dnd 啰嗦一点,但可控性强得多
@hello-pangea/dndr-b-dnd 的社区 fork,v18+ 支持 React 19老项目从 r-b-dnd 迁移API 几乎 1:1,迁移成本最低;但它本质是旧架构的延续,新项目不建议
pragmatic-drag-and-dropAtlassian 官方后继,活跃对包体积敏感、需要极致定制基于原生 HTML5 DnD,轻量但 API 偏底层,要自己搭很多脚手架
原生 HTML5 Drag & Drop API浏览器原生,零依赖几乎不推荐移动端几乎不可用,拖拽镜像样式定制极难;除非你的用户 100% 桌面端

决策逻辑很简单,三条线:第一,新项目无脑选 dnd-kit,它是目前唯一在"可定制性"和"开箱即用"之间取得平衡的库,看板、排序列表、多容器拖拽都有官方示例;第二,老项目如果已经在用 r-b-dnd,换 @hello-pangea/dnd 做最小迁移,别重写;第三,只有当你对包体积有执念、且团队能 hold 住底层 API 时,才考虑 pragmatic-drag-and-drop。原生 HTML5 DnD 的坑在于移动端:手机浏览器对它的支持是"能用但难用",触屏拖拽基本要自己重写一套 pointer 事件逻辑——而 dnd-kit 的传感器体系已经把这部分做掉了。

本文的实战代码全部基于 dnd-kit,因为它是 2026 年新项目的最优解,也是坑最多、最需要讲透的一个。

三、排序持久化:fractional indexing,一次讲透

这是整篇文章技术含量最高的一节,也是 vibe 项目最容易写错的地方。问题很直白:卡片拖到新位置后,这个顺序怎么存进数据库?

新手的第一反应是给每张卡一个整数 order 字段:0、1、2、3……把卡片插到第 1 位和第 2 位之间,就把后面的卡片 order 全部 +1。这在单人、卡片少的 demo 里没问题,上线后就是灾难:两人同时拖卡片,全表重排互相覆盖;一张看板几百张卡,每次拖动都要 UPDATE 几十行,慢且容易死锁;更隐蔽的是,两个并发请求都读到"当前最大 order 是 5",都写入 6,顺序直接错乱。整数 order 的本质问题是:顺序信息是全局的,任何一次局部移动都要改写全局。

业界的标准答案是 fractional indexing(分数索引):顺序键是一个 base62 编码的小数字符串,插入新卡时只需要在相邻两张卡的键之间"取中点"生成新键,一次拖动只 UPDATE 一行,并发天然安全。原理如下图:

fractional indexing 排序键生成示意:把新卡插到排序键 a0 与 a1 之间,取中点生成新键 a0V;反复插入导致键越来越长,超过阈值时 rebalance 重新均分

具体规则三条:第一,初始键按整数间隔生成,比如 a0、a1、a2;第二,把新卡插到 a0 和 a1 之间时,调用 generateKeyBetween('a0', 'a1') 得到中点键 a0V(V 是 base62 字符表的中点字符),直接写入这一行,其他行一个都不用动;第三,反复在同一缝隙插入,键会越来越长(a0P → a0PH → a0PHUn……),当键长度超过阈值(一般设 8~10)时,跑一次 rebalance() 把整列键重新均分成 a0、a1、a2……rebalance 是低频的批量操作,放在夜间定时任务里跑,用户无感知。

三种拖动场景的写库策略各不相同,提前想清楚能少写一半 bug:

  • 同列拖动:只需要重新计算这一张卡的键。取它在新位置前后两张卡的键做中点;如果拖到了列首或列尾,一端没有邻居,就用"最小键减半"或"最大键加半"的逻辑生成(fractional-indexing 库的 generateKeyBetween(a, null) / generateKeyBetween(null, b) 天然支持)。
  • 跨列拖动:同一张卡要同时更新 columnId 和排序键。这两个字段必须在同一个事务里写,否则会出现"卡片在新列但顺序键是旧列的"这种幽灵状态。乐观更新时前端先一起改,服务端用事务确认。
  • 跨看板移动:本质是"删除 + 插入":在原看板把卡片标记删除(或物理删除),在新看板按目标位置生成新键。建议走软删除 + 后台清理,避免跨看板事务的复杂度。

落库的完整时序是"乐观更新 + 服务端确认":用户松手瞬间,前端立刻用新键重新排序并渲染(0 延迟反馈);同时把 { cardId, newKey, newColumnId, clientOpId } 发给服务端;服务端在事务里写入并返回确认;如果失败,前端用拖动前的快照回滚,并 toast 提示用户重试。clientOpId 是幂等键,防止用户因"弹回错觉"重复拖动导致服务端写入两次——这个细节是第二章"松手弹回"翻车现场的根治方案。

// 服务端伪代码:一次拖动只 UPDATE 一行
import { generateKeyBetween } from 'fractional-indexing';

async function moveCard({ cardId, prevKey, nextKey, columnId, clientOpId }) {
  // 幂等:同一个 clientOpId 只执行一次
  if (await opLog.exists(clientOpId)) return { ok: true, deduped: true };
  const newKey = generateKeyBetween(prevKey ?? null, nextKey ?? null);
  await db.transaction(async (tx) => {
    await tx.card.update({
      where: { id: cardId },
      data: { sortKey: newKey, columnId },
    });
    await tx.opLog.create({ data: { clientOpId } });
  });
  return { ok: true, sortKey: newKey };
}

我的判断是:只要你的看板可能超过 50 张卡、或可能有两个人同时用,直接上 fractional indexing,不要从整数 order 起步再迁移。迁移的成本远高于一开始就做对——整数 order 迁到分数键需要一次全表重写,而反过来不需要。npm 上 fractional-indexing 这个包就是标准实现(Figma 当年用的就是这套思想),别自己手写 base62 中点算法,手写的一定会在边界 case 上翻车。

四、dnd-kit 实战搭建:最小闭环与四个关键件

这一节给出一套能直接跑的最小闭环。dnd-kit 的核心概念只有四个:DndContext(拖拽上下文,所有拖拽状态的总线)、SortableContext(声明"这些卡片是一个可排序集合")、useSortable(每张卡片绑定的 hook)、DragOverlay(拖拽时跟随手指的镜像层)。

import {
  DndContext, DragOverlay, KeyboardSensor, PointerSensor,
  closestCenter, useSensor, useSensors,
} from '@dnd-kit/core';
import {
  SortableContext, arrayMove, sortableKeyboardCoordinates,
  useSortable, verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';

function SortableCard({ id, title }) {
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
    useSortable({ id });
  return (
    <div
      ref={setNodeRef}
      style={{
        transform: CSS.Transform.toString(transform),
        transition,
        opacity: isDragging ? 0.4 : 1, // 原位置变淡,镜像由 DragOverlay 渲染
      }}
      {...attributes}
      {...listeners}
    >{title}</div>
  );
}

function KanbanColumn({ cards, onMove }) {
  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
  );
  const [activeId, setActiveId] = useState(null);
  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCenter}
      onDragStart={({ active }) => setActiveId(active.id)}
      onDragEnd={({ active, over }) => {
        setActiveId(null);
        if (over && active.id !== over.id) {
          const from = cards.findIndex(c => c.id === active.id);
          const to = cards.findIndex(c => c.id === over.id);
          onMove(arrayMove(cards, from, to), active.id, over.id);
        }
        // over 为 null = 拖到无效区域:什么都不做,天然回滚
      }}
      onDragCancel={() => setActiveId(null)}
    >
      <SortableContext items={cards.map(c => c.id)} strategy={verticalListSortingStrategy}>
        {cards.map(c => <SortableCard key={c.id} id={c.id} title={c.title} />)}
      </SortableContext>
      <DragOverlay>
        {activeId ? <div className="card-ghost">{cards.find(c => c.id === activeId)?.title}</div> : null}
      </DragOverlay>
    </DndContext>
  );
}

四个关键件逐个讲透:

  • DragOverlay(拖拽镜像):这是"顺滑感"的一半来源。正确姿势是原卡片在原地变淡(或留一个占位框),真正的跟随镜像由 DragOverlay 在顶层渲染。这样做的好处是镜像的移动只触发 transform,不触发列表重排——布局不抖动,帧率才保得住。反模式是直接 transform 原卡片,整列会跟着抽动。
  • collisionDetection(碰撞算法):closestCenter 计算拖拽物中心与各卡片中心的距离,取最近者为落点;rectIntersection 计算矩形相交面积。看板场景推荐 closestCenter,因为它对"拖到两张卡缝隙"的意图识别更稳定;rectIntersection 在卡片高度不一、快速拖动时容易在两个落点之间来回横跳。
  • KeyboardSensor(键盘拖拽):无障碍的硬要求,不是加分项。配上 sortableKeyboardCoordinates,用户可以用空格抓起卡片、方向键移动、空格放下、Esc 取消。很多 vibe 项目上线后被企业客户问到的第一个合规问题就是这个,提前做掉。
  • activationConstraint: { distance: 8 }:防误触的关键。没有这个约束,用户想点开卡片详情时手指轻微一滑就会触发拖拽。8px 是个经验值:桌面端鼠标和移动端触屏都适用。注意它和长按激活是二选一的关系——如果你的卡片上有大量可点击元素(按钮、链接),考虑用长按激活代替距离约束,见第六节。

另外注意 onDragEnd 里 over 为 null 的分支:用户把卡片拖到看板之外的无效区域松手,dnd-kit 不会自动回滚——但因为我们是乐观更新模式,只要什么都不做,界面本来就没变,天然就是回滚。真正的回滚逻辑只需要在"服务端确认失败"时触发,这一点在第三节的时序里已经讲了。

五、并发与冲突:两个人同时拖同一张卡

单人看板做到第四节就够了,但只要你的产品有"分享看板给同事"的按钮,第五节就是必修课。先说清楚这个问题的边界:完整的"多人实时协同编辑"(多光标、CRDT 合并、操作变换)是《多人实时协同编辑指南》的主题,本文只解决看板拖拽这个最小冲突面:两个人同时拖同一张卡,或者一个人拖卡、另一个人删卡。

方案是乐观并发控制,核心就一招:每张卡带一个版本号(或用 updatedAt 时间戳),服务端写入时做 compare-and-swap——只有当提交的版本号等于数据库里的当前版本号时才写入,否则拒绝。fractional indexing 在这里帮了大忙:因为不同卡片的移动互不干扰(各写各的 sortKey),真正的冲突只发生在同一张卡被两个人同时移动,冲突面非常小。

// 服务端:compare-and-swap,冲突时拒绝写入
async function moveCard({ cardId, baseVersion, ...move }) {
  const result = await db.card.updateMany({
    where: { id: cardId, version: baseVersion }, // 版本对不上 = 有人先写了
    data: { sortKey: move.newKey, columnId: move.columnId,
            version: { increment: 1 } },
  });
  if (result.count === 0) throw new ConflictError('card moved by someone else');
  return { ok: true };
}

冲突发生时的 UX 有三档,按产品阶段选:第一档,静默 LWW(Last Write Wins)——后写入的直接覆盖,不提示。适合个人项目、冲突概率极低的场景,简单但可能让先拖的人困惑;第二档,toast 提示刷新——"这张卡片刚刚被同事移动了,已为你刷新到最新位置",自动拉取最新数据。适合小团队协作,是性价比最高的选择;第三档,弹层让用户选——"你要保留你的位置还是同事的位置?"适合金融、医疗这种对数据位置敏感的场景,但会打断心流,vibe 项目一般不需要做到这一档。我的建议是:默认做第二档,第一档留给纯个人工具。

WebSocket 广播做到"最小可用"即可,不需要上全套协同框架。目标只有两个:让用户看到别人的卡在动(避免"我拖的时候卡片突然跳走"的惊吓),以及冲突时能收到提示。最小实现:

// 客户端:拖动结束写入成功后广播;收到广播后更新本地
socket.emit('card:moved', { cardId, sortKey, columnId, version, actor: myUserId });
socket.on('card:moved', (msg) => {
  if (msg.actor === myUserId) return; // 自己广播的不处理
  applyRemoteMove(msg); // 按 sortKey 重新排序该列
  if (currentlyDraggingCardId === msg.cardId) {
    toast('这张卡片正在被同事移动'); // 正在拖的卡被别人动了,温柔提示
  }
});

注意一个细节:收到别人的移动广播时,不要粗暴地全量重新拉取看板数据——那会导致正在拖拽的用户界面抖动。正确做法是只更新那一张卡的 sortKey / columnId 并局部重排。如果这张卡正在被本地用户拖拽中,推迟应用远程更新,等本地拖拽结束(onDragEnd / onDragCancel)后再合并,这是"不打扰正在操作的用户"的基本修养。

六、移动端与无障碍:三层手势打架

桌面端跑通的看板,搬到手机上大概率直接残废。根因是移动端有三层手势在打架:看板整体的横向滚动(切换列)、列内的纵向滚动(浏览卡片)、卡片的拖拽。浏览器不知道用户这一指是想滚动还是想拖,只能靠你明确告诉它。

第一道防线是 CSS:touch-action: none。把它加在可拖拽卡片的句柄(或整张卡)上,等于告诉浏览器"这个元素上的触摸手势全部交给我处理,不要触发滚动、缩放等默认行为"。没有这一行,dnd-kit 的 TouchSensor 在移动端会和浏览器原生滚动打架,表现为"拖一下就触发页面滚动,卡片根本拿不起来"。但注意不要全局加——只在可拖拽元素上加,否则整页都不能滚动了,用户会骂娘。

第二道是长按激活 vs 直接拖的取舍。第四节用的 distance: 8 是"直接拖":手指移动 8px 就进入拖拽。它的代价是卡片上的按钮、链接变得难点击(轻微滑动就被判定为拖拽)。如果你的卡片信息密度高、有很多可交互元素,换成长按激活:

useSensor(TouchSensor, {
  activationConstraint: { delay: 250, tolerance: 8 },
  // 按住 250ms 才进入拖拽,8px 内轻微抖动不取消
});

取舍很明确:看板类(Trello 型)用直接拖 + 距离约束,因为拖拽是最高频操作;卡片富交互型(每张卡上有按钮/开关/链接)用长按激活。没有银弹,按你的卡片设计选。

第三道是滚动容器隔离。看板横向滚动的容器和列内纵向滚动的容器是两层 overflow,直接拖拽时 dnd-kit 默认不会帮你自动滚动。一个实用的折中方案:拖拽到列边缘时不做自动滚动(实现复杂且容易抖),而是允许用户把卡片拖到列顶部的"列头投放区"来跨列——把跨列这个低频操作做成显式投放区,体验反而更可预测。等用户量上来、真的有人抱怨时,再接入 dnd-kit 的自动滚动插件。

无障碍方面,除了第四节的 KeyboardSensor,还有两件事:屏幕阅读器 announcements——dnd-kit 的 DndContext 支持 announcements 属性,可以在抓起、移动、放下时播报"已抓起卡片 X"、"已移动到第 3 位"、"已放下"。默认的英文播报对中文用户不友好,建议自定义中文文案;可见的焦点样式——键盘操作时当前卡片要有明显的 focus ring,否则键盘用户根本不知道自己"拿"的是哪张卡。这两件事加起来不到 50 行代码,但决定了你的产品能不能进企业采购的合规清单。

七、性能:长列几百张卡片时

看板性能的敌人只有一个:DOM 节点太多。一列 300 张卡片,每张卡片有标题、标签、头像、截止日期,就是上千个 DOM 节点。拖拽时每一帧都要计算碰撞,浏览器直接投降。解法是虚拟化,但虚拟化和拖拽结合有一个著名的坑。

标准方案是 @tanstack/virtual(原 react-virtual):只渲染可视区域内的卡片。但坑在于:没渲染出来的卡片不能作为投放目标——用户想把卡片拖到第 250 位,那个位置根本没有 DOM 节点,collision 算法算不出来。业界有两种解法:第一种,拖拽过程中禁用虚拟化、全量渲染(300 个节点现代浏览器其实扛得住,真正扛不住的是 3000 个);第二种,保留虚拟化,但在拖拽时把"未加载区域"渲染成一个大的投放占位区,松手后按滚动位置计算目标索引。我的建议是:300 张以内直接全量渲染 + DragOverlay 隔离,别上虚拟化;超过 300 张再用第二种方案。过早优化是万恶之源,虚拟化本身引入的复杂度(滚动位置同步、动态高度测量)比它解决的问题更大。

无论是否虚拟化,三条性能铁律:

  • DragOverlay 与列表重渲染隔离:跟随手指的镜像层只做 transform 位移,绝不触发列表数据更新。数据更新只在 onDragOver 的节流回调里做(比如 100ms 节流一次),或者只在 onDragEnd 做一次。把"视觉跟随"和"数据重排"解耦,是 60fps 的关键。
  • 动画只用 transform 和 opacity:这两个属性走 GPU 合成,不触发布局重排。任何改 width/height/top/left 的拖拽动画都是性能自杀。dnd-kit 默认的 transition 就是 transform-based,别手贱改成别的。
  • 服务端分页 + "拖到未加载区域"的策略:如果列表是服务端分页的,拖到已加载数据之外时,前端先按"插入到当前页末尾"乐观处理,同时触发加载下一页,数据回来后再用真实 sortKey 校准。不要让用户等网络请求回来才能松手——松手的反馈必须是即时的,这是第一章"落点可预测"的延伸。

八、上线检查清单:12 项

发布前逐项打勾,任何一项没做到都可能变成用户工单:

  1. 空列占位高度:空列必须有一个最小高度的投放区,否则卡片拖不进空列——这是新人最容易漏的,测试时专门建一列空列拖一次。
  2. 拖拽取消回滚:按 Esc 或拖到无效区域松手,界面必须纹丝不动;写一个单测断言 onDragCancel 后数据未变。
  3. 服务端失败回滚:mock 服务端返回 500,确认卡片回到原位且有 toast 提示,而不是卡在错误位置。
  4. 离线操作队列:断网时拖动,操作进入本地队列,恢复网络后按序重放;重放时用 clientOpId 去重。
  5. 幂等键:所有 moveCard 请求带 clientOpId,服务端用 opLog 去重;用 Playwright 模拟"快速拖两次同一张卡"验证不产生重复写入。
  6. 拖拽成功率埋点:上报 drag_start → drag_end → server_ack 三个事件,计算"松手后 2 秒内未确认"的比例,超过 1% 就告警。
  7. E2E 测试:Playwright 模拟拖拽要用坐标写法,多步移动模拟真实手指轨迹:
    const card = page.locator('[data-card-id="c1"]');
    const box = await card.boundingBox();
    await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
    await page.mouse.down();
    await page.mouse.move(box.x + box.width / 2, box.y + 300, { steps: 25 });
    await page.mouse.up();
    // 断言:卡片落点 + 服务端 sortKey 已更新
    await expect(page.locator('[data-column="done"] [data-card-id="c1"]')).toBeVisible();
  8. 键盘拖拽全路径:只用键盘完成"抓起 → 移动 → 放下 → 取消"四个动作,屏幕阅读器播报中文正常。
  9. 移动端真机测试:至少在 iOS Safari 和 Android Chrome 各测一遍三层手势(横向滚列、纵向滚卡、拖卡),模拟器不算数。
  10. 长列性能基线:造 300 张卡的列,拖动时用 Chrome DevTools 录一帧,确认帧率 55fps+、无长任务阻塞。
  11. 排序键 rebalance 巡检:定时任务每天扫一次 sortKey 长度分布,超过阈值的列自动 rebalance;在监控里看 rebalance 的执行次数,突然暴涨说明有用户在"同一缝隙反复插卡",可能是 UX 设计问题(比如缺少"插入到顶部"按钮)。
  12. 归档库扫描:CI 里加一条规则,package.json 里出现 react-beautiful-dnd 就报错——防止 AI 生成的代码把归档库带回来。这条是 2025-04-30 归档事件之后最值得做的防御性措施。

最后说一句判断:看板拖拽这类功能,vibe 项目和大厂产品的差距不在"能不能拖",而在"拖得顺不顺、错了能不能回、刷新乱不乱"。这三件事分别对应本文的第四节(dnd-kit 实战)、第五节(冲突与回滚)、第三节(fractional indexing 持久化)。按这个顺序做,一周时间,一个人,可以做出 Trello 八成功力的看板。剩下的两成是打磨,留给用户反馈来驱动。

阅读 0评论 0

评论 (0)

ME
0/1000
评论加载中...
浏览项目广场发布你的项目

相关文章

题图:手机上显示日历 App 日视图,旁边是键盘、咖啡杯与记事本的真实照片
指南
日历双向同步实战:把 Google / Outlook 日历接进你的 vibe 项目

用户在 Google 日历改了个会,你的 App 秒级感知、增量同步、还能写回去不打架:syncToken/deltaLink 增量游标、watch 推送续约、外部 ID 映射与删除墓碑、updated 时间戳冲突裁决——一套调通一下午、稳定三个月的日历同步引擎实战。

开发工作流自动化工具技巧
题图:数据中心机房内成排的服务器机柜与蓝色网线,象征网站边缘防护基础设施
指南
网页水印与防爬实战:保护你的内容、图片和 API 不被薅秃

爬虫 24 小时批量搬走你的内容、图片和商品数据怎么办?从 Cloudflare 四层检测、JA3/JA4 握手指纹、抓爬检测规则,到签名 URL、水印取证与投入产出清单——一份算过账的中小站长防爬实战指南。

部署上线后端工程独立开发