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 | 接收请求参数,返回 JSON | page/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=102.2 后端必须返回 total + rows
EasyUI DataGrid 对返回 JSON 的要求非常固定:必须包含total和rows两个字段。total是符合条件的总条数,rows是当前页数据数组。一个标准的返回结构是:
{ "total": 100, "rows": [ { "id": 1, "name": "张三" }, { "id": 2, "name": "李四" } ] }很多后端会把字段封装成records、list、data,或者返回PageResult里包含pageNum、pageSize,这些 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>片段的重要性就非常明显了。
不同数据库的方言也不一样。手写时至少要清楚自己的库用哪种写法:
| 数据库 | 分页写法 |
|---|---|
| MySQL | LIMIT #{offset}, #{rows} |
| PostgreSQL | LIMIT #{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 查询。正是因为这样,它有几条非常容易踩的红线:
startPage后面必须紧跟第一条 Mapper 查询,中间不能插入其他无关查询。- 同一个方法里如果有多条 select,只有紧跟
startPage后的那一条会被分页,后面的不会。 - 如果在 SpringMVC 的 Controller 里用,要确保一个请求一个线程,不能随便开异步线程去查,否则 ThreadLocal 里的分页参数可能串到别的请求。
- 稳妥做法是在 finally 里调用
PageHelper.clearPage(),防止异常场景下分页参数残留。
3.3 自定义拦截器:理解原理比拿来用更重要
有些老项目因为历史原因不想引第三方分页插件,或者需要兼容多套数据库,会自己写 MyBatis 拦截器。我也做过,但一般不建议在没有充分测试的情况下直接上生产。拦截器的思路大致是:实现org.apache.ibatis.plugin.Interceptor,拦截Executor的query方法,判断参数里是否包含分页对象;如果有,就先改写 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 配置:
- 打开浏览器开发者工具,切到 Network,重新点一次查询或翻页。
- 查看请求 URL 和 Payload,确认有没有
page和rows,确认值是否在变。 - 查看响应 JSON,确认
total是不是预期数字,rows是不是数组。 - 如果响应没问题但页面显示不对,再去看后端日志里 MyBatis 打印的 SQL 和参数。
- 检查 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 这两个字段。几个项目跑下来,这套小封装能省掉大量重复的分页联调时间。遇到分页问题也只需要盯着一个公共类排查,不用每个接口各查各的。
本文还有配套的精品资源,点击获取
