ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

快马平台+马威斯:协作看板拖拽与并发落地实践

快马平台+马威斯:协作看板拖拽与并发落地实践

1. 需求压过来的时候,我为什么放弃了从零手写看板

1.1 一次真实的需求倒逼:三天要出一个能拖的看板

去年年底接过一个内部协作系统的活儿,需求方要得很急,原话是“下周就要能给人看”。拆开来看其实不复杂:三到五列状态(待办、进行中、待验收、已完成),每列下面挂若干张任务卡,卡片能在列内换顺序,也能跨列拖过去改状态,多人同时在线的时候不能出现“我拖完刷新一下又回去了”的情况。听起来就是一个看板,但做过的人都知道,这里面“能拖”和“拖得舒服”之间隔着一整个太平洋。

如果从零手写,光是拖拽这一层就够喝一壶:指针事件的兼容、落位判定的阈值、跨容器命中的坐标换算、边缘自动滚动、移动端的长按与滚动冲突、松手之后的动画回弹、拖拽过程中其他人正好也在动同一张卡……这些还都是纯交互层的问题,业务层的数据一致性、顺序持久化、并发冲突一个都跑不掉。三天时间,靠一个人从零撸,大概率能做出一个“能拖但在某些浏览器上会跳”的版本。

所以我的第一反应不是写代码,而是找地基。标题里提到的“快马平台”,我把它理解为那种开箱即用的在线开发环境——浏览器打开就有工程、依赖预装、实时预览、接口可以先 Mock 后接真、完事一键发布;而“马威斯”我按一套面向看板场景的拖拽组件集来理解,它把手势捕获、占位提示、落位计算、过渡动画这些脏活封装成了可复用的模块。这个组合的价值不在于“少写代码”,而在于把不确定性从“我不知道拖拽在某某浏览器上会不会抽风”压缩到“我只需要确认业务规则”。

这篇东西写给两类人看:一类是手上正好有类似看板需求、想用低代码或组件集快速落地的同行;另一类是不打算用任何平台、准备自己写拖拽的开发者——文中关于落位判定、顺序索引、并发收敛的部分,跟你用不用平台没关系,都是绕不开的坎。

1.2 快马平台在这类项目里真正省掉的是哪部分工作

很多人对在线开发平台的理解停留在“拖几个控件生成页面”,这个理解偏差会让人低估它、也会让人高估它。我实际用下来,它省掉的其实是最没技术含量但最耗时间的三块:

第一块是环境与依赖。传统流程是npm create一个脚手架,装依赖,配路由,配状态管理,配构建,配代理,中间任何一步版本不兼容都能耗掉半天。在线环境里这些是预置的,工程创建出来就能跑,import一个库直接解析。对三天工期的项目,这半天就是命。

第二块是预览与交付链路。改一行代码,右侧预览立刻热更新;需要给别人看,生成一个链接就行,不用打包、不用传服务器、不用配域名。这在“需求方要马上看到”的场景下价值极高——你可以一边聊一边改,把反馈循环从“天”压到“分钟”。

第三块是接口层的临时替代。协作看板必然涉及后端。后端没写好的时候,平台一般能挂 Mock 数据,前端先把交互跑通,等真接口出来只换请求地址和字段映射。这让我能把“交互调试”和“联调”两个阶段解耦,不然前端就得干等着。

但我也得说清楚它不省什么:业务建模、状态设计、并发策略、交互手感,这四样平台一点忙都帮不上。它给你一个能跑的空壳,里面填什么、怎么填、填错了会不会打架,全靠你自己想清楚。我见过太多人拿着低代码平台三天做出一个“看起来像那么回事”的看板,上线一周后开始出现卡片顺序错乱、拖拽卡顿、两个人同时拖就丢数据——问题从来不在平台,在于设计阶段偷的懒。

1.3 马威斯组件集的边界:它能兜住什么,兜不住什么

说说组件集这一层。我把它当作“拖拽能力的半成品”来看,判断标准很简单:它替我处理了哪些事,剩下哪些必须我自己扛。

它能兜住的,通常是这些:指针事件的统一封装(鼠标、触摸、触控笔归一)、拖拽元素跟随指针的位移计算、原位置的占位符渲染、松手后的落位动画、拖拽过程中给相关元素加上的状态类名。做得好的还会处理自动滚动和跨容器拖拽的坐标换算。这些如果自己写,光调试跨容器命中就得一整天。

它兜不住的,是这些:数据从哪来、拖完之后存到哪去、顺序怎么表达、两个人同时拖怎么办、失败了怎么回滚。组件集解决的是“手势到界面”的一段,剩下“界面到数据”和“数据到别人屏幕”这两段,永远是业务方的责任。我在项目里踩过最深的坑,就是一开始误以为拖拽组件会把顺序也管好——结果发现它只负责把 DOM 挪过去,数据模型还是原样,一刷新全回来了。

提示:拿到任何拖拽组件集,第一件事是写一个最小用例,拖动后打印数据模型,看它到底改没改你的数据。这一步花十分钟,能省掉后面两天的返工。

所以我的整体思路是:平台负责地基和交付,组件集负责手势和动画,我自己负责数据模型、顺序索引和并发策略。三者边界清晰,出了问题也知道该往哪找。

2. 把看板拆成三张皮:数据模型、拖拽层、协作层

2.1 数据模型先行:列、卡片、顺序索引怎么定

我有个习惯,凡是带排序的交互,先不写界面,先把数据结构画出来。看板的数据模型看着简单,其实很容易埋雷。最容易犯的错是把顺序交给数组下标——cards[0]、cards[1]这样。单机没事,一旦涉及并发、涉及局部更新,下标就是灾难:插一张卡,后面所有卡的下标全变,你得把整个数组重发一遍;两个人同时插,下标直接打架。

我的做法是给每个卡片一个独立的、可比较的顺序值,列内按这个值排序,数组只是渲染结果。最小模型大概长这样:

// 列 { id: "col_todo", title: "待办", order: 1024 } // 卡片 { id: "card_a1", columnId: "col_todo", title: "梳理埋点字段", order: 2048, // 列内排序依据 version: 7, // 乐观锁版本号 updatedAt: 1730000000000, assignee: "u_102", tags: ["前端"] }

关键在于order用数值而不是下标。数值的好处是可以取中间值:要把卡片插到 order=1024 和 order=2048 之间,新卡片的 order 就是 1536,其他卡片一个都不用动。这就是所谓的“间隔值”或“分数索引”思路,后面 4.1 会详细算这笔账。

还有个细节:列本身也需要 order,因为列也可能被拖动换位置。很多人只想着卡片能拖,忘了列的顺序也会变,等到产品经理说“这几列我想调一下顺序”的时候,数据模型里没有位置字段,只能临时加,非常难受。

2.2 拖拽层只干一件事:把一次手势翻译成一个移动意图

这是我认为整个项目里最重要的一条设计原则:拖拽层不要直接改数据,它只负责产出一个“意图对象”,由上层决定要不要执行。为什么?因为拖拽过程中会有大量中间态——手指在移动,落点一直在变,但真正需要落库的只有松手那一刻的最终结果。如果拖拽层一边动一边写数据,你会得到几十次无意义的写入,还得处理回滚。

我让拖拽层输出的意图大概是这样:

{ type: "card.move", cardId: "card_a1", fromColumnId: "col_todo", toColumnId: "col_doing", beforeCardId: "card_b2", // 落点前一张卡,为空表示插到最前 afterCardId: "card_b5", // 落点后一张卡,为空表示插到最后 clientOpId: "op_9f3a..." // 客户端生成的唯一操作 ID,用于幂等 }

用beforeCardId/afterCardId而不是直接给一个目标 order,是我踩过坑之后的改法。直接给 order 的问题在于:这个 order 是客户端算的,服务端可能已经因为别人的操作变了;用“插在某两张卡之间”这种相对语义,服务端拿到之后可以基于当前最新状态重新计算 order,鲁棒性高得多。这就是所谓的“意图优于状态”。

clientOpId也别省。网络不好的时候,用户可能连点、请求可能重发,服务端靠这个 ID 去重,能避免同一张卡被移动两次。

2.3 协作层要吞掉的是“别人也在动”这件事

单机看板和协作看板的复杂度差一个数量级,差别全在这一层。单机版本里,你拖完就是拖完了;协作版本里,你拖的同时可能有人正在改这张卡的标题,有人正在把这张卡往别的列拖,有人刚把它删了。

我的处理原则是三条:

第一,本地操作先落地,不等服务端。用户松手的瞬间,界面必须立刻反映结果,不能转圈等接口。这叫乐观更新,用户感知上就是“跟手”。代价是失败要回滚,所以每次操作前得存一份快照。

第二,服务端的结果是权威,但不要粗暴覆盖本地。如果服务端返回 409 冲突,直接把最新数据砸到界面上,用户会看到自己刚拖好的卡片“啪”地跳回去,体验极差。正确的做法是把冲突信息拿到手,做一次合并,合并完再给一个轻提示。

第三,实时推送要能识别“是不是我自己干的”。WebSocket 广播回来的操作,如果actorId是自己,直接忽略,否则会出现“我拖了一下,卡片动了两次”的诡异现象(本地一次,广播回来又应用一次)。

这三条听着简单,实际写起来每一处都有细节,第 5 节我会把冲突处理的完整链路摊开讲。

3. 拖拽手感的细节全在像素里:从指针事件到落位判定

3.1 为什么我最终选了 Pointer Events 而不是原生 drag

HTML5 原生拖放(draggable="true"+dragstart/dragover/drop)看起来最省事,浏览器自带拖影、自带光标反馈,写起来也就几十行。但我试过一轮之后放弃了,原因有三:

一是拖影没法控制。原生拖放生成的半透明快照是浏览器画的,样式、缩放、圆角你都改不了。看板里的卡片通常有阴影、有标签颜色、有头像,原生拖影出来常常是一个灰扑扑的方块,跟设计稿差得远。

二是移动端基本不可用。原生拖放在触屏设备上的行为很不一致,有的浏览器需要长按 300ms 才触发,有的干脆不触发。看板这种高频交互,移动端体验不能将就。

三是过程事件太粗。dragover虽然会持续触发,但要做边缘自动滚动、要做实时落位高亮,你还需要精确的坐标和帧级控制,原生 API 在这块给的东西不够。

所以最终用的是Pointer Events:pointerdown/pointermove/pointerup/pointercancel。它把鼠标、触摸、触控笔统一成一套事件,pointerType告诉你当前是什么设备,配合setPointerCapture可以在指针移出元素后继续接收事件——这一点很关键,不然鼠标拖快了离开卡片就断了。

核心骨架大概是这样:

let dragging = null; function onPointerDown(e) { if (e.button !== 0 && e.pointerType === 'mouse') return; // 长按/触屏阈值:先记录起点,移动超过 5px 才真正进入拖拽 dragging = { startX: e.clientX, startY: e.clientY, active: false, el: e.currentTarget }; e.currentTarget.setPointerCapture(e.pointerId); } function onPointerMove(e) { if (!dragging) return; const dx = e.clientX - dragging.startX; const dy = e.clientY - dragging.startY; if (!dragging.active && Math.hypot(dx, dy) < 5) return; // 抖动过滤 dragging.active = true; // 后面用 rAF 统一更新,不要在这里直接改 style pendingPos = { x: e.clientX, y: e.clientY }; }

Math.hypot(dx, dy) < 5这个阈值是手感的关键。没有它,用户想点开一张卡片看详情,手指稍微抖一下就被判成拖拽,体验非常烦躁。5px 是我试下来比较舒服的值,触屏设备可以放宽到 8px。

3.2 落位判定:中心线、阈值与占位符的三件套

拖拽最核心的算法问题只有一个:当前指针位置,应该把卡片插到哪个位置?这个判断如果做得糙,用户会感觉“拖不准”。

我的做法分三步走:

第一步,找目标列。遍历所有列的getBoundingClientRect(),看指针的clientX落在哪个横向区间内。注意这里只判横向,纵向溢出也要能识别——用户把卡片拖到某列下方空白处,也应该算落在这列。所以判定用的是“列的横向范围 + 容器的纵向范围”,而不是严格矩形包含。

第二步,找目标位置。拿到目标列的所有卡片后,遍历它们的纵向中心线。指针的clientY小于某张卡的中心 y,就说明应该插在这张卡之前;找到第一个满足条件的就是落点。伪代码:

function findInsertIndex(cards, pointerY) { for (let i = 0; i < cards.length; i++) { const rect = cards[i].getBoundingClientRect(); const centerY = rect.top + rect.height / 2; if (pointerY < centerY) return i; } return cards.length; // 落在最后 }

第三步,加抖动抑制。光有中心线还不够。用户把卡片悬停在两张卡的边界附近时,指针稍微一动,落点就在“前”和“后”之间反复横跳,占位符疯狂闪,看着像卡了。解决办法是加一个滞回阈值:只有当指针越过中心线超过某个偏移量(比如 25% 卡高)时才真正切换落点,否则保持上一次的判定结果。

占位符的渲染也有讲究。我不用“把原卡片隐藏、然后插入一个空 div”这种做法,因为会引起列表整体重排,观感很跳。我用的是给当前卡片留一个等高的占位块,其他卡片通过transform: translateY()做位移动画,位移走的是合成层,不触发重排,60 帧稳定。

拖拽元素的跟随用transform: translate3d(x, y, 0)而不是改left/top。原因很实在:transform只触发合成,left/top会触发 layout,两者在拖拽这种每帧都要更新的场景下差别非常明显。我做过对比,同一台机器上,改left/top在卡片数量超过 50 张时就能感觉到掉帧,改transform基本无感。

3.3 边缘自动滚动:最容易做丢的一个体验

这个功能很多人第一版都不做,然后被用户骂。场景很简单:一列里有 40 张卡,容器高度只够显示 12 张,用户拖着第 30 张卡想放到第 2 的位置,如果容器不自动滚动,他只能先松手、滚动、再拖一次。这个交互断点非常难受。

实现逻辑不复杂:在拖拽移动时,计算指针距离滚动容器上下边缘的距离,小于阈值(我用 60px)就启动滚动,滚动速度按距离线性映射——越靠近边缘滚得越快。

const EDGE = 60; const MAX_SPEED = 18; // 每帧最大像素 function autoScroll(container, pointerY) { const rect = container.getBoundingClientRect(); const topDist = pointerY - rect.top; const bottomDist = rect.bottom - pointerY; if (topDist < EDGE && container.scrollTop > 0) { const ratio = 1 - Math.max(topDist, 0) / EDGE; container.scrollTop -= MAX_SPEED * ratio; } else if (bottomDist < EDGE) { const ratio = 1 - Math.max(bottomDist, 0) / EDGE; container.scrollTop += MAX_SPEED * ratio; } }

关键在于这个函数要挂在requestAnimationFrame循环里跑,而不是绑在pointermove上。因为用户把指针停在边缘不动的时候,pointermove根本不触发,但滚动必须继续。这一点我第一版就写错了,测试的时候还说“自动滚动怎么时好时坏”,后来才发现是事件驱动的锅。

还有个坑:滚动之后,卡片的getBoundingClientRect()全都变了,落位判定必须重新算。所以我的结构是每帧先滚动、再判定落点、最后更新占位符,顺序不能乱。

3.4 移动端与触屏:touch-action 和长按阈值

触屏上做拖拽,第一个必须加的是 CSS:

.kanban-card { touch-action: none; }

不加这行,用户按住卡片往下拖,浏览器会认为是页面滚动,直接把手势抢走,你的pointermove收到几下就断了。touch-action: none是明确告诉浏览器“这块区域的手势我自己处理”。

但加了之后有个副作用:这一列如果内容很长,用户在卡片上滑动就没法滚页面了。所以要区分——只有进入拖拽状态之后才需要阻止滚动。我的做法是默认touch-action: pan-y(允许纵向滚动),在pointerdown之后启动一个 200ms 的定时器,如果这期间没有移动超过阈值,就切到拖拽模式并把touch-action改成none;如果用户先滑动了,就判定为滚动,取消拖拽准备。

这就是所谓的“长按拖拽”体验:按住不动 200ms,卡片轻微放大 + 抬起阴影,提示“现在可以拖了”;直接滑动则是正常滚动。这个反馈很重要,没有视觉提示,用户根本不知道可以长按。

另外触屏上还要处理“拖到屏幕边缘”的情况,因为手机上容器通常就是整个视口,边缘滚动要作用在页面上,逻辑跟桌面版不太一样。我最后的做法是桌面端滚动列容器,移动端滚动整个页面,用matchMedia判断走哪条分支。

4. 顺序索引与持久化:拖完之后数据不能乱

4.1 用间隔值还是整型 order:两种方案的代价

前面提到 order 用数值而不是下标,这里把账算清楚。假设列内卡片 order 是 1024、2048、3072 这样递增的,现在我要把一张卡插到第 1 和第 2 之间:

方案插入操作影响行数并发安全精度风险
整型 order(每次 +1 递增)全部重排N 行差,容易撞号无
间隔值(步长 1024)取中间值 15361 行好反复插入会耗尽精度
字符串分数索引生成 "a0V" 之类1 行好实现略复杂

整型 order 的问题是每次插入都要把后面所有卡的 order 重算一遍,10 张卡还好,500 张卡就是 500 次更新,一次拖拽打 500 个请求,服务端直接报警。间隔值方案只改一行,代价是精度:连续在同一个缝隙插入,(1024 + 1536) / 2 = 1280,再插(1024 + 1280) / 2 = 1152……大概插 50 次左右,间隔就小于 1 了,再插下去浮点数精度不够,会出现两张卡 order 相等的情况,排序就不稳定了。

我的处理是设一个阈值,间隔小于 2 就触发局部重排:把这一列的 order 重新分配成 1024、2048、3072……重排是一次批量更新,频率极低(正常使用几乎碰不到),代价可以接受。这个“懒重排”策略是我认为最平衡的方案,实现简单、写入少、不会失控。

还有一点容易忽略:重排必须带版本号或者时间戳条件。如果两个人同时触发重排,可能一个把另一个的中间状态覆盖掉。我的做法是重排请求带上“我基于哪些卡片 ID 和 order 做的重排”,服务端校验不一致就拒绝,让客户端重新拉一次最新数据再算。

4.2 乐观更新与失败回滚的具体写法

乐观更新的核心就一句话:先改本地,再发请求,失败回滚。但“回滚”这两个字展开来有不少讲究。

最朴素的做法是操作前深拷贝整个状态,失败时整个覆盖回去。这在看板里问题不大(状态不大),但会有副作用:如果失败回滚的同时,另一个人推送了一条更新进来,你的整状态覆盖会把别人的更新也抹掉。所以我用的是基于操作的反向回滚:

async function moveCard(intent) { const snapshot = captureCardState(intent.cardId); // 只存这一张卡的相关字段 applyMoveLocally(intent); // 本地立即生效 try { const res = await api.moveCard(intent); // 服务端可能微调了 order,用返回值校正本地 patchCard(intent.cardId, { order: res.order, version: res.version }); } catch (err) { if (err.status === 409) { // 冲突:用服务端最新数据做合并,而不是粗暴回滚 mergeFromServer(err.payload); } else { revertCardState(intent.cardId, snapshot); // 普通失败才回滚 toast('移动失败,已还原'); } } }

注意captureCardState只存这一张卡的columnId/order/version,不存整个列表。这样回滚的影响面最小。同时patchCard那一步很多人会漏——服务端计算出来的 order 可能跟你本地算的不一样(因为中间有别人的操作),不校正的话,下次你再拖这张卡,本地和服务端的顺序基准就不一致了,容易越拖越乱。

4.3 提交节流与幂等:别让一次拖拽发出十条请求

拖拽过程中绝对不能发请求。我见过有人把落位判定挂在pointermove上,一移动就发一次“更新位置”,一次拖拽能发上百个请求,后端直接限流。正确做法是只有pointerup才产生一次提交。

但“松手就提交”还有个变体问题:用户手快,连续拖三张卡,间隔不到 200ms,会发三个请求。这三个请求如果在网络上乱序到达,最终状态可能不是用户看到的那个。我的处理是给每个请求带一个单调递增的本地序列号,服务端按序列号顺序处理,或者在客户端做一次合并——把 300ms 内的多次拖拽合并成一次批量提交。

let moveQueue = []; let flushTimer = null; function enqueueMove(intent) { moveQueue.push(intent); clearTimeout(flushTimer); flushTimer = setTimeout(flush, 300); } async function flush() { const batch = moveQueue.splice(0); if (!batch.length) return; await api.batchMove(batch); // 一次请求提交多个移动 }

批量提交的接口设计也有讲究:不能是“把最终状态覆盖上去”,而应该是“按顺序应用这些移动意图”,这样服务端才能正确计算每一步的 order。如果只是覆盖最终状态,中间那几步的副作用(比如触发通知、写操作日志)就丢了。

幂等这块,clientOpId一定要有。用户网络差的时候,请求超时重发是常态,没有幂等键,同一张卡可能被移动两次,或者触发了两次“任务已移动到进行中”的通知。服务端用clientOpId建唯一索引,重复的直接返回上次结果就行,实现成本极低,收益极大。

5. 多人同时拖动同一张卡片时会发生什么

5.1 粗粒度锁与细粒度合并的取舍

协作看板的并发策略,常见的就两条路:锁和合并。

锁的思路是拖拽开始时给卡片加一个“正在被某人拖动”的标记,其他人拖不动,界面上显示“某某正在移动”。优点是绝对不会冲突,实现也简单;缺点是体验很差——看板本来就是多人协作高频操作的场景,锁一下别人就卡住了,而且网络断线的时候锁还可能不释放,变成死锁。

合并的思路是允许大家随便拖,冲突了再收敛。优点是流畅,缺点是实现复杂,且要接受“最终状态可能跟某人预期不一致”。

我选的是折中方案:软锁提示 + 冲突合并。拖拽开始时不加真正的锁,但如果服务端知道这张卡最近 5 秒内被别人操作过,就在卡片上显示一个轻微的高亮和“某某刚刚动过”的提示,让用户有心理预期。真正提交时如果版本号对不上,走合并逻辑。这样既有提示又不阻塞,实际用下来体验最好。

5.2 用“意图事件”代替“全量快照”同步

实时同步最容易犯的错是广播全量快照。也就是“谁改了,就把整个看板的数据推给所有人”。这个方案在小规模下能用,但问题很明显:数据量大、带宽浪费、而且容易产生“旧快照覆盖新状态”的经典 bug——网络乱序时,先发的旧快照后到,直接把新状态盖掉了。

我用的是广播操作意图:

{ type: "card.moved", cardId: "card_a1", fromColumnId: "col_todo", toColumnId: "col_doing", beforeCardId: "card_b2", afterCardId: "card_b5", order: 1536, baseVersion: 7, actorId: "u_102", opId: "op_9f3a..." }

客户端收到之后按顺序应用,应用时校验baseVersion:如果本地这张卡的 version 还是 7,直接应用并把 version 加到 8;如果已经是 8 或更高,说明本地已经有更新的状态,这条广播就跳过(或者记日志)。

这套机制的关键是服务端要保证广播顺序与提交顺序一致,给每个操作分配一个单调递增的序号,客户端按序号应用,发现序号跳跃就主动拉一次全量。这样即使某条消息丢了,也能自愈。

5.3 冲突后的自愈:让界面自己收敛

冲突处理的终极目标不是“谁赢”,而是所有人的界面最终一致。我设计了一套自愈流程,实测下来用户几乎感知不到冲突:

第一层,本地正在拖的卡不受广播影响。如果收到某张卡的移动广播,而这张卡正好是用户当前正在拖的,先缓存起来(标记为 pending),不立即应用,等用户松手提交完再处理。不然会出现“拖着拖着卡片自己跑了”的情况。

第二层,提交冲突时以服务端为准,但保留本地意图。服务端返回 409 并附带最新状态,客户端先应用最新状态,然后把本地的移动意图重新算一次落点再提交一次。因为用户的本意是“我要把它放到这里”,而不是“我要基于旧版本放到这里”,重算之后大概率能成功。

第三层,连续失败就降级。如果同一张卡连续两次冲突(说明有高频争抢),就放弃自动重试,弹出提示让用户手动刷新一下再拖。这种极端情况很少,但必须兜住,不能死循环。

第四层,定期对齐。客户端每 30 秒或者每 20 次操作之后,主动拉一次全量状态做对比。如果发现本地和服务端不一致,以服务端为准静默修正,并打一条日志。这层是最后的安全网,能兜住前面所有没考虑到的边界情况。

6. 上线前我逐项过的验收清单与性能打磨

6.1 渲染性能:虚拟列表和拖拽的相亲相杀

当一列卡片超过 200 张的时候,全量渲染会明显卡顿,首屏时间和拖拽帧率都会掉。解决办法是虚拟列表——只渲染可视区域内的卡片。但它和拖拽有个天然冲突:被拖拽的那张卡如果滚出可视区域被回收了,拖拽就断了。

我的处理是给虚拟列表加一个“固定项”白名单:拖拽开始时把当前卡片 ID 加入白名单,虚拟列表跳过回收;拖拽结束时移除。同时占位符的高度要能撑住,不然列表总高度变化会引起滚动条跳动。

另外几个性能细节值得单独拎出来:

优化点做法效果
拖拽位移transform: translate3d走合成层,不触发重排
位移动画FLIP(先测位置再反向补偿)免费获得平滑过渡
落点计算缓存 rect,滚动后才重算避免每帧几十次布局读取
广播应用批量合并,requestAnimationFrame内统一更新避免多次重渲染
长列表虚拟滚动 + 拖拽白名单200+ 卡片依然流畅

FLIP 这个技术值得说一下,它不是什么库,就是一个思路:First(记录变化前的位置)→ Last(改变 DOM 到目标位置)→ Invert(用 transform 把元素“拉回”旧位置)→ Play(下一帧移除 transform,让它自然过渡过去)。用它做落位动画,不用手写任何关键帧,性能还好,因为整个动画过程只改transform。

6.2 键盘可达与读屏播报

这一块经常被跳过,但只要有正式用户,就该做。看板的键盘操作约定是:Tab聚焦到卡片,空格或回车“拾起”,方向键移动(左右换列、上下换位),再按空格“放下”,Esc取消。

实现上不是自己造一套,而是给卡片加上role="button"、aria-grabbed、tabindex="0",然后用aria-live="polite"的区域播报状态变化:

<div aria-live="polite" class="sr-only"> 卡片「梳理埋点字段」已移动到「进行中」第 2 位 </div>

这个播报对读屏用户是刚需,对普通用户无感(视觉隐藏),成本很低。我一开始也嫌麻烦没做,后来做无障碍检查被打回来,补上之后发现选择器都不用改,因为键盘操作走的是同一套意图对象,只是触发源从指针变成了方向键。

6.3 上线前逐项过的自查表

最后贴一份我自己用的检查清单,每一条都是实际踩过或者见过别人踩的:

场景检查项不做的后果
拖拽启动有 5px 抖动阈值点开卡片变成拖拽
拖拽中跳过了点击事件松手后误触打开详情
跨列拖拽空列也能接收卡进不去新列
边缘自动滚动生效且不抖长列表无法拖到远端
触屏touch-action与长按配合手机上拖不动
提交只在 pointerup 发请求请求风暴
幂等请求带 clientOpId重发导致重复移动
冲突409 有合并逻辑界面和服务端数据打架
广播忽略自己的操作卡片动两次
断线有重连与全量对齐状态永久不一致
空态空列有占位提示用户以为坏了
无障碍键盘可完整操作部分用户无法使用

最后再分享一个我觉得最容易被低估的技巧:把落位判定和渲染彻底解耦。判定只算出一个{ columnId, index },渲染层拿这个结果去更新占位符和位移,两边不互相依赖。这么做的好处是,我在调手感的时候可以只改判定阈值,不用碰渲染代码;在调性能的时候可以只换渲染策略,判定逻辑原封不动。这个边界一旦划清楚,后面所有的迭代都变得很轻。我在这个项目之后做的几个拖拽相关的需求,基本都是直接复用这套判定逻辑,只换皮,省下来的时间相当可观。

返回列表