当前位置: 首页 > news >正文

EasyUI DataGrid分页实战:SSM项目中的参数、SQL与排错全解

简介:这是一份Spring、SpringMVC、MyBatis与EasyUI整合的分页Demo,面向正在学习SSM框架整合及Web分页功能的Java开发者。项目采用Maven构建,完整演示了后端数据库查询、MyBatis映射、SpringMVC控制器处理与前端EasyUI分页组件之间的协作流程,可帮助理解分页参数传递、PageHelper或自定义拦截器等实现思路。压缩包共122个文件,以Java源码、XML配置、SQL脚本、PNG截图为主,另有JSP页面、JS、CSS及Eclipse项目配置等,充分覆盖了一个SSM+EasyUI项目的典型结构,压缩后仅1.68MB,轻量易用。目前已有260人学习下载。通过学习该Demo,开发者可快速掌握SSM框架整合要点、分页前后端联调方法,并基于现成的SQL和前端代码完成二次扩展,适合作为课设、毕设或企业入门项目的参考资料。 最近在维护一套spring + springmvc + mybatis + easyui拼起来的后台管理系统,代码是从上一个团队手里接过来的。功能本身不算复杂,但每次新增一个列表接口,最容易出问题的往往不是业务 SQL,而是分页。前端 EasyUI DataGrid 明明配置了 pagination,后端也写了分页查询,结果接口一调:total 是 0、第二页翻不动、搜索条件一加数据全乱套。这种问题说大不大,但排查起来要顺着页面、请求参数、Controller、Mapper SQL 一路找,没有经验确实会卡半天。下面就把这套组合从请求参数、JSON 返回格式到 MyBatis 分页的完整链路讲清楚,并把我自己联调踩过的坑和验证方法一起整理出来。适合正在接手 SSM 老项目,或者第一次在 SpringMVC 项目里给 EasyUI 表格加分页的同事参考。

1. 先把这个技术组合的分工和分页位置对齐

1.1 四层技术各自管什么

我对这套组合的理解是:Spring 管对象,SpringMVC 管 HTTP 请求和响应,MyBatis 管数据库访问,EasyUI 管页面表格展示。分页不是某一个层的功能,而是横跨四层的一套约定。很多项目失败,不是因为某一层写错了,而是四层之间的"暗号"没有对齐。

层级主要职责分页关心的事
前端 EasyUI DataGrid表格渲染、分页条当前页码、每页条数
SpringMVC Controller接收请求参数,返回 JSONpage/rows 参数怎么接收
Service / MyBatis数据查询、总数查询分页 SQL、count SQL
返回 JSON交给 DataGrid 渲染total 总条数、rows 当前页数组

1.2 "分页"容易在哪里走样

我见过不少同事在第一次接触 EasyUI 时,习惯性地用 Element UI 或 Bootstrap Table 的思维去思考分页,心里想的是 pageNum、pageSize,实际 EasyUI DataGrid 默认给后端传的是 page 和 rows。这两个词表面上只是命名不同,但如果大家都按自己的习惯写,前后端一对接就会出现"total 能拿到,表格只有第一页"、"后端按 pageNum 取参,全是 null"这类问题。

另一个容易走样的点,是页码从 0 开始还是从 1 开始。EasyUI 的默认分页条,页码是从 1 开始的;而后端很多同事写偏移量时直接(page - 1) * rows。如果前端传 1,后端计算成 0,那正好对;如果某个环节写成了(page - 2) * rows,那第一页就会直接从第 rows 条开始取,表现就是"第一页数据莫名少了前几条"。

所以,解决分页问题前,先别急着改代码。打开浏览器开发者工具,看看实际发出去的请求参数是什么,看看后端返回值是什么。把"谁传了什么、谁返回了什么"对齐,问题往往已经解决一半。

2. EasyUI DataGrid 的请求参数和返回 JSON 约定

2.1 前端 pagination 到底做了什么

一个最基础的 EasyUI DataGrid 配置大概是这样的:

$('#dg').datagrid({ url: '/user/list', method: 'post', pagination: true, pageSize: 20, pageList: [10, 20, 50, 100], columns: [[ { field: 'id', title: 'ID' }, { field: 'name', title: '姓名' } ]] });

当表格加载时,EasyUI 会自动把分页参数拼到请求里,实际请求大概是:

POST /user/list page=1&rows=20

注意,这里rows不是数据行数组,而是"每页显示多少条",和常说的 pageSize 一个意思。page是当前页码,从 1 开始。这两个参数名是 EasyUI 的默认约定,也是后端最容易搞混的地方。

如果需要在查询时带上搜索条件,可以直接调datagrid('load', {...})

$('#searchBtn').click(function () { $('#dg').datagrid('load', { name: $('#name').val(), deptId: $('#deptId').combobox('getValue') }); });

这样发出去的请求会变成:

page=1&rows=20&name=张三&deptId=10

2.2 后端必须返回 total + rows

EasyUI DataGrid 对返回 JSON 的要求非常固定:必须包含totalrows两个字段。total是符合条件的总条数,rows是当前页数据数组。一个标准的返回结构是:

{ "total": 100, "rows": [ { "id": 1, "name": "张三" }, { "id": 2, "name": "李四" } ] }

很多后端会把字段封装成recordslistdata,或者返回PageResult里包含pageNumpageSize,这些 EasyUI 默认都不认识。结果是表格能显示第一页数据,但底部分页栏的 total 永远是 0,翻页也会异常。

如果后端接口已经定了另一种结构,又不想改后端,可以在前端 DataGrid 配置里加loadFilter做映射:

$('#dg').datagrid({ url: '/user/list', pagination: true, loadFilter: function (data) { if (data && data.result) { return { total: data.result.total, rows: data.result.list }; } return data; } });

我的建议是:新写的接口直接按total + rows返回,别留映射这层。老项目如果改后端成本太高,再用loadFilter,但要在注释里写清楚为什么。

3. Controller 到 MyBatis:三种分页落地方式和取舍

3.1 手写 LIMIT 参数:最直白的入门写法

如果你的项目不想引额外依赖,手写 LIMIT 是最直观的方式。Controller 接收 page 和 rows,Service 计算 offset,Mapper 的 SQL 用 LIMIT 接收两个参数。

@RequestMapping("/list") @ResponseBody public Map<String, Object> list(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "20") Integer rows, UserQuery query) { int offset = (page == null || page < 1) ? 0 : (page - 1) * rows; query.setOffset(offset); query.setRows(rows); List<User> list = userMapper.selectPage(query); int total = userMapper.countPage(query); Map<String, Object> result = new HashMap<>(); result.put("total", total); result.put("rows", list); return result; }

Mapper XML 里大概是:

<sql id="queryCondition"> <where> <if test="name != null and name != ''"> and name like concat('%', #{name}, '%') </if> <if test="deptId != null"> and dept_id = #{deptId} </if> </where> </sql> <select id="selectPage" resultType="User"> select * from t_user <include refid="queryCondition"/> order by id desc limit #{offset}, #{rows} </select> <select id="countPage" resultType="int"> select count(*) from t_user <include refid="queryCondition"/> </select>

这种方式最大的好处是没有魔法,SQL 完全可控。代价是每个列表接口都要写 selectPage 和 countPage 两段 SQL,还要保证它们查询条件完全一致。条件一旦多起来,<sql>片段的重要性就非常明显了。

不同数据库的方言也不一样。手写时至少要清楚自己的库用哪种写法:

数据库分页写法
MySQLLIMIT #{offset}, #{rows}
PostgreSQLLIMIT #{rows} OFFSET #{offset}
Oracle 12c+OFFSET #{offset} ROWS FETCH NEXT #{rows} ROWS ONLY
SQL Server 2012+OFFSET #{offset} ROWS FETCH NEXT #{rows} ROWS ONLY

3.2 PageHelper:省心,但有几条红线不能碰

如果项目里允许引入 PageHelper,我通常会优先用插件,因为省去了写 count 查询的麻烦,也避免了 count 和 list 查询条件不一致这类低级错误。

SSM 项目里,除了依赖,要在 MyBatis 配置里注册分页拦截器:

<plugins> <plugin interceptor="com.github.pagehelper.PageInterceptor"> <property name="helperDialect" value="mysql"/> </plugin> </plugins>

Java 代码里这样用:

PageHelper.startPage(page, rows); List<User> list = userMapper.selectList(query); PageInfo<User> pageInfo = new PageInfo<>(list); Map<String, Object> result = new HashMap<>(); result.put("total", pageInfo.getTotal()); result.put("rows", pageInfo.getList());

PageHelper 的原理可以简单理解成:startPage把分页参数放到 ThreadLocal,MyBatis 执行下一条 select 语句时,拦截器动态给这条 SQL 拼接 LIMIT,并额外生成一条 count 查询。正是因为这样,它有几条非常容易踩的红线:

  1. startPage后面必须紧跟第一条 Mapper 查询,中间不能插入其他无关查询。
  2. 同一个方法里如果有多条 select,只有紧跟startPage后的那一条会被分页,后面的不会。
  3. 如果在 SpringMVC 的 Controller 里用,要确保一个请求一个线程,不能随便开异步线程去查,否则 ThreadLocal 里的分页参数可能串到别的请求。
  4. 稳妥做法是在 finally 里调用PageHelper.clearPage(),防止异常场景下分页参数残留。

3.3 自定义拦截器:理解原理比拿来用更重要

有些老项目因为历史原因不想引第三方分页插件,或者需要兼容多套数据库,会自己写 MyBatis 拦截器。我也做过,但一般不建议在没有充分测试的情况下直接上生产。拦截器的思路大致是:实现org.apache.ibatis.plugin.Interceptor,拦截Executorquery方法,判断参数里是否包含分页对象;如果有,就先改写 BoundSql 生成 count 查询,再把原 SQL 拼接上当前方言的分页语句,最后把结果封装成带 total 的对象。

这个方案难点不在截获 SQL,而在"如何判断参数对象里的分页字段"、"如何同时处理 count 和 list 两条 SQL"、"如何兼容多方言"。说实话,做出来至少需要两三天的测试成本。如果你只是要解决一个简单表格的分页,手写 LIMIT 或 PageHelper 已经够了。自定义拦截器更适合需要沉淀公司内部框架、统一分页规范的大团队。

4. 联调中出现最多的四个分页症状和排查路径

4.1 症状对照表

我在项目里遇到最多的分页问题,基本可以收敛成下面四类:

症状最常见原因快速处理方向
total 显示 0,表格却有数据返回 JSON 没有 total 字段,或字段名不叫 total查看响应 JSON,改字段名或加 loadFilter
点第二页,数据和第一页完全一样后端没有根据 page 计算 offset,SQL 没拼接 LIMIT看 SQL 日志,确认 LIMIT 参数是否变化
搜索条件后翻页,total 恢复成全部数据搜索条件没有传到 count 查询检查 controller 接收参数和 count SQL 的 where 条件
请求 page/rows 总是为空Controller 接收的是 pageNum/pageSize,或前端没传统一参数名,或在 Controller 用 @RequestParam 显式声明

4.2 完整的排查链路

遇到分页问题,我建议按下面的顺序去看,而不是一上来就怀疑 MyBatis 配置:

  1. 打开浏览器开发者工具,切到 Network,重新点一次查询或翻页。
  2. 查看请求 URL 和 Payload,确认有没有pagerows,确认值是否在变。
  3. 查看响应 JSON,确认total是不是预期数字,rows是不是数组。
  4. 如果响应没问题但页面显示不对,再去看后端日志里 MyBatis 打印的 SQL 和参数。
  5. 检查 SQL 里的 LIMIT 参数:第一页应该是 0, 20,第二页应该是 20, 20。

这套顺序能帮你快速把问题锁定在"前端没传""后端没接""SQL 没分页""返回格式不对"四类中的某一类。每次排查都从网络面板开始,会节省大量时间。

4.3 搜索条件怎么才不会丢

搜索条件丢失是分页问题里最隐蔽的一类。很多时候第一页查询带着 name 条件没问题,翻到第二页,条件就丢了,或者 total 变成了不符合条件的数据总数。

原因通常是前端翻页时 EasyUI 只带了 page 和 rows,之前load传的条件没有保留。解决方法是把搜索条件做成一个独立对象,每次翻页和搜索都走datagrid('load', query),而不是直接修改 URL 或只调用reload

function loadGrid() { var query = { name: $('#name').val(), status: $('#status').combobox('getValue') }; $('#dg').datagrid('load', query); } $('#searchBtn').click(loadGrid);

后端这边,建议把查询条件封装成一个 Query 对象,Controller 方法参数直接写UserQuery query,这样 page、rows 和业务条件都在一个对象里,后续加条件也不会漏。

5. 让分页 SQL 现形:日志配置与大分页优化

5.1 MyBatis 日志怎么打开

要确认分页到底有没有生效,最实在的方法是看 MyBatis 打印的 SQL。在 logback 或 log4j 里把 Mapper 包日志级别调到 DEBUG:

<logger name="com.example.mapper" level="DEBUG"/>

如果项目用的是 MyBatis 全局配置,可以显式指定日志实现:

<settings> <setting name="logImpl" value="SLF4J"/> </settings>

打开后,执行列表查询时日志里应该能看到类似的输出:

Preparing: select * from t_user where name like concat('%', ?, '%') order by id desc limit ?, ? Parameters: 张三(String), 0(Integer), 20(Integer)

看到limit ?, ?和参数里的0, 20,说明分页 SQL 是生效的。如果日志里只有 select 没有 limit,说明前端 page/rows 参数没有传到 Mapper,或者 PageHelper 的 startPage 没有作用到这条查询上。

5.2 大分页不要硬翻

LIMIT 分页在数据量小时很轻松,但一旦出现LIMIT 100000, 20这种深分页,数据库仍然要把前 10 万行扫出来再丢掉,性能会肉眼可见地下降。对后台管理列表来说,最简单的限制是限制最大页码和页大小,比如 page 最大 100,rows 最大 100,超过就返回空或按最大值处理。

如果业务确实需要深翻,可以考虑"主键游标"方式。比如列表按 id 排序,记录上一页最后一条的 id,下一页查询用:

select * from t_user where id > #{lastId} order by id limit #{rows}

这样跳过的行数不再随页数增加,性能会稳定很多。缺点是 EasyUI 的分页条是基于页码的,要做成"加载更多"或"上一页/下一页"模式,对列表交互有一定改造。

如果不想改交互,还可以用子查询先取主键:

select u.* from t_user u inner join ( select id from t_user order by id limit #{offset}, #{rows} ) tmp on u.id = tmp.id order by u.id;

这种写法对 InnoDB 大表比较友好,可以先走覆盖索引拿到主键,再回表取完整行。

5.3 我习惯的公共分页返回结构

最后分享一个我一直在用的公共结构。把请求参数封装成一个 PageQuery,把返回结果封装成一个 PageResult,所有 Controller 都返回同一套格式,前端 EasyUI 只需配置一次,后续新页面几乎不需要再调分页:

public class PageQuery { private Integer page = 1; private Integer rows = 20; // getter / setter 略 } public class PageResult<T> { private long total; private List<T> rows; // getter / setter 略 }

Controller 里统一转成 Map 或者直接返回 PageResult 都行,只要最终 JSON 里是 total 和 rows 这两个字段。几个项目跑下来,这套小封装能省掉大量重复的分页联调时间。遇到分页问题也只需要盯着一个公共类排查,不用每个接口各查各的。

本文还有配套的精品资源,点击获取

http://www.cnnetsun.cn/news/4343175.html

相关文章:

  • Grok Bot接入实战:API调用、本地部署与虚拟信用卡代购风险解析
  • 基于DSP28335的三电平SVPWM算法实现与调试
  • 毕业写论文不用乱氪金!一站式学术 AI,帮你省下查重会员钱
  • Replit智能路由与企业功能实战:从云端部署到灰度发布的完整指南
  • LeetCode题库压缩包:从解压避坑到打造个人刷题工作区
  • 开放世界多智能体自主数学发现:框架设计与工程实践
  • MKVToolNix v95.0:无损视频容器处理与自动化脚本实战
  • 3D人脸识别智能门锁深度解析:从防攻击原理到德施曼Q2FD选购验证指南
  • 蚂蚁工程数据挖掘岗笔试全解析:从特征工程到SQL优化
  • 嵌入式状态机与事件驱动架构:从混乱逻辑到可控设计
  • 嵌入式裸机用定时器模拟任务:从超级循环到轻量级时间片调度
  • M3U8转MP4:HLS流视频下载与TS合并的完整实现指南
  • YS312红外感应器STM32驱动实战:从硬件接线到软件消抖
  • 壁挂式饮水平台机深度解析:冰热双温、安装条件与选型指南
  • AI付费只看结果:从在线近红外到AI工具选型的工程逻辑
  • 山特SK2000 UPS深度评测:从原理到实战,构建家庭办公电力防线
  • 跨语言追踪:从分散到统一,构建千万QPS下的可观测链路
  • GPU代码里藏着的“方言“:AI能听懂英伟达最新硬件说的话吗?
  • 基于运动模仿的肌肉骨骼运动控制算法设计与可视化实现
  • 双工位气密检测方案,破解超声波焊接塑胶件节拍瓶颈
  • 如何实现千牛自动提报活动自动化?Canvas+WebGL+AudioContext全维度指纹隔离
  • 吃透Matlab神经网络:43个案例教你避开训练与数据预处理的坑
  • 足球赛事预测算法建模实战:从特征工程到概率输出的完整流程
  • 从ROS到任务调度:构建人形机器人服务系统的软件架构与实战
  • 嵌入式软件测试(二十九)——低开销性能分析
  • 电商项目中URule规则引擎的完整实战指南
  • 液冷铜管焊接砂孔缺陷检漏:双通道检漏仪与自动化产线方案
  • 出游Vlog全流程制作:AI辅助从拍摄到分发,以Niagara Falls周边为例
  • Unity流体模拟实战:Obi Fluid插件源码分析与调参指南
  • Linux常用命令实战指南:从系统基础到服务部署与排查