SpringMVC+MyBatis+EasyUI分页实战:PageHelper集成与四层联动避坑指南 简介这是一份面向Java Web开发初学者与SSM框架实践者的完整分页功能集成Demo聚焦Spring、SpringMVC、MyBatis与EasyUI四大技术栈的协同落地解决Web应用中海量数据高效展示与交互的核心问题。资源包共122个文件涵盖12个Java源码如UserController、UserServiceImpl、12个Class字节码、12个XML配置/映射文件含Spring容器与MyBatis SQL映射、8个SQL建表与初始化脚本、5个JS前端逻辑支撑EasyUI分页交互、2个JSP视图页面及配套CSS、GIF/PNG界面素材等结构清晰体现典型SSMEasyUI项目分层设计压缩包仅1.68MB轻量易导入。已有266人学习下载读者可直接获取可运行的Maven工程、分页核心工具类PageUtil、PageInterceptor、前后端联调示例及基础说明文档快速掌握从后端分页查询、MyBatis拦截器实现、SpringMVC数据封装到EasyUI DataGrid动态加载的全链路实现逻辑。1. Spring SpringMVC MyBatis EasyUI 分页不是拼凑四件套而是搭一条能扛住千条数据请求的流水线你有没有遇到过这样的现场前端 EasyUI 的 datagrid 显示空表格F12 看 Network 里请求发出去了、后端 Controller 也进了断点、Service 层查库日志也打了但返回 JSON 里rows: []、total: 0—— 数据明明在库里就是不露脸这不是玄学是分页链路上至少 5 个环节没对齐MyBatis 的RowBounds和PageHelper冲突、SpringMVC 的ResponseBody序列化把PageInfo对象转成空对象、EasyUI 的pageNumber/pageSize参数名被 Spring 默认忽略、Controller 返回值类型写成ListT却没手动封装total字段……这套组合拳不是“能跑就行”的玩具堆叠而是面向真实业务系统比如某高校教务后台、某公司内部审批流必须稳定输出带总数分页数据的生产级方案。它适合正在维护或重构老项目、需要快速交付带分页管理界面、又不能直接上 Spring Boot Vue 的工程师——尤其当你手头只有 JDK 1.8、Tomcat 7、jQuery 1.12 这些“老伙计”时这套技术栈反而是最省心的选择。2. 四层联动分页机制为什么必须用 PageHelper 而不是手写 RowBounds2.1 分页的本质矛盾数据库物理分页 vs 前端逻辑分页EasyUI 的 datagrid 要求接口返回固定结构{ total: 1234, rows: [ { id: 1, name: 张三 }, ... ] }而 MySQL 的LIMIT offset, size只能取数据块不返回总数Oracle 的ROWNUM更难兼顾总数与分页。若在 Service 层先SELECT COUNT(*)再SELECT * LIMIT ...看似合理实则埋下双查询性能雷当COUNT(*)扫全表、SELECT *又走索引时执行计划可能分裂且并发高时 COUNT 结果和实际数据行数可能错位。PageHelper 的解法是侵入 SQL 执行前一刻它通过 MyBatis 的Interceptor拦截Executor.query()自动将原始 SQL 改写为两条如SELECT COUNT(*) FROM (...)SELECT * FROM (...) LIMIT ...并复用同一事务上下文保证总数与数据强一致。这不是语法糖是 MyBatis 生态里少有的、真正解决“分页原子性”的方案。2.2 集成 PageHelper 的三步落地非 starter 版本注意本方案基于 MyBatis 3.4.x PageHelper 5.1.10兼容 JDK 1.8不依赖 Spring Boot Starter直击传统 XML 配置项目痛点。步骤 1添加 Maven 依赖pom.xml!-- MyBatis 核心 -- dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.4.6/version /dependency !-- PageHelper 分页插件 -- dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper/artifactId version5.1.10/version /dependency步骤 2配置 MyBatis 插件mybatis-config.xmlplugins !-- PageHelper 分页插件 -- plugin interceptorcom.github.pagehelper.PageInterceptor !-- 设置数据库方言必须显式指定 -- property namedialect valuemysql/ !-- 启用合理化分页参数校验 -- property namereasonable valuetrue/ !-- 支持通过 Mapper 接口参数传递分页参数 -- property namesupportMethodsArguments valuetrue/ !-- 开启 count 查询优化自动去除 ORDER BY -- property nameautoRuntimeDialect valuefalse/ /plugin /plugins参数说明reasonabletrue是关键——当pageNum1且pageSize10时若总页数仅 1 页PageHelper 会自动将pageNum修正为 1避免传入pageNum100导致空数据supportMethodsArgumentstrue允许在 Mapper 方法签名中直接加RowBounds或PageRowBounds参数比 XML 里写select内嵌limit更灵活。步骤 3Mapper 接口与 XML 写法以用户列表为例// UserMapper.java public interface UserMapper { // 方式一使用 Param 注解传参推荐 ListUser selectUserList(Param(username) String username, Param(status) Integer status); // 方式二使用 RowBounds兼容旧代码 ListUser selectUserListWithBounds(String username, Integer status, RowBounds rowBounds); }!-- UserMapper.xml -- select idselectUserList resultTypeUser SELECT id, username, email, status, create_time FROM sys_user WHERE 11 if testusername ! null and username ! AND username LIKE CONCAT(%, #{username}, %) /if if teststatus ! null AND status #{status} /if ORDER BY create_time DESC /select关键逻辑XML 中不要写LIMIT不要写#{pageNo}PageHelper 会在执行时自动注入LIMIT子句。你只管写“查全部”的 SQL分页由插件动态增强——这是与手写RowBounds的根本区别RowBounds是内存分页查全表再截取PageHelper 是数据库分页SQL 层改写。3. SpringMVC 层Controller 如何把 PageInfo 转成 EasyUI 要的 JSON 结构3.1 为什么不能直接return pageInfoPageInfo 是 PageHelper 封装的分页元数据对象含list,pageNum,pageSize,pages,total等字段。但ResponseBody默认用 Jackson 序列化时若未配置JsonInclude(JsonInclude.Include.NON_NULL)list为空时整个对象可能序列化失败更致命的是EasyUI 只认rows和total字段其他字段如pageNum会被前端忽略。直接返回 PageInfo 前端收不到数据。3.2 统一分页响应包装类避免每个 Controller 都写 Map// PageResult.java public class PageResultT { private long total; // EasyUI 要的总数 private ListT rows; // EasyUI 要的数据列表 public PageResult(long total, ListT rows) { this.total total; this.rows rows; } // getter/setter 省略 }3.3 Controller 标准写法含异常兜底Controller RequestMapping(/user) public class UserController { Autowired private UserService userService; /** * EasyUI datagrid 分页接口 * param page 当前页码EasyUI 默认传 pageNumber * param rows 每页条数EasyUI 默认传 pageSize * param username 搜索条件 * param status 状态筛选 */ RequestMapping(value /list, method RequestMethod.POST) ResponseBody public PageResultUser list( RequestParam(defaultValue 1) int page, RequestParam(defaultValue 10) int rows, String username, Integer status) { try { // 1. 使用 PageHelper.startPage 开启分页必须在查询前调用 PageHelper.startPage(page, rows); // 2. 执行查询此时 PageHelper 已拦截 SQL ListUser userList userService.selectUserList(username, status); // 3. 获取 PageInfo 并提取 total rows PageInfoUser pageInfo new PageInfo(userList); // 4. 封装为 EasyUI 兼容格式 return new PageResult(pageInfo.getTotal(), pageInfo.getList()); } catch (Exception e) { // 日志记录 返回空结果避免前端报错白屏 log.error(用户分页查询异常, e); return new PageResult(0L, Collections.emptyList()); } } }参数说明RequestParam(defaultValue 1)是安全底线——当 EasyUI 未传page参数时不会因null报 400 错误PageHelper.startPage(page, rows)必须在userService.selectUserList(...)之前调用否则分页失效new PageInfo(userList)是触发 COUNT 查询的关键它会立即执行SELECT COUNT(*)并缓存结果。4. EasyUI 前端datagrid 配置与参数映射的四个硬性约定4.1 datagrid 初始化必须声明的属性$(#userGrid).datagrid({ url: /user/list, // 后端分页接口地址 method: post, // 必须 POSTGET 有长度限制 fit: true, // 自适应父容器 pagination: true, // 启用分页栏 pageNumber: 1, // 初始页码 pageSize: 10, // 初始每页条数 pageList: [10, 20, 50, 100], // 可选页大小 singleSelect: true, // 单选模式避免多选干扰分页 remoteSort: false, // 关闭远程排序排序逻辑在后端做 // EasyUI 与后端参数映射核心 queryParams: { // 这里定义的 key 会作为 URL 参数发送 // 必须与 Controller RequestParam 名称一致 page: 1, rows: 10 }, // 定义列示例 columns: [[ {field: id, title: ID, width: 80}, {field: username, title: 用户名, width: 120}, {field: email, title: 邮箱, width: 180}, {field: status, title: 状态, width: 100, formatter: statusFormatter} ]] });关键逻辑queryParams中的page和rows是 EasyUI 的默认参数名但 SpringMVC 默认只识别page和rows而非pageNumber/pageSize。若后端 Controller 用RequestParam(pageNumber)则此处必须写pageNumber: 1否则参数传不过去——这是前后端联调第一大坑。4.2 自定义参数传递搜索条件同步分页// 搜索按钮点击事件 $(#searchBtn).click(function() { var username $(#usernameInput).val(); var status $(#statusSelect).combobox(getValue); // 重载 datagrid自动携带搜索参数 $(#userGrid).datagrid(load, { username: username, status: status }); });原理datagrid(load, {...})会合并queryParams与传入对象最终请求 URL 为/user/list?page1rows10username张status1。Controller 的RequestParam String username能直接接住无需额外解析。4.3 处理后端返回空数据的容错// 在 datagrid 配置中添加 onLoadError 回调 onLoadError: function(data) { $.messager.alert(错误, 数据加载失败请检查网络或联系管理员, error); // 清空表格避免残留旧数据 $(this).datagrid(loadData, {total: 0, rows: []}); }, onLoadSuccess: function(data) { // data 是后端返回的 JSON确保有 total 和 rows 字段 if (!data || typeof data.total undefined || typeof data.rows undefined) { $.messager.alert(警告, 接口返回格式错误请检查后端实现, warning); $(this).datagrid(loadData, {total: 0, rows: []}); } }血泪经验某次上线后发现分页栏显示“第 1 页共 0 页”排查发现是后端PageResult构造时total传了null因pageInfo.getTotal()返回long但null被序列化为0前端误判为无数据。从此我强制在PageResult构造器里加Objects.requireNonNull(total, total cannot be null)。5. 避坑指南五条让老项目分页从“能跑”到“稳跑”的实战教训5.1 现象分页栏显示“第 1 页共 1 页”但实际数据超过 10 条原因PageHelper 的dialect配置错误。例如 MySQL 项目配了dialectoracle插件生成的 COUNT SQL 变成SELECT COUNT(*) FROM (SELECT * FROM table) tOracle 写法MySQL 解析失败COUNT 返回 0导致total0分页栏只剩 1 页。解决检查mybatis-config.xml中dialect值是否与真实数据库匹配若用 Druid 多数据源需为每个 SqlSessionFactory 单独配置 PageInterceptor。5.2 现象修改 pageSize 后第一页数据正常第二页开始重复显示第一页数据原因PageHelper.startPage()未在每次请求开始时重置。常见于 Service 层方法被多次调用或在循环中误用startPage如for(int i0;i10;i) { PageHelper.startPage(i,10); ... }导致分页上下文污染。解决startPage必须在 Controller 层调用且每个 HTTP 请求只调用一次若 Service 层需复用分页逻辑改用PageHelper.offsetPage(offset, size)并确保 offset 计算准确offset (page-1)*size。5.3 现象EasyUI 搜索后分页栏页码重置为 1但数据仍显示上一页内容原因前端未在datagrid(load, {...})时重置pageNumber。EasyUI 默认保持当前页码搜索后若仍在第 3 页会请求/user/list?page3rows10usernamexxx但后端按page3查数据自然不对。解决搜索时强制重置页码$(#userGrid).datagrid(load, { page: 1, // 强制第一页 rows: $(#userGrid).datagrid(options).pageSize, username: username, status: status });5.4 现象Controller 返回PageResult但浏览器 F12 看 Response 是空 JSON{}原因Jackson 序列化时PageResult的total字段为long类型若值为0L且JsonInclude(JsonInclude.Include.NON_EMPTY)生效total:0被过滤或rows为null未初始化序列化后rows字段消失。解决在PageResult类上加注解JsonInclude(JsonInclude.Include.ALWAYS) public class PageResultT { ... }并在构造器中确保rows不为nullpublic PageResult(long total, ListT rows) { this.total total; this.rows rows null ? Collections.emptyList() : rows; }5.5 现象高并发下分页总数偶尔不准如总数应为 1005返回 1000原因PageHelper 的 COUNT 查询与主查询不在同一事务隔离级别。若数据库使用READ_COMMITTEDCOUNT 执行时部分新插入数据未提交主查询时已提交导致总数少算。解决在 Service 层方法上加Transactional(isolation Isolation.REPEATABLE_READ)强制两次查询在同一快照或改用PageHelper.count()手动控制 COUNT 时机需自行处理事务传播。6. 进阶技巧用 AOP 统一拦截分页请求彻底告别重复代码6.1 为什么需要 AOP每个分页 Controller 都要写PageHelper.startPage()new PageInfo()new PageResult()不仅冗余还易出错比如漏掉try-catch。AOP 能把分页逻辑抽离为横切关注点Controller 只专注业务查询。6.2 自定义分页注解声明式分页// Pageable.java Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface Pageable { String pageParam() default page; // URL 参数名 String sizeParam() default rows; // URL 参数名 String totalField() default total; // 响应体中总数字段名 String rowsField() default rows; // 响应体中数据列表字段名 }6.3 AOP 切面实现自动完成分页包装Aspect Component public class PageableAspect { Around(annotation(pageable)) public Object handlePagination(ProceedingJoinPoint joinPoint, Pageable pageable) throws Throwable { // 1. 获取请求参数中的 page 和 size Object[] args joinPoint.getArgs(); int page 1, size 10; for (Object arg : args) { if (arg instanceof HttpServletRequest) { HttpServletRequest request (HttpServletRequest) arg; page Integer.parseInt(request.getParameter(pageable.pageParam()) ! null ? request.getParameter(pageable.pageParam()) : 1); size Integer.parseInt(request.getParameter(pageable.sizeParam()) ! null ? request.getParameter(pageable.sizeParam()) : 10); break; } } // 2. 开启分页 PageHelper.startPage(page, size); // 3. 执行原方法获取查询结果 Object result joinPoint.proceed(); // 4. 包装为分页响应支持 List 或 PageInfo 返回值 if (result instanceof List) { List? list (List?) result; PageInfo? pageInfo new PageInfo(list); // 构造 Map 模拟 PageResult避免强依赖 PageResult 类 MapString, Object response new HashMap(); response.put(pageable.totalField(), pageInfo.getTotal()); response.put(pageable.rowsField(), pageInfo.getList()); return response; } return result; // 非 List 类型不处理 } }6.4 Controller 使用注解零侵入改造Controller RequestMapping(/user) public class UserController { Autowired private UserService userService; RequestMapping(value /list, method RequestMethod.POST) ResponseBody Pageable // 启用 AOP 分页增强 public ListUser list(HttpServletRequest request, String username, Integer status) { // 业务代码极度干净只管查数据 return userService.selectUserList(username, status); } }效果对比改造前 Controller 12 行分页代码 → 改造后仅 1 行Pageable注解 业务查询后续新增分页接口只需加注解无需复制粘贴。边界说明此 AOP 仅处理ListT返回值若需支持Map或自定义对象可扩展handlePagination方法用instanceof判断返回类型后分支处理。从那以后我每次接手老项目分页需求都强制走一遍「PageHelper 配置校验 → Controller 注解化 → EasyUI 参数映射复查」三步流程再启动服务看第一条数据是否如期出现。少一次复查就多一次凌晨三点爬起来改dialect的经历。希望帮到你。本文还有配套的精品资源点击获取