树的结构下实现叶子节点自由拖动

284 阅读11分钟

需求:

在树的结构下实现节点自由拖动,仅叶子节点可以拖动,叶子节点可以拖动到其他父节点。

在同级元素间实现拖动排序

方法一:全量更新位置法

适用场景:当需要排序的元素数量不多时,这种方法特别合适。
基本原理:给每个元素添加一个字段(比如叫sort),用来记录它当前的位置。当你在前端调整了这些元素的顺序后,将整个新序列一次性发送到后端进行更新。
具体步骤如下

  • 数据结构设计:为每个元素增加一个sort属性,这个数字表示了该元素在整个列表中的位置。例如,如果某个元素的sort值是1,那么它就排在第一位。

  • 前端操作:当你完成对元素的重新排序或移除某些元素之后,把剩下的所有元素ID按照新的顺序打包成一个数组,并把这个数组发送给服务器。数组中元素的索引位置直接反映了它们最新的排列顺序。

  • 后端处理

    • 首先检查哪些元素被从列表中移除了。这一步通过比较从前端接收到的新数组和数据库里原有的ID来完成,任何只存在于数据库而不出现在新数组里的ID都会被标记为删除。

    • 接下来根据新数组内元素的顺序更新所有相关项的位置信息。

小结:这种方式非常适合于管理少量(大约5到15个)项目的排序需求,如首页轮播图或是任务卡片等。但如果面对的是大量数据,则可能不是最佳选择。

方法二:中间值

中间值确保元素在拖动过程中的排序和布局能够平滑且合理地调整。

  1. 初始位置设定:每当创建一个新的元素时,我们会给它一个默认的位置(通过pos字段记录)。这个位置的设置遵循一个简单的规则:第一个元素的位置被设为65536,第二个则是131072(即2 * 65536),以此类推,第N个元素的位置就是N * 65536。这样的设置有助于保持每个新加入元素之间的相对距离一致。

  2. 拖拽调整位置:当你通过拖拽改变某个元素的位置时,我们需要更新其pos值以反映新的顺序。具体的更新规则:

    • 中:如果将一个元素移动到两个现有元素之间,则新位置等于这两个相邻元素位置之和的一半。
    • 前:若将其移至列表最前端,则新位置是原首个元素位置的一半。
    • 后:如果移动到最后面,则新位置为最后一个元素的位置加上65536。
  3. 整数校验与重排:有时候,经过多次调整后,可能会出现某些元素之间的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),比较 oldWeightnewWeight,分为以下三种情况:

  1. oldWeight == newWeight

    • 处理方式:保持不变,无需进行任何操作。
  2. oldWeight < newWeight

    • 处理方式:把记录向后拖拽了。
    • 需要修改的数据范围:所有 Weight >= newWeightWeighton < oldWeight 的记录,把这些数据的 Weight 值减1。
    • SQL 语句:
UPDATE sys_region SET weight = weight - 1 WHERE weight &lt;= #{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 &lt; #{oldWeight} and parent_region_id = #{parentId}

示例

向前移动

初始状态:

1 2 3 4

向前移动 21

1   3 4
2
  • old_position = 2
  • new_position = 1
  • SQL 语句:
UPDATE sys_region SET weight = weight + 1 WHERE weight >= 1 AND weight < 2;
向后移动

初始状态:

1 2 3 4

向后移动 12

  2 3 4
  1
  • old_position = 1
  • new_position = 2
  • SQL 语句:
UPDATE sys_region SET weight = weight - 1 WHERE weight <= 2 AND weight > 1;

跨级移动

在跨级移动的情况下,记录从一个父节点移动到另一个父节点。为了确保移动后的顺序正确,需要进行两步操作:

  1. 调整目标父节点下的记录位置:

将目标父节点下所有 weight 大于等于新位置 newWeight 的记录的 weight 值加1,为新记录腾出位置。

  1. 调整原父节点下的记录位置:

将原父节点下所有 weight 大于旧位置 oldWeight 的记录的 weight 值减1,填补移动后留下的空位

示例

初始状态:

10 
 -11 12 13
20 
 -21 22 23

跨级移动 1120 下:

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 更新数据

  1. 根据 id 查询出当前记录数据 curActivity
  2. 更新 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 不断把下一条记录查询出来。只适合于“全量查询”的场景,做排序和分页查询

语雀的文档都是全量查询的,没有做分页,应该是在内存里排序后返回给前端。