前端拖拽排序怎么选库:别一上来就自己写
要不要自己写一套拖拽排序,判断标准其实很简单:先问这个需求会不会碰到移动端触摸、嵌套容器、自动滚动、可访问性这四样里的任何一样。只要沾上一样,成本就会指数级上涨,这时候直接选成熟库几乎总是划算的。只有桌面端单列表、内部工具临时用一下这种边界明确的场景,自己撸一版才划算。
这个判断标准不是我一开始就想清楚的,是被 mousedown/mousemove/mouseup 那版自己写的排序逼出来的。桌面端 demo 跑得挺顺,问题出在接到移动端反馈之后:touchmove 默认会触发页面滚动,得手动 preventDefault,而 preventDefault 又要求事件监听器不能是 passive 的,否则浏览器直接忽略这行代码,控制台连报错都没有,只是安静地不生效。光是把这个坑摸清楚就花了大半天。后来又陆续补了拖动时的占位元素、超出可视区的自动滚动、快速拖动时鼠标"甩"出元素导致丢失的问题……补到第三周,团队里几个人排队等这个功能能用,我干脆换成了成熟库,把自己写的那版留作内部工具用。
先判断业务场景,再挑库
选库之前,得先弄清楚需求落在哪一类:普通列表排序、看板列之间拖动、树形结构拖动、表格行拖动,还是拖拽生成页面或流程图。简单列表排序用轻量库就够了,看板、嵌套容器、拖拽预览这类复杂交互,得选择能力更完整的方案,用错方向后面会很难受。
还要在动手前问清楚几个边界:是否要支持移动端触摸,是否需要跨列表拖动,是否需要拖动到空容器,是否要限制某些项不能拖,排序结果是立即保存还是手动保存,失败后要不要回滚顺序。这些问题看起来琐碎,但会直接决定后面的数据设计——尤其是"立即保存还是手动保存"这条,一旦选错,返工的不只是交互,还有接口设计。
原生 Drag and Drop API 为什么不是首选
在选第三方库之前,很多人会先想到浏览器自带的 HTML5 Drag and Drop API:给元素加 draggable="true",监听 dragstart、dragover、drop 就能跑起来,看起来不用引任何依赖。我当初也这么想过,真上手才发现坑比想象中多。
第一个坑是它天生面向"跨窗口/跨应用拖拽"设计的,那套 dataTransfer API 更像是为拖文件、拖链接准备的,用来做同页面内的列表排序反而别扭:拖动过程中的占位效果需要自己在 dragover 里算插入位置、手动挪 DOM 或者维护一个影子元素,库里现成的 ghost、chosen 样式这里全没有。第二个坑更致命:这套 API 在移动端触摸屏上基本不可用,Safari 和 Chrome 的移动版都不支持原生拖拽事件,意味着一旦产品要求兼容手机端,原生 API 直接出局,绕不过去。第三个坑是浏览器之间的行为差异,dragover 不调用 preventDefault() 的话 drop 事件根本不会触发,这个细节很容易漏掉,新手往往调试半天才发现。
原生 API 也不是没有用武之地:如果场景就是"从页面拖一个文件到浏览器里上传",或者做的是浏览器扩展、桌面应用这类不需要考虑移动端的场景,用原生事件反而更轻,不用引入几十 KB 的依赖。但只要涉及移动端触摸、需要拖拽动画、需要跨列表联动这几件事里的任意一件,我都会直接跳过原生 API,上成熟库。这和前面"要不要自己写"的判断标准其实是同一条逻辑:原生 API 本质上也是"自己写"的一种,只是浏览器帮你实现了事件触发这一层,占位、动画、触摸兼容这些真正麻烦的部分还是得自己扛。
SortableJS:简单列表排序的常用选择
SortableJS 很适合处理普通列表排序。它不强绑定某个框架,Vue、React 或原生项目都能接入,常见的任务列表、菜单排序、图片排序,都可以用它快速完成。它的优势是使用简单、体积相对可控、支持拖拽排序和跨列表拖动,生态里也有现成的 Vue、React 封装。如果需求核心就是"把列表顺序拖一拖",它通常是稳妥的第一选择。
它有几个我常用的配置点值得记一下。animation 给个 150 左右就有了顺滑的过渡,比自己写 transition 省心;handle 可以指定只有某个图标能拖,避免整行都能拖导致误触;ghostClass 和 chosenClass 用来控制拖动时占位和选中态的样式,默认那套灰色边框基本都得改。跨列表拖动靠的是 group 配置,两个列表配同一个 group 名就能互相拖,这个在做看板雏形时特别方便:
1import Sortable from 'sortablejs'; 2 3Sortable.create(listEl, { 4 animation: 150, 5 handle: '.drag-handle', 6 ghostClass: 'sortable-ghost', 7 group: 'tasks', 8 onEnd(evt) { 9 // 注意 onEnd 里 SortableJS 已经把 DOM 顺序改了 10 // 但我们的数据数组还没动,必须自己同步 11 if (evt.oldIndex === evt.newIndex) return; 12 state.list = moveItem(state.list, evt.oldIndex, evt.newIndex); 13 }, 14});
这里有个我踩过的坑:在 React/Vue 里用 SortableJS,框架重新渲染时会把 SortableJS 已经改过的真实 DOM 又按虚拟 DOM 还原回去,结果就是"拖完一闪又跳回原位"。解决办法要么在 onEnd 里先把 SortableJS 对 DOM 的修改撤销掉(把 evt.item 移回原位),让数据更新触发的渲染来决定最终顺序;要么直接用社区封装好的组件,它们内部已经处理了这层冲突。
接入这类库时,关键是把排序结果同步回数据源,不能只让 DOM 顺序变了、数据数组没变:
1function moveItem(list, oldIndex, newIndex) { 2 const nextList = [...list]; 3 const [item] = nextList.splice(oldIndex, 1); 4 5 nextList.splice(newIndex, 0, item); 6 7 return nextList; 8}
这个函数看起来简单,但它表达了一个重要原则:拖拽只是交互,真正的状态仍然应该由数据驱动。
React DnD:适合复杂 React 拖拽
React DnD 更像是一套拖拽能力框架,而不是一个排序组件。它适合拖拽卡片到不同区域、拖拽组件生成页面、棋盘或编辑器这类复杂交互。学习成本比普通排序库高一些,但抽象能力更强,项目越复杂越能体现价值。
复杂拖拽通常不是"排序"这么简单,而是"拖动一个东西到另一个区域后触发业务规则"。比如页面搭建器里,左侧物料拖到画布,实际上是创建组件;流程图里节点连线,实际上是修改图结构。这类场景如果用普通排序库硬做,后面会在命中区域、预览层、拖拽类型、状态同步上不断补洞。
React DnD 的核心抽象是 useDrag 和 useDrop 两个 hook,外加一个 type 来描述"拖的是什么、谁能接"。我做内部页面搭建器那阵,左侧物料和画布上的已有组件用了不同的 type,画布只接受这两种 type,靠 monitor.getItemType() 区分到底是"新建"还是"移动":
1const [, drop] = useDrop(() => ({ 2 accept: ['MATERIAL', 'CANVAS_NODE'], 3 drop(item, monitor) { 4 if (monitor.getItemType() === 'MATERIAL') { 5 createNode(item.componentType); // 从物料拖入,是创建 6 } else { 7 moveNode(item.id, item.targetId); // 画布内拖动,是移动 8 } 9 }, 10}));
它真正省事的地方是 monitor:拖到一半就能拿到是否悬停、是否命中、相对位置,做插入指示线(那条蓝色的横线)不用自己算坐标。代价是它绑了 React 的渲染模型,每次拖动相关的 hover 都可能触发重渲染,节点一多要注意做订阅粒度的拆分,不然画布会明显卡。另外它默认的 HTML5 后端不支持触摸,移动端得换 react-dnd-touch-backend,这点文档里不显眼,我是真机测试时才发现的——桌面端一切正常,手机上点了半天卡片纹丝不动。
最近看内部工具选型的时候,也顺手扫了一眼 dnd-kit,它去年才起步,这一年还在快速迭代,社区讨论里对它的 sensor 抽象评价不错——同一套逻辑对鼠标、触摸、键盘输入抽象得更清楚,做可访问排序会舒服一些。我暂时只在玩具项目里试过,还没上过正式项目,先记一笔留着后面评估。
React DnD 还有一个容易被忽略的细节:Provider 只能挂一个 backend,如果项目里同时要支持桌面拖拽和移动端触摸拖拽,不能简单地把 HTML5 backend 和触摸 backend 都注册上,得根据 navigator 或者媒体查询判断运行环境,动态选一个 backend 传给顶层 DndProvider。我们内部搭建器一开始想偷懒两个都注册,结果两套事件互相干扰,画布上的拖拽变得很不稳定,最后还是老老实实做了运行时判断。选型这件事说到底就一句话:不要只看 demo 顺不顺,要看它和业务模型是否匹配。简单排序用复杂框架,复杂编辑器用简单排序库,后面都会难受。
Dragula:上手快,但要看维护情况
Dragula 的 API 很直观,早两年在前端拖拽方案里很受欢迎,适合快速实现拖放效果。但在新项目里用之前,最好先检查一下维护活跃度、依赖兼容性和团队熟悉程度。
拖拽库不是只看能不能跑,还要看长期维护是否稳定。我有个内部项目当初图省事用了 Dragula,后来升级构建工具时它一个老依赖跟新版本冲突,社区又没人修,最后只能连带把这块交互重写了。选库的时候多花十分钟翻一眼 issue 列表和最近一次提交时间,比事后返工划算得多。
Vue 项目怎么选
Vue 项目里常见做法是直接用 SortableJS 的 Vue 封装,选择时重点看几件事:是否支持当前 Vue 版本,是否能绑定 v-model,数据变化是否清晰,是否支持移动端,是否方便自定义拖拽样式。不要只看 demo 好不好看,要看它和数据结构是否匹配。
团队里新项目这半年基本默认走 Vue 3,官方文档从年初起也把 Vue 3 定成了默认版本;存量的 Vue 2 项目倒是刚多了一个选项——月初 Vue 2.7 发布了,把 Composition API、<script setup> 这些能力反向移植了回来,官方也说这是 Vue 2 最后一个大版本。我们有个 Vue 2.6 的老后台系统,之前想用 Composition API 得靠 @vue/composition-api 插件,现在理论上可以直接升到 2.7 用官方内置的能力,省掉插件这一层。这个升级我们还没排上日程,但至少多了个不用整体迁到 Vue 3 的台阶。拖拽组件这块,不管项目是 Vue 2 还是 Vue 3,选型标准是一样的,跟框架版本关系不大。
Vue 里尤其要注意响应式数据更新:如果库直接操作 DOM,而你没有同步更新数组,下一次组件渲染可能把 DOM 顺序又改回去。比较稳的方式是让组件通过事件返回新顺序,然后由外层更新数据:
1function handleSortChange(nextItems) { 2 items.value = nextItems; 3}
如果排序需要保存到服务端,也建议区分"本地顺序已变化"和"服务端保存成功"两个状态。保存失败时,要么回滚,要么提示用户重新保存。我现在习惯在拖完先乐观更新本地顺序,同时存一份拖动前的快照,请求失败就用快照回滚并 toast 提示:
1async function handleSortChange(nextItems) { 2 const snapshot = items.value; 3 items.value = nextItems; // 乐观更新,先让 UI 顺 4 try { 5 await saveOrder(nextItems.map((i) => i.id)); 6 } catch (err) { 7 items.value = snapshot; // 失败回滚 8 showToast('排序保存失败,已还原'); 9 } 10}
还有个细节:用户可能拖得很快,连续触发好几次保存请求。如果不加处理,后发的请求可能先返回,造成顺序错乱。我一般给保存做个 300ms 的防抖,或者只认最后一次请求的结果,前面的直接丢弃。
不建议自己写完整拖拽排序
如果只是学习,自己写一版当然可以。但在生产项目里,完整拖拽排序会遇到很多细节:拖动过程中的滚动容器、移动端触摸事件、拖拽时元素宽高变化、动画和占位元素、键盘可访问性、数据顺序和 UI 顺序同步。这些细节越堆越多,最后很容易变成一个难维护的小框架。
如果确实要自己写,也建议只写非常明确的小范围能力,比如桌面端单列表排序、内部工具临时使用。只要涉及移动端、嵌套容器、自动滚动、可访问性,就应该重新评估成本——这也是我在开头就把这条摆出来当判断标准的原因,踩过一次坑之后才真正体会到它的分量。
保存顺序的设计
拖拽排序最终通常要落到服务端,保存方式常见有两种:提交完整排序后的 id 列表,或者只提交本次移动项和目标位置。
完整列表简单直接:
1await updateSortOrder({ 2 ids: items.map((item) => item.id), 3});
优点是服务端容易覆盖保存,缺点是列表很大时 payload 会变大,也要考虑并发修改。
只提交移动信息更轻:
1await moveItemTo({ 2 id: movedItem.id, 3 beforeId: targetItem.id, 4});
但服务端逻辑会更复杂。选哪种方式,要看列表规模、并发要求和后端排序模型。
后端排序模型这块也想多说一句。最直接的是用一个连续整数 order 字段,但插入中间位置时往往要把后面一片记录全部 +1,列表一大写放大很明显。我后来见过的更稳的做法是用浮点数或者 LexoRank 这类"排序键":往 A、B 之间插入,取它俩中点(或者中间字符串)就行,不用动其他行。代价是连续插同一个位置久了精度会用尽,需要偶尔做一次全量重排。如果是中小列表,老老实实存整数顺序、整列覆盖也完全够用,别提前过度设计。
并发也要提前想一下。两个管理员同时调整同一个菜单或看板时,后保存的人可能覆盖前一个人的顺序。简单系统可以接受最后写入覆盖,但配置平台最好带上版本号或更新时间:
1await updateSortOrder({ 2 ids: items.map((item) => item.id), 3 version: currentVersion, 4});
服务端发现版本不一致时返回冲突,前端提示用户刷新后重试。这个处理看起来麻烦,但比悄悄覆盖别人刚排好的顺序更靠谱。我们后台系统的菜单排序去年就吃过一次没版本号的亏,两个管理员前后脚保存,后一个把前一个刚调好的顺序整个盖掉了,排查起来还费了点劲——毕竟接口日志里两次请求都返回成功,谁也不知道数据被覆盖过。
排序保存的时机选择也值得单独说一下。常见做法有两种:一种是每次拖动结束(onEnd)就立刻发请求,好处是数据始终和 UI 同步,坏处是用户如果反复调整顺序,会打出一串没什么意义的请求;另一种是加一个显式的"保存"按钮,拖动过程只改本地状态,用户确认无误后统一提交。后台配置类页面我现在倾向后一种,因为管理员经常是"先拖乱试试看效果,不满意再改回来",每次挪动都落库既浪费请求,也会把中间态污染进操作日志。面向普通用户的列表(比如收藏夹排序)反而适合前一种,用户预期是"松手就是最终结果",多一步确认反而显得多余。选哪种,本质上还是要看这个排序对用户来说是"随手调整"还是"郑重编辑"。
触摸端的几个具体兼容问题
移动端拖拽排序踩的坑,比想象中琐碎得多,值得单独列几个真正会咬人的细节。
第一个是前面提过的 touchmove 与页面滚动的冲突。大多数拖拽库会在内部帮你处理好 preventDefault 和 passive 监听器的关系,但如果外层容器自己也监听了触摸事件(比如下拉刷新组件),两套监听器可能互相抢事件,导致拖拽和下拉刷新在同一个页面上打架。遇到这种情况,通常得给拖拽区域单独设置 touch-action: none,从 CSS 层面告诉浏览器这块区域不用它接管默认的触摸手势。
第二个是长按和拖拽的手势冲突。很多列表项同时支持"长按弹出操作菜单"和"按住拖动排序",两个手势在最初几十毫秒里是没法区分的。常见处理是设一个延时阈值(比如 150~200ms),在阈值内手指没有明显移动就判定为长按,移动超过一定距离就判定为拖拽,SortableJS 的 delay 和 delayOnTouchOnly 配置就是干这个的,社区封装组件不一定暴露这两个参数,选型的时候可以顺手确认一下。
第三个是拖动时的视觉反馈在小屏幕上容易失真。桌面端鼠标拖动时,占位元素跟着鼠标走,视线一直盯着光标附近就够了;触摸端手指本身会挡住拖动的元素,用户看不清当前挪到哪了。实际做法是把被拖动元素在触摸点上方偏移一小段距离渲染,让手指不挡住内容,这个细节很多 demo 里不会展示,得自己在真机上试过才会注意到。
可访问性不要完全忽略
拖拽交互对键盘用户并不友好。如果业务要求更高可访问性,至少要提供替代操作,例如上移、下移按钮:
1<button type="button">上移</button> 2<button type="button">下移</button>
这看起来不如拖拽酷,但它能让不能使用鼠标或触屏的用户完成同样任务。后台系统、配置系统里,这类替代操作很实用。我自己的体会是这对按钮顺手到不只是无障碍:写自动化测试时拖拽很难模拟,但点"上移"按钮的断言又快又稳,连带把回归测试也省心了。
性能和大列表
如果列表有几百上千项,拖拽排序会明显复杂。虚拟列表、滚动容器、拖拽预览层可能互相影响,这种场景要提前验证库是否支持虚拟滚动,或者是否能限制一次只排序当前分组。不要等页面已经卡了,再发现底层方案不适合。
虚拟列表和拖拽是天然有冲突的:屏幕外的 DOM 根本不存在,拖动到不可见位置时库不一定知道目标在哪里。如果业务一定要支持几千条排序,我会优先从产品层面拆分分组、搜索、分页排序,而不是试图让一个长列表无限拖。交互越酷,越要问一句:用户真的需要从第 3 条拖到第 900 条吗?
几个容易被忽略的延伸点
选库之外,还有几个细节值得提前想清楚。一是拖拽和长按手势在移动端经常打架:很多列表项本身还要支持"长按弹出菜单",如果拖拽库也监听同一个触摸事件序列,两者容易互相抢占,得靠延时阈值或者显式的拖拽手柄区分开,不能让整行都可拖。二是滚动容器嵌套时的自动滚动方向判断:拖到列表底部要触发容器滚动,但如果外层还有一层可滚动的页面容器,很容易出现"该滚内层却滚了外层"的错位,得明确告诉库以哪个元素为滚动边界。三是拖拽库大多通过内联样式或 transform 来做占位动画,如果项目里同时有 CSS 过渡类名控制显隐,两者的样式优先级也可能打架,调试的时候得先分清是库自己的行内样式在起作用,还是业务的类名在起作用。
这几个点单独看都不大,但凑在一起就是生产项目里拖拽排序总也做不"顺"的原因,比选哪个库本身更值得花时间想清楚。
构建和依赖层面的两个小判断
选库的时候,除了 API 好不好用,我现在也会顺手看两眼构建层面的东西。一是包体积和依赖树:SortableJS 本身没有额外依赖,打包体积可控;React DnD 依赖 dnd-core,加上 HTML5 后端和触摸后端两套实现,体积会明显重一些,如果项目对首屏包体积敏感,值得先用 webpack-bundle-analyzer 之类的工具看一眼实际增量,而不是只看 npm 页面上宣传的体积。二是类型支持:项目里用 TypeScript 的话,SortableJS 官方类型定义还比较薄,很多配置项要么类型缺失要么标了 any,用之前最好翻一下 @types/sortablejs 的更新时间,别等写到一半才发现类型对不上。
内部工具这半年也在评估 pnpm 做 monorepo,好几个共享组件(包括这个拖拽排序的二次封装)都考虑抽成独立包放到 workspace 里,避免每个项目各自复制一份 moveItem、乐观更新、防抖保存这些逻辑。这件事和选拖拽库本身关系不大,但如果一个团队里有两三个项目都要用到拖拽排序,把这层数据同步逻辑抽出来复用,比每次重新写一遍更划算。
测试和排查手段
拖拽交互的自动化测试一直是个麻烦事,端到端测试框架里模拟真实的鼠标按下、移动、松开这一整套坐标计算,写起来比点击按钮的断言啰嗦得多,还很容易因为动画时序的问题变得不稳定,同一条用例本地跑得过、CI 上偶尔失败。我现在的做法是把拖拽交互拆成两层测试:底层的数据变换函数(比如前面那个 moveItem)用普通单元测试覆盖,只测输入输出,不涉及任何 DOM;上层真正的拖拽手势,只挑一两条关键路径写端到端用例,其余交互路径靠前面提到的"上移/下移"按钮做断言,因为按钮点击的自动化测试要稳定得多。这样至少能保证核心的排序逻辑有测试兜底,不用把所有精力耗在模拟拖拽手势上。
排查线上问题时,我一般先确认是"DOM 顺序变了但数据没变"还是反过来。前者的典型表现是刷新页面后顺序又变回去了,多半是前面提到的库和框架渲染模型打架;后者的表现是页面上顺序看着对,但保存以后刷新发现顺序不对,这种情况要重点检查保存请求的 payload 里传的 id 顺序,以及后端是不是按传入顺序原样落库,还是又做了一次自己的排序。这两种问题的排查方向完全不同,一开始把现象分类分清楚,能省掉不少来回定位的时间。
回到选型这件事
简单列表排序优先看 SortableJS,复杂 React 拖拽可以考虑 React DnD,Vue 项目可以选择成熟的 Vue 封装。拖拽排序的关键不是"能拖起来",而是拖动过程稳定、数据同步清楚、维护成本可控。
我现在选拖拽库,会先把业务模型讲清楚:拖的是列表项、卡片、组件,还是流程节点;结果是排序、移动、创建,还是连线。模型清楚以后,库的选择才不会只停留在 demo 层面。