设计万物 - 拖动列表排序
场景
在现代 Web 应用中,拖放排序功能为用户提供了直观、便捷的交互体验。无论是调整任务列表的优先级,还是重新排列相册中的图片,流畅的拖放操作总能极大地提升用户满意度。然而,一个看似简单的功能,其背后却需要一个高效、稳健的后端设计来支撑。
思考
在我最早写代码的时候,那个时候还没有那么流行 H5 前端技术也没有现在迭代那么好,拖动排序也还不流行,那个时候的排序是分为:上移、下移、置顶、置底这些操作。所以在那个时候,并没有什么问题,你只需要一个 order 值记录数据的顺序位置,并在点击按钮的时候执行对应操作进行整体排序亦或是简单的数据修改就可以了。比如:上移其实就是和前一条数据交换 order 值罢了。
随之技术发展,到了现在,拖动变成了一个非常容易实现的操作,从而问题就出现了。如何让一个数据放到任意一个位置呢?
方案
笨办法
当年我的第一个方案就是前端传递这个数据的最终位置信息,比如将 A 移动到,2 和 3 数据之间。然后后端收到这个数据重新计算所有数据的 order 并更新。
1 | {"id": 100001, "pre_order": 2, "post_order": 3} |
这就和一个数组排序一样,将一个数据找到起点或终点,然后定位到所需位置,然后将之后的数据全部重新计算一次 index 索引之后重新得到新的顺序,这个方案可能是最直接能被想到的。可以简单叫 ”更新法“
到现在回想起来,其实我觉得这种方案在大多数情况下依旧是可用的,为什么?因为绝大多数需要进行拖动排序的场景不会有太大的数据量。仔细思考 🤔,如果你有一千条数据,你是不可能期望用户将一条数据从第一拖动到 500 的,这操作太麻烦了。多数场景下几十条数据之内是会采用这种交互的,而这个方案最大的问题就是可能一次性更新的记录数太多。不过也正由于场景中数据量不会太大,所以没什么问题。
但如果真的数据量比较多,又有调整顺序的需求呢?即便不是拖动的交互。
新办法
为了解决全量更新法的性能问题,我们可以采用一种更为精妙的方法——取中值法。这种方法的核心思想是,为每个列表项分配一个排序值(可以是浮点数或一个很大的整数),当一个项目被移动到新的位置时,它的排序值将被设置为其前后两个项目排序值的中间值。
实现逻辑
我们同样需要一个排序字段,例如 sort_key,但这次它的类型通常是浮点数(float 或 double)或长整型(bigint)。为了避免一开始就出现小数,可以初始化时使用较大的整数间隔,例如 10000, 20000, 30000…
1 | {"id": 100001, "pre_id": 100002, "post_id": 100005} |
通过 pre_id 和 post_id 找到 prev_sort_key next_sort_key,然后计算这两个值的中间值:new_sort_key = (prev_sort_key + next_sort_key) / 2。
让我们直接看表格数据变化会更容易理解,下面展示的是任务 D 移动到 任务 A 和任务 B 之间的情况:
| 项目 | 排序前顺序 | 排序前排序键 (Sort Key) | 排序后顺序 | 排序后排序键 (Sort Key) | 备注 |
|---|---|---|---|---|---|
| 任务 A | 1 | 10000 | 1 | 10000 | 未移动 |
| 任务 B | 2 | 20000 | 3 | 20000 | 未移动 |
| 任务 C | 3 | 30000 | 4 | 30000 | 未移动 |
| 任务 D | 4 | 40000 | 2 | 15000 | 被移动项,排序键更新 |
| 任务 E | 5 | 50000 | 5 | 50000 | 未移动 |
特殊情况
这个方案也有几个特殊情况需要处理
- 移动到首位:如果项目被移动到列表的最前面,其新的
sort_key可以是当前最小sort_key的一半,或者min_sort_key - 10000(如果使用整数间隔)。 - 移动到末尾:如果被移动到最后,其新的
sort_key可以是当前最大sort_key再加上一个固定间隔,例如max_sort_key + 10000。 - 精度问题:如果频繁地在两个元素之间插入新元素,
sort_key的浮点数精度可能会耗尽,或者整数间隔会变得非常小。此时,需要一个“重排(rebalancing)”机制,当检测到排序间隔过小时,异步地重新计算并均匀分布整个列表的sort_key值。
对于精度问题这通常是一个兜底的方案,实际中很少情况会出现经常拖动到同一个位置的情况,并且如果选择间隔为 65536 是能支持拖 16 次…
优点
这个方案的优点非常明显,就是拖动完成之后只需要更新一条数据就可以完成想要的排序操作,避免的频繁更新大量数据的问题,同时这个方案的实现代码会更简单,只需要简单的查询和计算即可,并且可以将两种特殊情况放入到基础情况中一并计算,代码会更加简洁。
总结
当然选择哪种拖动排序的设计方案,最终取决于您的具体业务需求。
- 如果您的应用场景中列表规模可控,且用户不会进行过于频繁的排序操作,更新法以其简单性和可靠性成为一个不错的选择。
- 而对于追求极致性能和流畅用户体验的大型应用,取中值法无疑是更优的方案。虽然实现上稍显复杂,但它带来的性能提升和可扩展性是全量更新法无法比拟的。
这作为第一篇,也就是我最想写的一篇,我的感觉是,如果我从没有听说过这个方案,那么我可能从工作开始到结束会一直用老办法用到底。我也是偶然在知乎上看到别人发帖才去仔细思考这个场景和他们提出的设计,最后用在了实际业务中发现确实好用。一旦你听过这个设计,那么会让系统的某个小部分更优雅,也正是这一个个小场景中的小设计其实是最容易被学会的。这种经验我觉得会比我们常说的系统设计来的更有意义。




