需求:
在树的结构下实现节点自由拖动,仅叶子节点可以拖动,叶子节点可以拖动到其他父节点。
在同级元素间实现拖动排序
方法一:全量更新位置法
适用场景:当需要排序的元素数量不多时,这种方法特别合适。
基本原理:给每个元素添加一个字段(比如叫sort),用来记录它当前的位置。当你在前端调整了这些元素的顺序后,将整个新序列一次性发送到后端进行更新。
具体步骤如下:
-
数据结构设计:为每个元素增加一个
sort属性,这个数字表示了该元素在整个列表中的位置。例如,如果某个元素的sort值是1,那么它就排在第一位。 -
前端操作:当你完成对元素的重新排序或移除某些元素之后,把剩下的所有元素ID按照新的顺序打包成一个数组,并把这个数组发送给服务器。数组中元素的索引位置直接反映了它们最新的排列顺序。
-
后端处理:
-
首先检查哪些元素被从列表中移除了。这一步通过比较从前端接收到的新数组和数据库里原有的ID来完成,任何只存在于数据库而不出现在新数组里的ID都会被标记为删除。
-
接下来根据新数组内元素的顺序更新所有相关项的位置信息。
-
小结:这种方式非常适合于管理少量(大约5到15个)项目的排序需求,如首页轮播图或是任务卡片等。但如果面对的是大量数据,则可能不是最佳选择。
方法二:中间值
中间值确保元素在拖动过程中的排序和布局能够平滑且合理地调整。
-
初始位置设定:每当创建一个新的元素时,我们会给它一个默认的位置(通过
pos字段记录)。这个位置的设置遵循一个简单的规则:第一个元素的位置被设为65536,第二个则是131072(即2 * 65536),以此类推,第N个元素的位置就是N * 65536。这样的设置有助于保持每个新加入元素之间的相对距离一致。 -
拖拽调整位置:当你通过拖拽改变某个元素的位置时,我们需要更新其
pos值以反映新的顺序。具体的更新规则:- 中:如果将一个元素移动到两个现有元素之间,则新位置等于这两个相邻元素位置之和的一半。
- 前:若将其移至列表最前端,则新位置是原首个元素位置的一半。
- 后:如果移动到最后面,则新位置为最后一个元素的位置加上65536。
-
整数校验与重排:有时候,经过多次调整后,可能会出现某些元素之间的
pos值不再是整数倍关系的情况。这时就需要重新安排所有元素的位置了。做法是从头开始依次给每个元素分配新的pos值,比如第一位再次设为65536,第二位设为131072,直到最后一位按照上述规律递增。
通过这种方式,我们可以灵活地改变列表内各项目的位置,并且在需要按顺序展示或处理这些数据时,只需简单地根据它们的pos值进行排序即可得到正确的序列。
但也有相应的问题:由于频繁调整导致出现小数值
解决方案:
- 使用浮点数代替整数来存储
pos值,但这会带来数据库精度控制以及前端显示准确性方面的新挑战。 - 在发现小数问题时立即对整个列表进行一次全面重排,但这可能会影响系统的响应速度,尤其是在用户交互频繁的应用场景下。
- 定期执行批量重排任务,虽然这能在一定程度上缓解性能压力,但如果定时任务执行不够及时的话,还是可能导致短时间内排序混乱的问题发生。
方法三:数组方式实现
在这个方法中,每个元素都有一个叫做index的字段,用来表示该元素在列表中的顺序。我们设定这些索引从0开始,并且随着列表的增长而递增。
基本操作如下:
-
增加元素:新增元素时,序号为当前元素数据总量值。
-
删除元素:删除元素时,将大于该元素的序号,都减1。
-
修改元素排序。当元素从 x 移动到 y 时,
- 若 x < y 时,则将(x, y)范围内的元素都减1
- 若 x > y 时,则将(y, x)范围内的元素都加1
-
查询数据:展示整个列表或查找特定位置上的项目非常简单直接——只需根据
index字段对所有项目进行排序即可得到正确顺序的结果。特别地,如果你想获取第n个位置上的项目,请记得其实际存储时对应的index应该是n-1。
总结:类似数组下标,这种方法的最大优势在于它能够快速完成查询任务,不过相对而言,在更改项目顺序方面会稍微复杂一些。
需求实现
代码如下
// 前端传入实体类
public class RegionReorderDto {
@ApiModelProperty("节点id")
private String id;
@ApiModelProperty("排序权重")
private Integer newWeight;
@ApiModelProperty("父节点id")
private String parentId;
}
主要逻辑
/**
* 对区域进行排序
*/
@Transactional
public void reorderRegions(RegionReorderDto regionReorders) {
// 获取用户 权限校验
// 验证父节点
validateParentRegion(regionReorders);
// 获取旧位置
Region sysRegion = getAndValidateRegion(regionReorders);
// 更新位置
Integer oldWeight = sysRegion.getWeight();
Integer newWeight = regionReorders.getNewWeight();
String parentId = regionReorders.getParentId();
// 执行移动操作
moveRegion(sysRegion, oldWeight, newWeight, parentId);
// 更新位置
sysRegion.setWeight(newWeight);
// 更新数据库中的资源位置
}
逻辑实现
/**
* 移动地区到新的权重位置
*
* @param sysRegion 包含地区信息的对象
* @param oldWeight 当前权重
* @param newWeight 新的权重
* @param parentId 新的父地区ID
*/
private void moveRegion(Region region, Integer oldWeight, Integer newWeight, String parentId) {
// 如果新父地区ID与当前父地区ID不同,则进行跨级移动
if (!parentId.equals(region.getParentRegionId())) {
// 更新新父地区下的权重值,使其增加1
meterMapper.updateWeightForMoveGetInto(newWeight, parentId);
// 更新旧父地区下的权重值,使其减少1
meterMapper.updateWeightForMoveLeave(oldWeight, region.getParentRegionId());
// 更新地区对象中的父地区ID
region.setParentRegionId(parentId);
} else {
// 如果新父地区ID与当前父地区ID相同,则进行同级移动
if (oldWeight.equals(newWeight)) {
// 位置未改变,无需处理
return;
} else if (oldWeight < newWeight) {
// 向后移动
meterMapper.updateWeightForBackwardMove(oldWeight, newWeight, parentId);
} else {
// 向前移动
meterMapper.updateWeightForForwardMove(oldWeight, newWeight, parentId);
}
}
}
<!-- 这是一个MyBatis XML映射文件,用于定义数据库操作 -->
<mapper namespace="com.example.region.mapper.RegionMapper">
<!-- 查询地区信息 -->
<!-- 根据给定的regionId从数据库中获取地区信息 -->
<select id="getRegion" parameterType="map" resultType="com.example.region.entity.Region">
<!-- 注意使用#{param}来防止SQL注入 -->
SELECT * FROM regions WHERE region_id = #{regionId}
</select>
<!-- 更新权重值(向前移动) -->
<!-- 将指定范围内地区的权重值增加1 -->
<update id="updateWeightForForwardMove" parameterType="map">
UPDATE regions
SET weight = weight + 1
WHERE weight >= #{newWeight} AND weight < #{oldWeight} AND parent_region_id = #{parentId}
</update>
<!-- 更新权重值(向后移动) -->
<!-- 将指定范围内地区的权重值减少1 -->
<update id="updateWeightForBackwardMove" parameterType="map">
UPDATE regions
SET weight = weight - 1
WHERE weight <= #{newWeight} AND weight > #{oldWeight} AND parent_region_id = #{parentId}
</update>
<!-- 更新权重值(插入新的权重位置) -->
<!-- 将指定范围内的地区的权重值增加1 -->
<update id="updateWeightForMoveGetInto" parameterType="map">
UPDATE regions
SET weight = weight + 1
WHERE weight >= #{newWeight} AND parent_region_id = #{parentId}
</update>
<!-- 更新权重值(移除旧的位置) -->
<!-- 将指定范围内的地区的权重值减少1 -->
<update id="updateWeightForMoveLeave" parameterType="map">
UPDATE regions
SET weight = weight - 1
WHERE weight > #{oldWeight} AND parent_region_id = #{parentId}
</update>
<!-- 更新地区信息 -->
<!-- 根据给定的regionId更新地区信息 -->
<update id="updateRegion" parameterType="com.example.region.entity.Region">
UPDATE regions
SET parent_region_id = #{parentRegionId}, weight = #{weight}
WHERE region_id = #{regionId}
</update>
</mapper>
同级移动使用数组实现:使用 newWeight字段表示记录的位置
在资源表中设置一个 newWeight 字段来表示记录的位置。当拖拽一条记录时,前端传入该记录的 id 值和新的位置值 newWeight,服务端根据 id 查询出资源原本的 Weight 值(记为 oldWeight),比较 oldWeight 和 newWeight,分为以下三种情况:
-
oldWeight == newWeight:- 处理方式:保持不变,无需进行任何操作。
-
oldWeight < newWeight:- 处理方式:把记录向后拖拽了。
- 需要修改的数据范围:所有
Weight >= newWeight且Weighton < oldWeight的记录,把这些数据的Weight值减1。 - SQL 语句:
UPDATE sys_region SET weight = weight - 1 WHERE weight <= #{newWeight} and weight > #{oldWeight} and parent_region_id = #{parentId}
3. oldWeight > newWeight:
* 处理方式:把记录向前拖拽了。
* 需要修改的数据范围:所有 `Weight <= newWeight` 且 `Weight> oldWeight` 的记录,把这些数据的 `Weight` 值加1。
* SQL 语句:
UPDATE sys_region SET weight = weight + 1 WHERE weight >= #{newWeight} and weight < #{oldWeight} and parent_region_id = #{parentId}
示例
向前移动
初始状态:
1 2 3 4
向前移动 2 到 1:
1 3 4
2
old_position = 2new_position = 1- SQL 语句:
UPDATE sys_region SET weight = weight + 1 WHERE weight >= 1 AND weight < 2;
向后移动
初始状态:
1 2 3 4
向后移动 1 到 2:
2 3 4
1
old_position = 1new_position = 2- SQL 语句:
UPDATE sys_region SET weight = weight - 1 WHERE weight <= 2 AND weight > 1;
跨级移动
在跨级移动的情况下,记录从一个父节点移动到另一个父节点。为了确保移动后的顺序正确,需要进行两步操作:
- 调整目标父节点下的记录位置:
将目标父节点下所有 weight 大于等于新位置 newWeight 的记录的 weight 值加1,为新记录腾出位置。
- 调整原父节点下的记录位置:
将原父节点下所有 weight 大于旧位置 oldWeight 的记录的 weight 值减1,填补移动后留下的空位
示例
初始状态:
10
-11 12 13
20
-21 22 23
跨级移动 11 到 20 下:
10
- 12 13
20
-21 22 23
-11
- old_position = 11
- new_position = 21
- old_parent_id = 10
- new_parent_id = 20
SQL 语句:
UPDATE sys_region SET weight = weight + 1 WHERE weight >= 21 AND parent_region_id = 20;
21 变为 22,22 变为 23,23 变为 24。
UPDATE sys_region SET weight = weight - 1 WHERE weight > 11 AND parent_region_id = 10;
12 变为 11,13 变为 12。
劣势
每次拖拽都要修改很多行数据,特别是当把一条记录拖拽到第一行时,需要修改表中所有数据的 position 值。这会导致数据库加锁(X锁),从而降低数据库的并发性能。
可以通过权限控制,只让管理员操作,防止并发。
优化:
事务管理:
如果菜单排序涉及多个操作,考虑使用事务来确保数据一致性。直接加@Transactional
并发控制:
如果多个用户同时拖动菜单,需要考虑并发问题,可能需要乐观锁或悲观锁机制。
乐观锁:
使用版本号或时间戳字段来实现乐观锁。每次更新记录时,检查版本号是否匹配,如果不匹配则抛出异常。
在 SysRegion 实体类中添加一个 version 字段。
在 Mapper.updateRegion 方法中添加对 version 的检查和更新。
// 在 reorderRegions 方法中
sysRegion.setWeight(newWeight);
sysRegion.setVersion(sysRegion.getVersion() + 1);
int rowsAffected = meterMapper.updateRegion(sysRegion, sysRegion.getVersion() - 1);
if (rowsAffected == 0) {
throw new OptimisticLockingFailureException("数据已被其他事务修改");
}
悲观锁:
在读取数据时立即加锁,确保在事务结束之前,其他事务无法修改这些数据。在 MySQL 中,使用 SELECT ... FOR UPDATE 语句来实现悲观锁。
扩展
语雀实现拖拽功能:
通过双向链表实现拖拽功能。
同级移动 moveAfter
{
"book_id": 38065185,
"format": "list",
"node_uuid": "remtZOn97MEFFgSz",
"action": "moveAfter",
"target_uuid": "tr_ZAZt6rKzJCwzX"
}
跨级移动 prependChild
{
"book_id": 38065185,
"format": "list",
"node_uuid": "tr_ZAZt6rKzJCwzX",
"action": "prependChild",
"target_uuid": "iLOavJHdV9jJLH4z"
}
跨级移动 变为根节点
{
"book_id": 38065185,
"format": "list",
"node_uuid": "bfMC1GqT5jOR_36x",
"action": "prependChild"
}
双向链表
通过设置一个头节点,是移动都是只需要传入 前一个记录的ID
增加两个字段 prev_id、sibling_id,分别表示前后指针
前端传入该记录的 id 和它新的 prev_id(前一个记录的ID),后端根据 id 和 prev_id 更新数据
- 根据 id 查询出当前记录数据 curActivity
- 更新 curActivity 前后记录的指针
//断开
UPDATE activity SET prev_id = #{curActivity.prev_id} WHERE id = #{curActivity.sibling_id};
UPDATE activity SET sibling_id = #{curActivity.sibling_id} WHERE id = #{curActivity.prev_id};
3. 根据 prev_id 查询出前一个记录数据 prevActivity 4. 设置 prevActivity 的指针和后续记录的指针
UPDATE activity SET prev_id = #{id} WHERE id = #{prevActivity.slibling_id};
UPDATE activity SET sibling_id = #{prevActivity.slibling_id} WHERE id = #{id};
UPDATE activity SET sibling_id = #{id} WHERE id = #{prev_id};
UPDATE activity SET prev_id = #{prev_id} WHERE id = #{id};
数据表加的是行锁,对数据库并发性能的影响小于数组。
与数组相比,链表更适合元素插入,每次插入一个元素,只需要移动元素的前后指针。
虽然链表适合插入记录,但是也有链表的缺陷:不适合遍历查询,要先查询出链表的一条记录,然后根据 sibling_id 不断把下一条记录查询出来。只适合于“全量查询”的场景,做排序和分页查询
语雀的文档都是全量查询的,没有做分页,应该是在内存里排序后返回给前端。