☰
SpringBoot + PageHelper + Vue 分页实战:从后端到前端的完整链路
2026/10/20 6:59:47 网站建设 项目流程

1. 为什么分页这件事值得单独拎出来聊

做后台管理系统的人都有一个共识:列表页是绕不过去的坎,而分页是列表页的命门。我见过太多项目,前端把数据一股脑全查出来再在浏览器里切片,数据量小的时候看着挺美,一旦表里过了几万行,页面直接卡成幻灯片。也见过后端老老实实写了分页,结果前端翻到第二页数据就乱了,或者总数对不上,翻着翻着出现重复记录。这些问题的根子,往往不在业务逻辑,而在分页这条链路上前后端没对齐。

这篇内容聊的是SpringBoot + PageHelper + Vue这套组合怎么把分页做扎实。PageHelper 是国内 Java 后端圈子里用得极广的一个 MyBatis 分页插件,它的核心价值在于:你只需要在查询前写一行PageHelper.startPage(pageNum, pageSize),它就能自动帮你改写 SQL 加上limit子句,同时把总数查出来封装进PageInfo。Vue 这边负责把页码、每页条数、总记录数这些状态管理好,再把请求发出去、把结果渲染出来。

适合谁看?如果你正在写第一个带列表的后台页面,或者你已经在用这套技术栈但总觉得分页哪里不对劲,那这篇就是写给你的。我会把整条链路拆开,从依赖引入、后端接口设计、前端状态管理,一直到实际跑起来之后那些文档里不会写的坑,全部过一遍。核心关键词就三个:SpringBoot 分页、PageHelper 插件、Vue 分页组件,围绕它们把每个环节讲透。

2. 后端分页的骨架:PageHelper 到底替你干了什么

2.1 不引入插件时,手写分页有多啰嗦

先想清楚一件事:如果没有 PageHelper,你要怎么分页?通常得写两条 SQL,一条select count(*) from ... where ...拿总数,一条select ... from ... where ... limit #{offset}, #{pageSize}拿当前页数据。两条 SQL 的 where 条件必须完全一致,改一处就得改两处,稍不留神就出现"总数是 100,但翻到第 5 页只有 3 条"这种对不上的情况。而且 offset 的计算、参数校验、返回结构的封装,全是重复劳动。

PageHelper 的思路是拦截 MyBatis 执行 SQL 的过程。当你调用startPage之后,它会用一个 ThreadLocal 把分页参数存起来,紧接着拦截你即将执行的那条查询,自动做两件事:一是把原 SQL 包一层 count 查询算出总数,二是给原 SQL 拼上 limit。执行完把结果塞进PageInfo对象,里面既有当前页数据列表,也有总记录数、总页数、是否有上一页下一页这些信息。你写的业务 SQL 完全不用动,这就是它最舒服的地方。

2.2 依赖引入与版本选择的门道

在pom.xml里加依赖,这里有个版本选择的经验。PageHelper 的版本要和你的 MyBatis、SpringBoot 版本大致匹配,太老的版本在新版 SpringBoot 下可能出现自动配置失效的问题。

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

我建议直接用pagehelper-spring-boot-starter这个 starter,而不是单独的pagehelper。原因很实在:starter 帮你把PageInterceptor自动注册进 MyBatis 的插件链,省去了手写配置类的麻烦。如果你用的是单独的 pagehelper,就得自己写一个@Configuration去 new 一个PageInterceptor再交给SqlSessionFactory,多此一举还容易配错。

版本上,1.4.x 系列对 SpringBoot 2.x 和 3.x 的兼容性都还不错。如果你项目里 MyBatis 用的是 3.5 以上,配 1.4.7 基本不会出问题。这里提醒一句:不要同时引入多个分页插件,比如你既引了 PageHelper 又引了 MyBatis-Plus 的分页,两个拦截器会打架,SQL 可能被改写两次,结果就是数据莫名其妙地少。

2.3 配置文件里那几个必须写对的参数

光引依赖还不够,application.yml里得把 PageHelper 的行为配清楚:

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

逐个说这几个参数为什么重要。helper-dialect指定数据库方言,虽然 PageHelper 能自动探测,但显式写死 mysql 更稳,避免某些场景下探测失败导致 limit 语法不对。reasonable这个参数特别关键,设为 true 之后,如果你传的页码小于 1,它会查第一页;如果页码超过总页数,它会查最后一页。这个行为在真实系统里非常有用,因为前端用户手动改 URL 参数、或者删完最后一页数据后停留在空页,都是常见情况,reasonable 能兜住这些边界。

support-methods-arguments和params是配合使用的,它允许你从方法参数里自动识别分页参数,不用每次都手动startPage。不过我个人习惯还是显式调用startPage,因为自动识别有时候会误判,把不该分页的查询也分了页,排查起来很头疼。这个取舍看团队习惯,新手建议先用显式调用,逻辑清晰。

2.4 一个容易忽略的线程安全问题

PageHelper 用 ThreadLocal 存分页参数,这意味着startPage和它后面紧跟的那条查询必须在同一个线程、且中间不能插入别的查询。我踩过一次坑:在startPage之后、真正查询之前,顺手调了一个别的 service 方法去查了个字典表,结果分页参数被那个查询消费掉了,真正的列表查询反而没分页,返回了全量数据。当时数据量小没发现,上线后列表页直接拖垮了数据库。

所以记住一条铁律:PageHelper.startPage()之后,紧接着的那一条查询才会被分页,中间不要插入任何其他数据库操作。如果确实需要先查别的数据,把startPage挪到紧挨着列表查询的位置。

3. 后端接口怎么设计才让前端用得舒服

3.1 返回结构:PageInfo 直接返回还是自己封装

PageHelper 查完之后返回的是PageInfo<T>,它自带一堆字段:list、total、pageNum、pageSize、pages、hasPreviousPage、hasNextPage等等。有人图省事直接把PageInfo序列化成 JSON 返回给前端,能跑通,但我不推荐。

原因是PageInfo的字段命名和前端习惯不太一致,而且它把很多派生字段都塞进来了,前端真正需要的其实就四个:当前页数据、总条数、当前页码、每页条数。直接返回 PageInfo 会让接口契约变得臃肿,前端还得去猜哪个字段是干嘛的。更好的做法是定义一个统一的返回体:

public class PageResult<T> { private List<T> records; private long total; private int pageNum; private int pageSize; // 构造方法、getter、setter 省略 }

然后在 service 里做一次转换:

public PageResult<UserVO> listUsers(int pageNum, int pageSize, String keyword) { PageHelper.startPage(pageNum, pageSize); List<User> users = userMapper.selectByKeyword(keyword); PageInfo<User> pageInfo = new PageInfo<>(users); List<UserVO> voList = users.stream() .map(this::toVO) .collect(Collectors.toList()); return new PageResult<>(voList, pageInfo.getTotal(), pageInfo.getPageNum(), pageInfo.getPageSize()); }

这里有个细节值得说:new PageInfo<>(users)必须在users被转换之前执行。因为 PageHelper 把总数信息挂在了返回的 List 上(实际是Page类型),如果你先 stream 转换生成了新的 List,那个新 List 就丢了分页元信息,PageInfo拿到的 total 会是当前页的条数而不是总数。这个坑我见过不止一个同事踩,表现就是前端总数永远等于每页条数。

3.2 Controller 层的参数校验不能省

接口参数进来,pageNum 和 pageSize 必须做校验。前端传个 pageSize=100000 过来,你不拦着,数据库直接给你查出十万条,内存瞬间飙升。我的做法是设一个上限:

@GetMapping("/users") public Result<PageResult<UserVO>> listUsers( @RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "10") int pageSize, @RequestParam(required = false) String keyword) { if (pageNum < 1) pageNum = 1; if (pageSize < 1) pageSize = 10; if (pageSize > 100) pageSize = 100; return Result.success(userService.listUsers(pageNum, pageSize, keyword)); }

为什么上限设 100?这是经验值。后台列表页一屏最多也就显示几十条,用户主动选"每页 100 条"已经是极限了。再大对用户体验没帮助,对数据库却是实打实的压力。这个上限配合前面配置的reasonable: true,基本能把所有异常入参都兜住。

3.3 关键字查询和分页的配合

带条件的分页是最常见的场景。这里要注意的是,count 查询和 list 查询的条件必须一致,而 PageHelper 自动生成的 count SQL 是基于你的原 SQL 改写的,所以只要你把条件写在同一条 SQL 里,就不会出现条件不一致的问题。这恰恰是 PageHelper 相比手写两条 SQL 的优势。

但有个例外:如果你的 SQL 里用了order by,PageHelper 生成 count SQL 时会自动把 order by 去掉,因为排序对计数没意义,去掉能提升性能。这个优化是自动的,你不用管。不过如果你的 SQL 里有group by,count 的结果可能和你预期的不一样,这时候需要手动指定 count 查询,用params配置里的countSql来覆盖。这种情况在统计类列表里会遇到,普通增删改查列表基本碰不到。

4. Vue 前端的分页状态怎么管才不乱

4.1 分页组件的选型:Element Plus 还是自己写

Vue 生态里做后台,Element Plus 的el-pagination组件是默认选择。它开箱即用,支持页码切换、每页条数切换、跳页、总数显示,样式也统一。如果你用的是 Vue 3 + Element Plus,直接用它就行,没必要自己造轮子。

但用组件不代表不用管状态。很多人用el-pagination的方式是:把current-page和page-size绑到 data 上,监听current-change和size-change事件去重新请求。这个思路对,但细节上容易出问题。比如切换每页条数时,当前页码应该重置为 1,否则你本来在第 5 页,把每页条数从 10 改成 20,总页数变少了,第 5 页可能已经不存在了,就会查到空数据。

4.2 用组合式函数封装分页逻辑

在 Vue 3 里,我习惯把分页逻辑抽成一个 composable,这样多个列表页可以复用:

import { ref, reactive } from 'vue' export function usePagination(fetchFn) { const loading = ref(false) const list = ref([]) const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 }) const load = async () => { loading.value = true try { const res = await fetchFn({ pageNum: pagination.pageNum, pageSize: pagination.pageSize }) list.value = res.data.records pagination.total = res.data.total } finally { loading.value = false } } const handlePageChange = (page) => { pagination.pageNum = page load() } const handleSizeChange = (size) => { pagination.pageSize = size pagination.pageNum = 1 load() } return { loading, list, pagination, load, handlePageChange, handleSizeChange } }

这个封装的价值在于把"页码变化"和"每页条数变化"这两个行为的差异固化下来:页码变化只改页码,每页条数变化要重置页码。这个规则如果每个页面都手写一遍,迟早有人漏掉重置那一步。

4.3 搜索条件和分页的联动

列表页通常带搜索框。用户输入关键字点搜索,这时候页码必须重置为 1,因为搜索结果是全新的数据集,停留在原来的页码没有意义。这个逻辑要写进搜索的处理函数里:

const handleSearch = () => { pagination.pageNum = 1 load() }

还有一个更隐蔽的问题:搜索条件在翻页时必须保持。也就是说,用户在第 1 页搜了"张三",翻到第 2 页,请求里必须还带着"张三"这个条件。如果搜索条件只存在组件的局部变量里,翻页时没带上,第 2 页就会变成全量数据的第 2 页,用户会觉得"怎么翻页之后搜索失效了"。解决办法是把搜索条件也纳入请求参数,每次 load 都带上。

4.4 删除数据后的页码回退处理

这是个真实场景里高频出现的问题。假设你在第 3 页,每页 10 条,总共 25 条数据,第 3 页只有 5 条。你把这 5 条全删了,然后重新加载,此时总数变成 20 条,只有 2 页了,但你的 pageNum 还是 3,请求出去要么返回空,要么被 reasonable 兜到最后一页。用户体验上就是"删完之后页面空了"。

我的处理方式是在删除成功后判断:如果当前页删完之后没有数据了,且当前页不是第一页,就把页码减 1 再加载。

const handleDelete = async (id) => { await deleteApi(id) if (list.value.length === 1 && pagination.pageNum > 1) { pagination.pageNum -= 1 } load() }

这个判断的逻辑是:如果当前页只剩这一条数据,删掉之后这页就空了,那就往前退一页。这个细节很小,但用户能明显感觉到"顺手"。

5. 前后端联调时那些文档不会写的坑

5.1 总数对不上:先查 count SQL 再查数据

联调时最常见的问题就是前端显示的总数和实际数据量对不上。排查顺序应该是:先在数据库里手动执行 count 查询,确认总数是多少;然后打开 MyBatis 的 SQL 日志,看 PageHelper 实际生成的 count SQL 是什么。多数情况下,问题出在 where 条件上,比如某个条件在 Java 代码里是动态拼接的,count 时没带上。

打开 SQL 日志的方法是在application.yml里配:

mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

这样控制台会打印出每条执行的 SQL 和参数,一眼就能看出 count SQL 和 list SQL 的条件是否一致。这个手段在排查分页问题时几乎是必备的,建议开发阶段就开着。

5.2 翻页数据重复或丢失:排序字段不稳定

如果你的 SQL 里没有明确的order by,或者 order by 的字段有大量重复值,翻页时可能出现同一条数据在第 1 页和第 2 页都出现,或者某条数据哪一页都找不到。原因是数据库在没有稳定排序的情况下,每次查询返回的顺序可能不同,limit 切出来的结果自然就乱了。

解决办法很简单:order by 里必须带一个唯一字段,通常是主键 id。比如order by create_time desc, id desc。这样即使 create_time 有重复,id 也能保证顺序唯一,翻页就稳定了。这个问题在数据量小的时候不容易暴露,一旦数据多了、并发上来了,就会变成偶发的诡异 bug,排查起来非常费劲。

5.3 前端页码从 0 开始还是从 1 开始

PageHelper 的页码是从 1 开始的,el-pagination的current-page默认也是从 1 开始,看起来是一致的。但如果你用了某些 UI 库或者自己写了分页逻辑,可能会遇到从 0 开始的情况。联调时如果发现"第一页数据是空的,第二页才是真正的第一页",八成就是这里差了个 1。

我的建议是在接口文档里明确写清楚:pageNum 从 1 开始。前端传参时不要做任何加减,直接用组件的当前页码。如果确实需要转换,在请求拦截器里统一处理,不要散落在各个页面里。

5.4 大数据量下的 count 性能问题

当表里数据到了百万级,select count(*)本身就会变慢,因为 InnoDB 需要扫描索引来计数。这时候分页的瓶颈往往不在 limit,而在 count。有几个应对思路:一是给 where 条件涉及的字段建好索引,让 count 能走索引;二是如果业务允许,用近似值代替精确总数,比如显示"约 1000+ 条";三是对于深度翻页(比如翻到第 1000 页),可以用"上一页最后一条的 id"作为游标来查下一页,避开 offset 越来越大的问题。

不过这些都属于优化范畴,普通后台系统数据量在十万以内,PageHelper 的默认行为完全够用。不要过早优化,先把功能做对,等真的遇到性能瓶颈再针对性处理。

6. 把这套组合用顺手之后的几点体会

整套链路跑通之后,我最大的感受是:分页这件事的复杂度不在任何单点上,而在前后端的约定和边界处理上。PageHelper 把 SQL 改写的脏活干了,Vue 的组件把 UI 渲染的活干了,但中间那些"页码重置""条件保持""删除回退""排序稳定"的规则,没有任何一个库能替你决定,必须由开发者自己想清楚。

我现在做新项目,会先把分页的约定写进接口文档:pageNum 从 1 开始、pageSize 上限 100、返回结构固定为 records/total/pageNum/pageSize、排序必须带唯一字段。这几条定下来,前后端各写各的,联调时基本不会在分页上扯皮。

另外分享一个我自己的小习惯:在开发阶段,我会故意把 pageSize 设成 2 或者 3,然后用少量数据反复翻页,观察边界行为。因为每页 10 条的时候,很多边界问题(比如最后一页只有 1 条、删除后回退)不容易触发,把 pageSize 调小能让这些问题快速暴露出来。这个土办法帮我提前发现过好几次删除回退的 bug,比等到测试阶段再报出来省事多了。

最后说一句关于工具选择的态度。PageHelper 不是唯一的分页方案,MyBatis-Plus 的分页、手写 limit、甚至 JPA 的 Pageable 都能实现同样的效果。选哪个不重要,重要的是你清楚它在你这条链路上做了什么、没做什么。把边界想清楚,用哪个工具都能把分页做稳。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询