分组拖动排序功能全流程实现(前端Sortable.js + 后端Java批量更新)

实战!分组拖动排序功能全流程实现(前端Sortable.js + 后端Java批量更新)

在后台管理系统开发中,"分组拖动排序"是高频交互需求------比如用户分组、权限分组、菜单分组等场景,产品往往要求支持通过拖拽调整分组顺序,且排序结果实时持久化到数据库。本文从业务场景出发,完整拆解"前端拖拽交互 + 后端高效持久化"的实现方案,全程使用脱敏表名/类名,兼顾实用性与可落地性。

一、需求背景与技术选型

1. 核心需求

  • 前端:展示用户分组列表,支持鼠标拖拽调整分组顺序;
  • 后端:接收前端传入的分组ID顺序,自动分配连续的排序序号(1、2、3...),批量更新到数据库;
  • 性能要求:避免循环单条更新数据库,尽可能减少数据库交互次数;
  • 数据安全:确保排序更新原子性(要么全成功,要么全回滚),避免部分分组排序失效。

2. 技术选型

技术栈 选型理由
前端 Sortable.js(轻量无依赖,仅20KB,支持拖拽动画、自定义拖拽手柄)
后端 Java + Spring Boot(业务逻辑) + MyBatis(批量SQL更新)
数据库 MySQL(新增sort_num字段存储排序序号)

二、数据库设计(脱敏版)

新建用户分组表t_user_group,核心字段聚焦排序相关,其他业务字段按需扩展:

sql 复制代码
CREATE TABLE `t_user_group` (
  `id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '分组ID',
  `group_name` VARCHAR(50) NOT NULL COMMENT '分组名称',
  `sort_num` INT NOT NULL DEFAULT 0 COMMENT '排序序号(1、2、3...,越小越靠前)',
  `parent_id` BIGINT DEFAULT -1 COMMENT '父分组ID(-1代表顶级分组)',
  `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户分组表';

核心字段说明sort_num 是排序核心字段,存储连续的整数序号,查询时通过 ORDER BY sort_num ASC 即可按拖拽顺序展示。

三、前端实现(Sortable.js 拖拽交互)

1. 引入依赖

可通过CDN或npm引入Sortable.js,这里使用CDN简化示例:

html 复制代码
<!-- 引入Sortable.js -->
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<!-- 引入Axios(用于请求后端接口) -->
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>

2. 渲染分组列表

前端页面展示分组列表,为每个分组行绑定data-group-id存储分组ID(核心:后端仅需ID顺序,无需传排序号):

html 复制代码
<div class="group-list-container">
  <table>
    <thead>
      <tr><th>分组名称</th><th>操作</th></tr>
    </thead>
    <tbody id="group-list-tbody">
      <!-- 后端渲染示例(也可前端异步加载) -->
      <tr data-group-id="1"><td>普通用户组</td><td><i class="sort-handle">☰</i></td></tr>
      <tr data-group-id="2"><td>VIP用户组</td><td><i class="sort-handle">☰</i></td></tr>
      <tr data-group-id="3"><td>管理员组</td><td><i class="sort-handle">☰</i></td></tr>
    </tbody>
  </table>
</div>

3. 初始化拖拽并提交排序

核心逻辑:拖拽结束后收集分组ID顺序,调用后端接口提交,前端无需关心排序号(由后端自动分配1、2、3...):

javascript 复制代码
// 获取分组列表DOM
const groupTbody = document.querySelector('#group-list-tbody');

// 初始化Sortable
const sortable = new Sortable(groupTbody, {
  animation: 150, // 拖拽动画时长(毫秒)
  handle: '.sort-handle', // 仅拖拽手柄可触发排序(提升交互体验)
  onEnd: function () {
    // 拖拽结束后,收集分组ID顺序(核心:仅传ID列表)
    const groupIdList = Array.from(groupTbody.children).map(tr => {
      return Number(tr.dataset.groupId); // 结果示例:[2,1,3]
    });

    // 调用后端排序接口
    axios.post('/api/user-group/batch-sort', groupIdList)
      .then(res => {
        alert('排序成功!');
        // 可选:刷新列表(若需实时展示排序结果)
        // window.location.reload();
      })
      .catch(err => {
        alert('排序失败:' + err.response.data.msg);
      });
  }
});

四、后端实现(Java + MyBatis 批量更新)

1. 定义DTO(接收前端参数)

前端仅传分组ID列表,无需DTO封装复杂字段,直接用List<Long>接收即可;若需扩展,可定义简单DTO:

java 复制代码
import lombok.Data;
import java.util.List;

/**
 * 分组排序入参DTO(可选,也可直接用List<Long>接收)
 */
@Data
public class UserGroupSortDTO {
    private List<Long> groupIdList; // 拖拽后的分组ID顺序列表
}

2. Controller层(接收请求)

java 复制代码
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.List;

@RestController
@RequestMapping("/api/user-group")
public class UserGroupController {

    @Resource
    private UserGroupService userGroupService;

    /**
     * 分组批量排序接口
     * @param groupIdList 前端传入的分组ID顺序列表
     */
    @PostMapping("/batch-sort")
    public ResultResponse<Boolean> batchSort(@RequestBody List<Long> groupIdList) {
        userGroupService.redefineSort(groupIdList);
        return ResultResponse.getSuccessResponse(true, "排序成功");
    }
}

3. Service层(核心逻辑:校验 + 事务 + 自动分配排序号)

Service层是核心,需做好参数校验(避免脏数据)、事务保障(原子性)、自动分配sort_num

java 复制代码
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;

import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

@Slf4j
@Service
public class UserGroupService {

    @Resource
    private UserGroupMapper userGroupMapper;

    /**
     * 重新定义分组排序:按前端ID顺序,自动分配sort_num=1、2、3...
     */
    @Transactional(rollbackFor = Exception.class) // 事务:批量更新原子性
    public void redefineSort(List<Long> groupIdList) {
        // ========== 步骤1:参数校验(避免脏数据) ==========
        // 1.1 校验列表非空
        if (CollectionUtils.isEmpty(groupIdList)) {
            log.warn("分组排序失败:传入的ID列表为空");
            throw new BusinessException("分组ID列表不能为空");
        }
        // 1.2 校验列表无重复ID
        Set<Long> idSet = new HashSet<>(groupIdList);
        if (idSet.size() != groupIdList.size()) {
            log.warn("分组排序失败:ID列表包含重复值,列表:{}", groupIdList);
            throw new BusinessException("分组ID不能重复");
        }
        // 1.3 校验所有ID都存在(避免更新无效ID)
        int existCount = userGroupMapper.countExistGroupIds(groupIdList);
        if (existCount != groupIdList.size()) {
            log.warn("分组排序失败:存在无效ID,传入数量:{},有效数量:{}", groupIdList.size(), existCount);
            throw new BusinessException("存在无效的分组ID,请检查");
        }

        // ========== 步骤2:构造批量更新数据(自动分配sort_num) ==========
        List<UserGroup> sortList = new ArrayList<>();
        int sortNum = 1; // 排序序号从1开始
        for (Long groupId : groupIdList) {
            UserGroup userGroup = new UserGroup();
            userGroup.setId(groupId);
            userGroup.setSortNum(sortNum);
            sortList.add(userGroup);
            sortNum++;
        }

        // ========== 步骤3:批量更新排序(核心操作) ==========
        try {
            int updateCount = userGroupMapper.batchUpdateSort(sortList);
            log.info("分组排序成功:更新{}个分组的sort_num,ID列表:{}", updateCount, groupIdList);
        } catch (Exception e) {
            log.error("分组排序批量更新失败", e);
            throw new BusinessException("排序失败:" + e.getMessage());
        }
    }
}

4. Mapper层(MyBatis 批量更新SQL)

4.1 Mapper接口
java 复制代码
import org.apache.ibatis.annotations.Param;
import java.util.List;

public interface UserGroupMapper {

    /**
     * 统计有效分组ID数量(校验ID是否存在)
     */
    int countExistGroupIds(@Param("groupIdList") List<Long> groupIdList);

    /**
     * 批量更新分组排序(核心:一条SQL完成所有更新)
     */
    int batchUpdateSort(@Param("sortList") List<UserGroup> sortList);
}
4.2 Mapper.xml(关键:CASE WHEN 批量更新)

避坑重点CASE WHEN 的条件必须是分组ID(id),而非排序号(sort_num),否则更新逻辑完全失效!

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.demo.user.mapper.UserGroupMapper">

    <!-- 统计有效分组ID数量 -->
    <select id="countExistGroupIds" resultType="int">
        SELECT COUNT(1) FROM t_user_group
        WHERE id IN
        <foreach collection="groupIdList" item="id" open="(" close=")" separator=",">
            #{id}
        </foreach>
    </select>

    <!-- 批量更新排序(核心:一条SQL替代循环单更) -->
    <update id="batchUpdateSort">
        UPDATE t_user_group
        SET sort_num = CASE id
            <foreach collection="sortList" item="item" index="index">
                WHEN #{item.id} THEN #{item.sortNum} <!-- 按ID匹配,赋值新排序号 -->
            </foreach>
        END
        WHERE id IN
        <foreach collection="sortList" item="item" open="(" close=")" separator=",">
            #{item.id}
        </foreach>
    </update>

</mapper>

5. 配套工具类(异常 + 统一返回)

5.1 业务异常类
java 复制代码
/**
 * 自定义业务异常
 */
public class BusinessException extends RuntimeException {
    public BusinessException(String message) {
        super(message);
    }
}
5.2 统一返回类
java 复制代码
import lombok.Data;

/**
 * 接口统一返回结果
 */
@Data
public class ResultResponse<T> {
    private int code; // 200=成功,500=失败
    private String msg; // 提示信息
    private T data; // 返回数据

    // 成功响应
    public static <T> ResultResponse<T> getSuccessResponse(T data, String msg) {
        ResultResponse<T> response = new ResultResponse<>();
        response.setCode(200);
        response.setMsg(msg);
        response.setData(data);
        return response;
    }

    // 失败响应
    public static <T> ResultResponse<T> getFailResponse(String msg) {
        ResultResponse<T> response = new ResultResponse<>();
        response.setCode(500);
        response.setMsg(msg);
        response.setData(null);
        return response;
    }
}

五、关键优化点与避坑指南

1. 核心优化点

优化策略 价值
批量SQL更新 一条SQL完成所有分组的sort_num更新,替代循环单条更新,减少数据库交互
事务保障 确保排序更新原子性,避免"部分分组更新成功、部分失败"
全量参数校验 拦截空列表、重复ID、无效ID,避免脏数据入库
前端仅传ID列表 简化前端逻辑,排序号由后端统一分配,避免前后端数据不一致

2. 常见避坑点

  • SQL语法错误CASE WHEN 条件写成 #{item.sortNum} 而非 #{item.id},导致更新无效果;
  • 无事务包裹:批量更新时数据库异常,导致部分分组排序号错误;
  • 前端传部分ID :仅传拖拽的分组ID,未传全量,导致未传的分组sort_num断层;
  • 空指针风险 :未校验groupIdList为null,或UserGroupsortNum字段为null;
  • 排序号不连续 :后端未从1开始分配序号,或序号递增逻辑错误(如sortNum += 2)。

六、扩展场景:部分分组排序(非全量)

若业务需求是"仅拖拽调整单个分组,其余分组自动顺延"(比如把3号分组拖到2号位置,原2、4号分组顺延),可调整逻辑:

  1. 前端传"被拖拽分组ID + 目标位置序号";
  2. 后端先查询所有分组的sort_num,调整目标位置后分组的序号(如sort_num += 1);
  3. 仍用批量SQL更新,避免循环单更。

七、总结

分组拖动排序功能的核心是"前端轻量交互 + 后端高效持久化":

  1. 前端用Sortable.js实现拖拽,仅需传递ID顺序,无需关心排序号计算;
  2. 后端通过CASE WHEN批量SQL更新,配合事务和参数校验,确保排序高效且安全;
  3. 避坑关键:批量SQL的正确性、事务的原子性、参数的全量校验。

该方案兼顾性能与可维护性,可直接适配到用户分组、菜单、角色等各类需要拖拽排序的场景中。

相关推荐
TH_16 小时前
15、IDEA可视化操作代码分支
java
编程大师哥6 小时前
Java Web 核心全解析
java·开发语言·前端
fruge6 小时前
Electron 桌面应用开发:前端与原生交互原理及性能优化
前端·electron·交互
一路向前的月光6 小时前
Eltable二次封装
javascript·vue.js·elementui
执行上下文6 小时前
WordPress评论留言通知推送插件!
javascript·php
惊鸿.Jh6 小时前
若依自定义后端接口404踩坑记录
java·开发语言
源码获取_wx:Fegn08956 小时前
基于springboot + vue考勤管理系统
java·开发语言·vue.js·spring boot·后端·spring·课程设计
认真敲代码的小火龙6 小时前
【JAVA项目】基于JAVA的仓库管理系统
java·开发语言·课程设计