简介:这是一份面向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界面素材等,结构清晰体现典型SSM+EasyUI项目分层设计,压缩包仅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 返回值类型写成List<T>却没手动封装total字段……这套组合拳不是“能跑就行”的玩具堆叠,而是面向真实业务系统(比如某高校教务后台、某公司内部审批流)必须稳定输出带总数+分页数据的生产级方案。它适合正在维护或重构老项目、需要快速交付带分页管理界面、又不能直接上 Spring Boot + Vue 的工程师——尤其当你手头只有 JDK 1.8、Tomcat 7、jQuery 1.12 这些“老伙计”时,这套技术栈反而是最省心的选择。
2. 四层联动分页机制:为什么必须用 PageHelper 而不是手写 RowBounds?
2.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> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.4.6</version> </dependency> <!-- PageHelper 分页插件 --> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper</artifactId> <version>5.1.10</version> </dependency>步骤 2:配置 MyBatis 插件(mybatis-config.xml)
<plugins> <!-- PageHelper 分页插件 --> <plugin interceptor="com.github.pagehelper.PageInterceptor"> <!-- 设置数据库方言,必须显式指定 --> <property name="dialect" value="mysql"/> <!-- 启用合理化分页参数校验 --> <property name="reasonable" value="true"/> <!-- 支持通过 Mapper 接口参数传递分页参数 --> <property name="supportMethodsArguments" value="true"/> <!-- 开启 count 查询优化(自动去除 ORDER BY) --> <property name="autoRuntimeDialect" value="false"/> </plugin> </plugins>参数说明:
reasonable=true是关键——当pageNum=1且pageSize=10时,若总页数仅 1 页,PageHelper 会自动将pageNum修正为 1(避免传入pageNum=100导致空数据);supportMethodsArguments=true允许在 Mapper 方法签名中直接加RowBounds或PageRowBounds参数,比 XML 里写<select>内嵌limit更灵活。
步骤 3:Mapper 接口与 XML 写法(以用户列表为例)
// UserMapper.java public interface UserMapper { // 方式一:使用 @Param 注解传参(推荐) List<User> selectUserList(@Param("username") String username, @Param("status") Integer status); // 方式二:使用 RowBounds(兼容旧代码) List<User> selectUserListWithBounds(String username, Integer status, RowBounds rowBounds); }<!-- UserMapper.xml --> <select id="selectUserList" resultType="User"> SELECT id, username, email, status, create_time FROM sys_user WHERE 1=1 <if test="username != null and username != ''"> AND username LIKE CONCAT('%', #{username}, '%') </if> <if test="status != 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 pageInfo?
PageInfo 是 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 PageResult<T> { private long total; // EasyUI 要的总数 private List<T> rows; // EasyUI 要的数据列表 public PageResult(long total, List<T> 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 PageResult<User> 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) List<User> userList = userService.selectUserList(username, status); // 3. 获取 PageInfo 并提取 total + rows PageInfo<User> 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', // 必须 POST(GET 有长度限制) 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?page=1&rows=10&username=张&status=1。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 项目配了dialect="oracle",插件生成的 COUNT SQL 变成SELECT COUNT(*) FROM (SELECT * FROM table) t(Oracle 写法),MySQL 解析失败,COUNT 返回 0,导致total=0,分页栏只剩 1 页。 - 解决:检查
mybatis-config.xml中dialect值是否与真实数据库匹配;若用 Druid 多数据源,需为每个 SqlSessionFactory 单独配置 PageInterceptor。
5.2 现象:修改 pageSize 后,第一页数据正常,第二页开始重复显示第一页数据
- 原因:
PageHelper.startPage()未在每次请求开始时重置。常见于 Service 层方法被多次调用,或在循环中误用startPage(如for(int i=0;i<10;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?page=3&rows=10&username=xxx,但后端按page=3查,数据自然不对。 - 解决:搜索时强制重置页码:
$('#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 PageResult<T> { ... }rows不为null:public PageResult(long total, List<T> rows) { this.total = total; this.rows = rows == null ? Collections.emptyList() : rows; }
5.5 现象:高并发下分页总数偶尔不准(如总数应为 1005,返回 1000)
- 原因:PageHelper 的 COUNT 查询与主查询不在同一事务隔离级别。若数据库使用
READ_COMMITTED,COUNT 执行时部分新插入数据未提交,主查询时已提交,导致总数少算。 - 解决:在 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 类) Map<String, 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 List<User> list(HttpServletRequest request, String username, Integer status) { // 业务代码极度干净:只管查数据 return userService.selectUserList(username, status); } }效果对比:改造前 Controller 12 行分页代码 → 改造后仅 1 行
@Pageable注解 + 业务查询;后续新增分页接口,只需加注解,无需复制粘贴。
边界说明:此 AOP 仅处理List<T>返回值,若需支持Map或自定义对象,可扩展handlePagination方法,用instanceof判断返回类型后分支处理。
从那以后我每次接手老项目分页需求,都强制走一遍「PageHelper 配置校验 → Controller 注解化 → EasyUI 参数映射复查」三步流程,再启动服务看第一条数据是否如期出现。少一次复查,就多一次凌晨三点爬起来改dialect的经历。希望帮到你。
本文还有配套的精品资源,点击获取