分组拖动排序功能全流程实现(前端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的正确性、事务的原子性、参数的全量校验。

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

相关推荐
xcLeigh8 小时前
Unity基础:Start与Update方法——Unity脚本生命周期初探
java·unity·游戏引擎·教程
郭邯8 小时前
一次「字符洁癖」引发的思考:我用 AI 写了个全角半角转换工具
前端
想要成为糕糕手9 小时前
🚀 在浏览器里跑 DeepSeek-R1?WebGPU 端侧推理实战(五)—— 中断、重置、缓存与流式生成
前端·react.js·llm
zephyr059 小时前
链表实现O(n log n)时间复杂度的排序——归并排序详解
java·数据结构·算法
GuWenyue9 小时前
后端接口又双叒没写好?3 步搭建前端 Mock,从此告别"傻等后端"
前端·mocha
Zane19949 小时前
JRE 去哪了?一次讲清楚 JDK、JRE、JVM 到底是什么关系?
java·后端
海兰9 小时前
【数据采集】开源的 Web 数据采集与处理Firecrawl(一)
前端·开源
vivo互联网技术9 小时前
从一键检测到 AI 修复:我们如何把无障碍检查做进研发流程
前端·人工智能
PPPPickup9 小时前
基础知识差缺补漏-面试版持续更新
java·开发语言
liuxiaocheng9 小时前
聊聊 Vercel AI SDK 的流式协议:前后端到底是怎么"边想边说"的
前端·后端