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) | 取中间值 1536 | 1 行 | 好 | 反复插入会耗尽精度 |
| 字符串分数索引 | 生成 "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 },渲染层拿这个结果去更新占位符和位移,两边不互相依赖。这么做的好处是,我在调手感的时候可以只改判定阈值,不用碰渲染代码;在调性能的时候可以只换渲染策略,判定逻辑原封不动。这个边界一旦划清楚,后面所有的迭代都变得很轻。我在这个项目之后做的几个拖拽相关的需求,基本都是直接复用这套判定逻辑,只换皮,省下来的时间相当可观。