Spring Boot + Thymeleaf + PageHelper 服务端分页实战

发布时间:2026/10/31 7:32:09
Spring Boot + Thymeleaf + PageHelper 服务端分页实战

1. 这套分页方案到底解决了什么问题

做后台管理系统的人大概都有过这种体验:列表页数据一多,要么一次性全查出来卡到浏览器假死,要么自己手写分页逻辑写到怀疑人生——页码计算、总条数、上一页下一页的边界判断,稍不留神就出 bug。我这次接手的项目就是一个典型的中后台数据列表场景,需求很朴素:表格展示、分页翻页、每页条数可调,前端不搞前后端分离那一套,直接用服务端渲染把页面吐出来。

技术选型上,后端是 Spring Boot,模板引擎用 Thymeleaf,分页工具用 PageHelper 配合它的 PageInfo 封装。这套组合在服务端渲染的项目里算是相当成熟的搭配,尤其适合那种不需要单独维护一套前端工程、页面逻辑相对固定的管理系统。标题里说的“前端引入式分页”,指的是分页条不是靠前端框架动态渲染,而是由后端把分页数据塞进模型,Thymeleaf 模板里直接遍历生成页码链接,用户点一下就走一次完整的请求-响应循环。

这种方案能干什么?一句话概括:用最少的代码量,把“查一页数据 + 渲染分页条 + 处理翻页参数”这条链路跑通,并且页码、总数、边界状态全部由后端算好,前端只负责展示。它解决的问题很具体——避免全量查询带来的内存和带宽浪费,避免手写分页算法引入的边界错误,同时让前后端职责清晰:后端管数据切片,模板管展示。

适合谁来参考?我觉得三类人最对口。第一类是刚接触 Spring Boot 服务端渲染、还在用List一把梭查全表的新手,这套方案能让你快速建立分页的正确认知;第二类是在维护老式 JSP/Thymeleaf 项目、需要给现有列表页补分页功能的中级开发者;第三类是想搞清楚 PageHelper 和 PageInfo 到底怎么配合、参数怎么传、坑在哪里的同学。哪怕你后面要转前后端分离,这套服务端分页的思维模型也是通用的,只是把渲染环节换成接口返回 JSON 而已。

我先把整体链路在脑子里过一遍,方便后面拆细节:用户访问列表页,Controller 接收页码和每页条数两个参数,调用 Service 层查询,Service 里在真正执行 SQL 之前调用PageHelper.startPage(pageNum, pageSize),紧接着执行那条普通的查询语句,PageHelper 通过拦截器自动改写 SQL 加上LIMIT,查完之后把结果包装成PageInfo,Controller 把PageInfo放进 Model,Thymeleaf 模板遍历pageInfo.list渲染表格行,再用pageInfo的页码属性渲染分页条。整条链路的关键在于startPage 必须紧挨着查询语句,这个顺序错了分页就失效,后面会重点讲。

2. 整体设计与技术选型的取舍逻辑

2.1 为什么是服务端分页而不是前端分页

分页这件事,本质上是在“数据切片发生在哪一层”上做选择。前端分页是把全量数据一次性给浏览器,由 JavaScript 在本地做切片展示;服务端分页是每次只取当前页需要的那一小段。两者的分水岭在于数据量级和实时性要求。

我见过不少项目在数据量只有几百条的时候用前端分页,确实简单,一次查询全给前端,翻页零延迟。但一旦数据涨到几万条,全量查询的代价就藏不住了:数据库要扫全表、网络要传全量、浏览器内存要扛住整个数组,首屏加载时间肉眼可见地变长。更麻烦的是,如果列表还带筛选条件,前端分页的“总数”和“筛选结果”容易对不上,逻辑会变得很绕。

服务端分页的代价是每次翻页都要走一次请求,但换来的是内存占用恒定、首屏只传一页数据、总数由数据库精确统计。对于中后台这种“数据会持续增长、但用户每次只看一屏”的场景,服务端分页是更稳的选择。标题里“前端引入式”这个说法,我理解强调的是分页条由模板渲染、翻页靠链接跳转,而不是靠 AJAX 局部刷新,这进一步降低了前端复杂度——不需要维护一套前端状态管理,页面刷新即最新状态。

2.2 PageHelper 与 PageInfo 的分工

很多人把这两个东西混为一谈,其实职责完全不同。PageHelper 是拦截器层面的工具,它的核心动作是在 MyBatis 执行 SQL 前做一次“偷梁换柱”:检测到startPage被调用过,就把原 SQL 改写成带LIMIT offset, size的语句,同时额外发一条COUNT查询算出总条数。PageInfo 是结果封装对象,它把 PageHelper 查出来的Page对象(本质是个 List,但携带了分页元信息)转换成模板友好的属性集合,比如pageNum、pageSize、total、pages、hasPreviousPage、hasNextPage、isFirstPage、isLastPage这些。

为什么要有 PageInfo 这一层?因为 PageHelper 返回的Page对象虽然也带这些信息,但它的属性命名和布尔判断对模板不够友好,而且Page直接暴露给视图层会让模板和 MyBatis 插件产生耦合。PageInfo 做了一次干净的转换,模板里只需要认 PageInfo 的字段,将来换分页实现也不影响模板。这个分层思路值得学:数据访问层的产物不要直接扔给视图层,中间加一层视图模型转换。

2.3 Thymeleaf 在分页渲染中的角色定位

Thymeleaf 是服务端模板引擎,它的分页渲染逻辑就是“拿到 PageInfo,用th:each遍历数据行,用th:href拼页码链接”。相比 JSP,Thymeleaf 的表达式更简洁,th:if做边界判断、th:classappend做当前页高亮都很顺手。相比前端框架,它不需要额外的构建工具和运行时,模板文件改完刷新即生效,调试成本低。

这里有个设计取舍要说明:分页条的页码链接是“全量渲染”还是“窗口渲染”。全量渲染就是把 1 到总页数全部生成链接,简单但页数多了会撑爆页面;窗口渲染是只显示当前页附近的几个页码,加省略号。我在这个项目里选了窗口渲染,因为后台数据动辄几十上百页,全量渲染的 DOM 节点太多,而且用户体验也不好。窗口逻辑放在模板里用 Thymeleaf 的算术表达式实现,具体写法后面实操部分给。

3. 核心细节解析与实操要点

3.1 startPage 的调用时机是生死线

这是 PageHelper 最容易踩的坑,没有之一。PageHelper.startPage(pageNum, pageSize)的本质是往当前线程的ThreadLocal里塞一个分页参数,然后 MyBatis 的拦截器在下一条 SQL 执行时读取这个参数并改写 SQL。这意味着两件事:第一,startPage 之后必须紧跟真正要分页的那条查询,中间不能插入别的查询;第二,分页参数是线程绑定的,异步或多线程场景要特别小心。

我见过最典型的错误写法是这样的:先调startPage,然后做了一堆参数校验、调了另一个 Service 查了个字典表,最后才执行列表查询。结果字典表那条 SQL 被分页了,列表查询反而没分页,数据全量返回。排查的时候看日志会发现 COUNT 语句查的是字典表,非常迷惑。正确做法是把startPage放在紧贴查询语句的前一行,中间不夹任何数据库操作。

注意:如果确实需要在分页查询前做校验或查辅助数据,把这些操作全部挪到startPage之前完成,或者干脆在 Controller 层做完再进 Service。

3.2 页码与条数的参数校验不能省

前端传来的pageNum和pageSize是用户可控的,直接透传给 PageHelper 有风险。pageNum传 0 或负数,PageHelper 会做一定兜底,但不同版本行为不完全一致;pageSize传个 100000,等于变相全量查询,把分页的意义抵消了。所以参数进来第一件事是校验和归一化。

我的处理习惯是:pageNum小于 1 时归一到 1;pageSize设一个白名单,比如只允许 10、20、50、100 这几个值,传了别的就回落到默认 10。这样既防了恶意大值,也让前端下拉框的选择和后端逻辑对齐。另外pageSize的上限一定要卡死,我一般设 100,超过就截断。这个上限不是拍脑袋,是结合单页表格的可读性和单次查询的响应时间定的——一页超过 100 行,用户滚动和阅读都累,数据库单次返回的字段量也上去了。

3.3 PageInfo 的常用属性要烂熟于心

模板里渲染分页条,靠的全是 PageInfo 的属性。我把最常用的几个列出来,这些属性名在 Thymeleaf 表达式里直接可用:

属性名含义典型用途
pageNum当前页码高亮当前页、拼链接
pageSize每页条数条数下拉框回显
total总记录数显示“共 X 条”
pages总页数计算页码窗口范围
hasPreviousPage是否有上一页上一页按钮禁用判断
hasNextPage是否有下一页下一页按钮禁用判断
isFirstPage是否首页首页按钮禁用判断
isLastPage是否末页末页按钮禁用判断
navigatePages导航页码数窗口大小,默认 8
navigatepageNums导航页码数组遍历生成页码链接

navigatepageNums这个属性特别值得说,它是 PageInfo 帮你算好的“当前页附近的页码数组”,配合navigatePages使用,直接遍历就能生成窗口化的页码,省得自己在模板里算偏移量。不过它的窗口逻辑是固定以当前页为中心取前后各若干页,首尾页的衔接处理不如手写灵活,所以我在项目里还是手写了窗口计算,navigatepageNums作为备选方案保留。

3.4 查询语句本身要保持“干净”

PageHelper 改写 SQL 的前提是它能识别出你的查询语句。这里有个隐含要求:被分页的查询最好是一条简单的单表或常规联表查询,不要在里面嵌套复杂的子查询、UNION 或者存储过程调用。PageHelper 的 COUNT 查询是把原 SQL 包一层SELECT COUNT(*) FROM (原SQL)来算总数的,如果原 SQL 里有ORDER BY,某些版本会自动去掉排序以提升 COUNT 性能,但如果 SQL 结构太复杂,COUNT 的准确性和性能都可能出问题。

我的经验是:分页查询尽量走“主表 + 必要关联”的简单结构,复杂统计逻辑单独用一条查询算好再合并,不要硬塞进分页 SQL 里。另外查询语句里不要写LIMIT,PageHelper 会自己加,你手写了会冲突。还有ORDER BY要显式写上,否则分页结果的顺序不稳定,翻页时可能出现同一条数据在两页都出现或都不出现的情况——这不是 PageHelper 的 bug,是数据库在没有明确排序时返回顺序不确定导致的。

4. 实操过程与核心环节实现

4.1 依赖引入与基础配置

项目是 Maven 构建,分页相关的依赖主要是 PageHelper 的 Spring Boot Starter。这里要注意版本匹配,Starter 的版本要和 Spring Boot 大版本对齐,否则可能出现自动配置不生效的问题。我用的组合是 Spring Boot 2.x 配 PageHelper Starter 1.4.x,这个搭配比较稳。

<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.6</version> </dependency>

配置文件里把分页的默认参数设好,这样即使某处忘了传参也有兜底:

pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: count=countSql

helper-dialect指定数据库方言,PageHelper 靠它生成对应数据库的 LIMIT 语法;reasonable设为 true 是个很实用的开关,它会在页码超出范围时自动纠正——比如总共 5 页,用户传了第 10 页,它会返回最后一页而不是空列表,传了第 0 页会返回第一页。这个“合理化”行为对用户体验友好,但要注意它和参数校验是互补关系,不能因为开了它就不做前端参数校验。

4.2 Controller 层的参数接收与模型装配

Controller 的职责很清晰:接参数、做归一化、调 Service、装模型、返回视图名。参数用@RequestParam接收并给默认值,这样用户直接访问列表页(不带任何参数)也能正常显示第一页。

@GetMapping("/list") public String list(@RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, @RequestParam(required = false) String keyword, Model model) { pageNum = pageNum < 1 ? 1 : pageNum; pageSize = normalizePageSize(pageSize); PageInfo<ItemVO> pageInfo = itemService.queryByPage(pageNum, pageSize, keyword); model.addAttribute("pageInfo", pageInfo); model.addAttribute("keyword", keyword); model.addAttribute("pageSizeOptions", Arrays.asList(10, 20, 50, 100)); return "item/list"; }

这里有个细节:keyword这个筛选条件要原样回传到模型里,因为分页条的每个链接都要带上它,否则翻页之后筛选条件就丢了。这是服务端渲染分页的一个常见疏漏——翻页链接只拼了页码,忘了拼其他查询参数,导致用户翻到第二页发现筛选失效。我的做法是把所有查询条件统一放进模型,模板里拼链接时全部带上。

normalizePageSize方法就是前面说的白名单校验:

private Integer normalizePageSize(Integer pageSize) { List<Integer> allowed = Arrays.asList(10, 20, 50, 100); return allowed.contains(pageSize) ? pageSize : 10; }

4.3 Service 层的分页查询写法

Service 层是分页逻辑的核心,startPage和查询语句的紧邻关系在这里体现。写法上我习惯把分页查询单独成一个方法,方法内第一行就是startPage,紧接着就是 Mapper 调用,中间不夹任何逻辑。

public PageInfo<ItemVO> queryByPage(Integer pageNum, Integer pageSize, String keyword) { PageHelper.startPage(pageNum, pageSize); List<ItemVO> list = itemMapper.selectByCondition(keyword); return new PageInfo<>(list); }

new PageInfo<>(list)这一步是关键转换。传进去的list实际上是 PageHelper 返回的Page对象(它实现了 List 接口),PageInfo 的构造函数会识别出这个类型并从中提取分页元信息。如果你把list先做了stream().map()之类的转换再传给 PageInfo,元信息就丢了,total会变成当前页的条数,分页条直接错乱。这个坑我踩过,当时对列表做了个字段映射,结果总数永远等于每页条数,排查了半天才发现是转换破坏了 Page 类型。

注意:如果确实需要对结果做转换,正确做法是先new PageInfo<>(原始list)拿到分页信息,再对pageInfo.getList()做转换并 set 回去,而不是在构造 PageInfo 之前转换。

4.4 Thymeleaf 模板的数据行渲染

模板部分分两块:数据表格和分页条。数据行渲染比较直接,th:each遍历pageInfo.list,每行取字段。这里要注意空列表的处理,pageInfo.list为空时给个“暂无数据”的提示行,避免表格空荡荡。

<table> <thead> <tr><th>编号</th><th>名称</th><th>状态</th><th>创建时间</th></tr> </thead> <tbody> <tr th:each="item : ${pageInfo.list}"> <td th:text="${item.id}"></td> <td th:text="${item.name}"></td> <td th:text="${item.statusText}"></td> <td th:text="${#dates.format(item.createTime, 'yyyy-MM-dd HH:mm')}"></td> </tr> <tr th:if="${#lists.isEmpty(pageInfo.list)}"> <td colspan="4">暂无数据</td> </tr> </tbody> </table>

#lists.isEmpty是 Thymeleaf 内置的工具方法,比写pageInfo.list.size() == 0更语义化。日期格式化用#dates.format,注意createTime得是java.util.Date类型,如果是LocalDateTime要用#temporals.format,这两个工具类别搞混,混用会报模板解析异常。

4.5 分页条的窗口化渲染实现

分页条是这套方案里最需要动脑的部分。我采用窗口化渲染,规则是:始终显示首页和末页,当前页前后各显示 2 页,中间用省略号连接。这样无论总页数多少,分页条的宽度都稳定在 7 到 9 个元素左右。

先在 Controller 或模板里算出窗口的起止页码。我倾向在模板里用 Thymeleaf 表达式算,减少后端负担:

<div class="pagination" th:if="${pageInfo.pages > 0}"> <span th:text="'共 ' + ${pageInfo.total} + ' 条,第 ' + ${pageInfo.pageNum} + '/' + ${pageInfo.pages} + ' 页'"></span> <a th:if="${pageInfo.hasPreviousPage}" th:href="@{/item/list(pageNum=${pageInfo.pageNum - 1}, pageSize=${pageInfo.pageSize}, keyword=${keyword})}">上一页</a> <span th:unless="${pageInfo.hasPreviousPage}" class="disabled">上一页</span> <a th:if="${pageInfo.pageNum > 3}" th:href="@{/item/list(pageNum=1, pageSize=${pageInfo.pageSize}, keyword=${keyword})}">1</a> <span th:if="${pageInfo.pageNum > 4}">...</span> <a th:each="p : ${#numbers.sequence( T(java.lang.Math).max(1, pageInfo.pageNum - 2), T(java.lang.Math).min(pageInfo.pages, pageInfo.pageNum + 2))}" th:href="@{/item/list(pageNum=${p}, pageSize=${pageInfo.pageSize}, keyword=${keyword})}" th:text="${p}" th:classappend="${p == pageInfo.pageNum} ? 'active' : ''"></a> <span th:if="${pageInfo.pageNum < pageInfo.pages - 3}">...</span> <a th:if="${pageInfo.pageNum < pageInfo.pages - 2}" th:href="@{/item/list(pageNum=${pageInfo.pages}, pageSize=${pageInfo.pageSize}, keyword=${keyword})}" th:text="${pageInfo.pages}"></a> <a th:if="${pageInfo.hasNextPage}" th:href="@{/item/list(pageNum=${pageInfo.pageNum + 1}, pageSize=${pageInfo.pageSize}, keyword=${keyword})}">下一页</a> <span th:unless="${pageInfo.hasNextPage}" class="disabled">下一页</span> </div>

这段模板有几个技术点值得拆开说。@{...}是 Thymeleaf 的链接表达式,括号里是查询参数,它会自动做 URL 编码,keyword里有中文或特殊字符也不会出问题。#numbers.sequence(start, end)生成一个整数序列,配合T(java.lang.Math).max/min把窗口范围夹在 1 到总页数之间,避免出现 0 或超出总页数的页码。th:classappend做当前页高亮,表达式返回布尔值,true 时追加active类。

省略号的显示条件要仔细推敲:当前页大于 3 时,前面才需要显示首页和省略号;当前页小于总页数减 2 时,后面才需要省略号和末页。这些阈值和窗口大小(前后各 2 页)是联动的,改窗口大小就要同步改阈值,否则会出现“省略号后面紧跟一个和窗口重复的页码”这种尴尬情况。

4.6 每页条数切换与筛选条件保持

每页条数切换用下拉框,选中后触发页面跳转。这里有个体验细节:切换条数时应该回到第一页,因为原来的页码在新条数下可能已经超出范围。实现上用onchange拼 URL 跳转,把pageNum固定为 1。

<select onchange="location.href='/item/list?pageNum=1&pageSize=' + this.value + '&keyword=' + encodeURIComponent('[[${keyword}]]')"> <option th:each="size : ${pageSizeOptions}" th:value="${size}" th:text="${size + ' 条/页'}" th:selected="${size == pageInfo.pageSize}"></option> </select>

[[${keyword}]]是 Thymeleaf 的内联表达式,在 JavaScript 里取值要用这种写法,直接写${keyword}在 script 或事件属性里不会被解析。encodeURIComponent不能省,否则 keyword 里有&或空格会把 URL 参数截断。这个下拉框的写法虽然用了内联 JS,但整体还是服务端渲染的思路,没有引入前端框架。

5. 常见问题与排查技巧实录

5.1 分页失效、数据全量返回的排查路径

分页失效是最高频的问题,表现是页面显示所有数据、分页条显示总页数为 1。排查按这个顺序走:第一,确认startPage是否真的被调用了,打断点或加日志看;第二,确认startPage和查询语句之间有没有夹别的数据库操作,有的话挪走;第三,确认查询返回的list是不是被转换过再传给 PageInfo,转换过就按前面说的方法调整;第四,确认 PageHelper 的依赖和配置是否生效,看启动日志里有没有 PageHelper 的初始化信息。

还有一种隐蔽情况:项目里如果同时引入了多个 MyBatis 插件(比如某些数据权限插件),插件的执行顺序可能影响 PageHelper 的拦截。这种情况看 SQL 日志最直接——正常分页时日志里应该能看到一条 COUNT 语句和一条带 LIMIT 的查询语句,如果只有查询没有 COUNT,说明拦截器没生效。

5.2 总数不对、翻页出现重复或遗漏数据

总数不对通常和 COUNT 查询有关。如果查询语句里有GROUP BY,PageHelper 的 COUNT 会把分组后的结果再包一层统计,某些版本下算出来的总数是分组数而不是记录数,需要手动指定 count 语句。配置里的params: count=countSql就是为这种情况准备的,可以在 Mapper 里单独写一个 count 方法,通过countSql参数指定。

翻页重复或遗漏,十有八九是排序不稳定。数据库在没有明确ORDER BY时,两次查询的返回顺序可能不同,导致同一条记录在翻页时漂移。解决办法很简单:查询语句里加上确定性的排序字段,最好用主键或唯一索引字段兜底,比如ORDER BY create_time DESC, id DESC。这个细节在数据量小的时候不容易暴露,数据一多就原形毕露。

5.3 页码越界与空页处理

用户手动改 URL 传个超大页码,或者筛选后结果变少导致当前页超出范围,都会出现空页。开了reasonable: true之后 PageHelper 会自动纠正到最后一页,但模板上的分页条高亮和页码显示要跟着对。我的做法是在 Controller 里拿到 PageInfo 后判断一下,如果pageInfo.getList()为空且pageInfo.getPageNum() > 1,重定向到第一页或最后一页,避免用户看到空白页。

还有一种情况是筛选条件变化后页码没重置。比如用户在第 5 页,然后输入关键词搜索,结果只有 2 页,这时应该回到第 1 页。处理方式是在搜索表单提交时把pageNum固定为 1,和条数切换的逻辑一致。

5.4 常见问题速查表

现象可能原因解决方向
数据全量返回,总页数为 1startPage 未生效或被其他查询消耗检查调用顺序,确认紧邻查询
总数等于每页条数list 被转换后传给 PageInfo先构造 PageInfo 再转换 list
翻页数据重复或遗漏查询无确定性排序加 ORDER BY 主键兜底
页码超出范围显示空白未开 reasonable 或未做重定向开配置项 + Controller 兜底
筛选后翻页条件丢失分页链接未带筛选参数模板链接补全所有查询参数
COUNT 语句报错原 SQL 含 GROUP BY 或复杂子查询单独指定 countSql
条数切换后页码错乱切换时未重置 pageNum切换链接固定 pageNum=1

5.5 几个踩过坑才明白的实操心得

第一个心得:分页查询的 Service 方法不要做事务之外的副作用操作。我见过有人在分页方法里顺手更新了个访问计数,结果那条 UPDATE 被 PageHelper 的拦截器误判,虽然一般不会,但把写操作和分页查询混在一个方法里,排查问题时干扰太大。分页方法就老老实实只读。

第二个心得:PageInfo 的list属性在模板里遍历时,不要在循环里再发起数据库查询。这是 N+1 问题的变种,模板里一个th:each里调 Service 方法查关联数据,一页 20 条就是 20 次查询,页面响应直接翻倍。关联数据要么在分页查询时用联表一次带出来,要么单独批量查好放进模型。

第三个心得:分页条的链接用@{...}表达式而不是手拼字符串。手拼字符串在参数有中文、空格、特殊符号时容易出编码问题,@{...}会自动处理 URL 编码,省心很多。我早期手拼过一次,keyword 里带了个&,翻页后筛选条件直接被截断,用户反馈“搜索着搜索着条件就没了”,查了半天才发现是 URL 拼接的锅。

第四个心得:开发阶段把 SQL 日志打开。PageHelper 是否生效、COUNT 语句长什么样、LIMIT 参数对不对,看日志一目了然。配置里把 MyBatis 的日志级别调到 DEBUG,配合 PageHelper 的日志,分页链路的每一步都清清楚楚。上线前再调回 INFO,避免日志量过大。

6. 方案边界与后续可扩展方向

这套“Spring Boot + Thymeleaf + PageHelper + PageInfo”的组合,优势在于轻量和直接,但它有明确的适用边界。数据量在百万级以内、查询条件不算特别复杂、页面以服务端渲染为主的中后台,用起来非常顺手。但如果列表需要支持多列排序、列筛选、行内编辑这些交互密集的功能,服务端渲染的每次翻页都走完整请求,体验上会不如前后端分离的方案。

我在实际项目里给它留了扩展口子:Service 层的分页方法签名保持稳定,返回 PageInfo,将来如果要转成接口返回 JSON,Controller 换个返回类型就行,Service 和 Mapper 完全不用动。模板里的分页条逻辑也可以抽成一个 Thymeleaf fragment,多个列表页复用同一套分页 UI,改一处全局生效。fragment 的传参用th:fragment和th:replace,把 PageInfo 和基础 URL 作为参数传进去,这样新增列表页时只需要写数据表格部分,分页条直接引用。

另外一个小优化方向是给分页链接加上“跳转到指定页”的输入框,配合前面的窗口化渲染,用户在大页数场景下能快速定位。实现上就是一个输入框加按钮,点击时拼 URL 跳转,页码做一下范围校验即可。这个功能不复杂,但对几十页以上的列表体验提升明显,值得加上。

最后分享一个我在调试分页时常用的小技巧:在模板的分页条旁边临时加一行调试信息,把pageInfo的关键属性都打出来——pageNum、pageSize、total、pages、hasPreviousPage、hasNextPage。翻页时盯着这几个值看,哪里不对一眼就能定位。调试完把这行删掉或者用th:if="${false}"关掉,不影响正式页面。这个习惯帮我省了大量“猜哪里错了”的时间,比反复看代码高效得多。