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

发布时间:2026/10/29 3:46:21
快马平台+马威斯:协作看板拖拽与并发落地实践 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用数值而不是下标。数值的好处是可以取中间值要把卡片插到 order1024 和 order2048 之间新卡片的 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 而不是原生 dragHTML5 原生拖放draggabletruedragstart/dragover/drop看起来最省事浏览器自带拖影、自带光标反馈写起来也就几十行。但我试过一轮之后放弃了原因有三一是拖影没法控制。原生拖放生成的半透明快照是浏览器画的样式、缩放、圆角你都改不了。看板里的卡片通常有阴影、有标签颜色、有头像原生拖影出来常常是一个灰扑扑的方块跟设计稿差得远。二是移动端基本不可用。原生拖放在触屏设备上的行为很不一致有的浏览器需要长按 300ms 才触发有的干脆不触发。看板这种高频交互移动端体验不能将就。三是过程事件太粗。dragover虽然会持续触发但要做边缘自动滚动、要做实时落位高亮你还需要精确的坐标和帧级控制原生 API 在这块给的东西不够。所以最终用的是Pointer Eventspointerdown/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取消。实现上不是自己造一套而是给卡片加上rolebutton、aria-grabbed、tabindex0然后用aria-livepolite的区域播报状态变化div aria-livepolite classsr-only 卡片「梳理埋点字段」已移动到「进行中」第 2 位 /div这个播报对读屏用户是刚需对普通用户无感视觉隐藏成本很低。我一开始也嫌麻烦没做后来做无障碍检查被打回来补上之后发现选择器都不用改因为键盘操作走的是同一套意图对象只是触发源从指针变成了方向键。6.3 上线前逐项过的自查表最后贴一份我自己用的检查清单每一条都是实际踩过或者见过别人踩的场景检查项不做的后果拖拽启动有 5px 抖动阈值点开卡片变成拖拽拖拽中跳过了点击事件松手后误触打开详情跨列拖拽空列也能接收卡进不去新列边缘自动滚动生效且不抖长列表无法拖到远端触屏touch-action与长按配合手机上拖不动提交只在 pointerup 发请求请求风暴幂等请求带 clientOpId重发导致重复移动冲突409 有合并逻辑界面和服务端数据打架广播忽略自己的操作卡片动两次断线有重连与全量对齐状态永久不一致空态空列有占位提示用户以为坏了无障碍键盘可完整操作部分用户无法使用最后再分享一个我觉得最容易被低估的技巧把落位判定和渲染彻底解耦。判定只算出一个{ columnId, index }渲染层拿这个结果去更新占位符和位移两边不互相依赖。这么做的好处是我在调手感的时候可以只改判定阈值不用碰渲染代码在调性能的时候可以只换渲染策略判定逻辑原封不动。这个边界一旦划清楚后面所有的迭代都变得很轻。我在这个项目之后做的几个拖拽相关的需求基本都是直接复用这套判定逻辑只换皮省下来的时间相当可观。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询