☰
SpringBoot + PageHelper + Vue 真分页实战:从原理到工程化落地
2026/10/20 16:18:05 网站建设 项目流程

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

做后台管理系统的人都有一个共识:列表页是绕不开的坎,而分页是列表页的命门。我见过太多项目,前端把数据一股脑全查出来再在浏览器里切片,数据量小的时候看着挺美,一旦上了几千条就开始卡顿,上万条直接让页面失去响应。这种“假分页”在演示环境里能糊弄过去,到了真实业务场景就是灾难。

这次要聊的方案是SpringBoot + PageHelper + Vue这套组合拳,核心目标很明确:让后端只返回当前页需要的那几条数据,前端只负责渲染和翻页交互,数据库层面用LIMIT精准截取。这套方案解决的是真分页问题——数据在数据库就被切好了,网络传输量小,前端渲染压力低,页码跳转响应快。

适合谁看?如果你正在写前后端分离的管理系统,后端用 SpringBoot,前端用 Vue,列表数据超过几百条,那这篇内容就是给你准备的。哪怕你刚接触前后端分离不久,只要跟着思路走,也能把这套分页机制搭起来。我会把每个环节的“为什么”讲清楚,不只是贴代码,而是让你明白每一步在干什么、为什么要这么干。

2. 整体方案设计与技术选型思路

2.1 为什么是 PageHelper 而不是手写 LIMIT

分页的本质操作就两步:查总数、查当前页数据。手写 SQL 的话,你得写两条语句,一条SELECT COUNT(*),一条带LIMIT的查询,然后在 Service 层组装分页对象。这活儿不难,但重复度极高,每个列表接口都要来一遍,代码里全是模板化的东西。

PageHelper 的价值在于它把这套逻辑封装成了一个拦截器。你只需要在查询前调用一行PageHelper.startPage(pageNum, pageSize),紧接着执行正常的查询语句,它就会自动帮你做两件事:先查总数,再给原 SQL 拼上分页限制。最终返回的PageInfo对象里,当前页数据、总记录数、总页数、是否有上一页/下一页,全都给你算好了。

选它的理由很直接:代码量少、侵入性低、和 MyBatis 无缝集成。你原来的 Mapper 查询语句一个字都不用改,加一行代码就完事。相比之下,手写分页虽然可控性更强,但在中后台系统这种列表接口密集的场景下,维护成本太高。

2.2 前后端职责怎么划分才合理

分页这件事,前后端都得参与,但边界要划清楚。我的做法是:

  • 后端负责:接收页码和每页条数,执行分页查询,返回当前页数据和分页元信息(总数、总页数等)。
  • 前端负责:维护当前页码状态,触发查询请求,渲染表格和分页组件,处理页码变化事件。

关键点在于:前端不要自己去算总页数。有些前端同学喜欢拿到总数后自己除以每页条数算页数,这看似没问题,但一旦后端的分页逻辑有调整(比如每页条数有上限),前端算出来的结果就对不上了。正确做法是后端直接把total、pages这些字段返回,前端拿来就用。

2.3 数据流转的完整链路

一次分页请求的完整链路是这样的:

  1. 用户在 Vue 页面点击第 3 页,前端把pageNum=3、pageSize=10作为参数发给后端。
  2. 后端 Controller 接收参数,调用 Service。
  3. Service 里先PageHelper.startPage(3, 10),然后执行 Mapper 查询。
  4. PageHelper 拦截 SQL,先查总数,再查第 3 页的 10 条数据。
  5. 查询结果被包装成PageInfo,包含list(当前页数据)、total(总记录数)、pages(总页数)等。
  6. 后端把PageInfo转成统一的 JSON 响应格式返回。
  7. 前端拿到数据,渲染表格,同时把total传给分页组件,组件自动算出该显示多少个页码按钮。

这条链路里,PageHelper 的拦截时机是最容易出问题的地方,后面会专门讲。

3. 后端核心细节与实操要点

3.1 依赖引入与版本匹配

PageHelper 的依赖引入本身不复杂,但版本匹配是个坑。SpringBoot 2.x 和 3.x 对 PageHelper 的版本要求不一样,用错了启动就报错。

<!-- SpringBoot 2.x 项目用这个 --> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.6</version> </dependency>

如果你用的是 SpringBoot 3.x,需要换成适配的版本,通常是1.4.7以上。我实测下来,1.4.6在 SpringBoot 3.x 里会因为javax和jakarta包名的问题启动失败。这个坑踩过一次就记住了。

注意:引入pagehelper-spring-boot-starter后,不需要再单独引入 MyBatis 的 starter,它已经包含了。重复引入会导致版本冲突。

3.2 配置文件里的关键参数

application.yml 里需要配置 PageHelper 的一些行为:

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

逐个解释这些参数的作用:

  • helper-dialect:指定数据库方言。虽然 PageHelper 会自动检测,但显式指定更稳妥,避免在某些环境下检测失败。
  • reasonable:这个参数很关键。设为true时,如果页码小于 1 会查第一页,页码超过总页数会查最后一页。设为false时,页码越界会返回空数据。我一般设为true,用户体验更好。
  • support-methods-arguments:允许通过方法参数来传递分页信息,配合params使用。
  • params:指定从参数中提取分页信息的映射关系。

3.3 Service 层的正确写法

Service 层的写法有一个铁律:PageHelper.startPage()必须紧挨着查询语句,中间不能插入其他数据库操作。

public PageInfo<UserVO> getUserList(int pageNum, int pageSize, String keyword) { // 这一行必须紧挨着下面的查询 PageHelper.startPage(pageNum, pageSize); List<UserVO> list = userMapper.selectByKeyword(keyword); // 用查询结果构造 PageInfo return new PageInfo<>(list); }

为什么必须紧挨着?因为 PageHelper 的工作原理是往当前线程的 ThreadLocal 里放一个分页参数,下一次 MyBatis 查询执行时会读取这个参数并拦截 SQL。如果你在startPage和查询之间又执行了别的查询,那个查询会被分页,而真正想分页的查询反而没被拦截。这个坑非常隐蔽,排查起来很费时间。

实操心得:我习惯在startPage上面加一行注释// 分页参数设置,必须紧邻查询,提醒自己和同事不要在这中间插代码。

3.4 Controller 层的参数接收与响应封装

Controller 层要做的事情很清晰:接收分页参数,调用 Service,封装统一响应。

@GetMapping("/list") public Result<PageInfo<UserVO>> list( @RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "10") int pageSize, @RequestParam(required = false) String keyword) { PageInfo<UserVO> pageInfo = userService.getUserList(pageNum, pageSize, keyword); return Result.success(pageInfo); }

这里有几个细节值得注意:

  • defaultValue给页码和每页条数设默认值,防止前端不传参数时报错。
  • pageSize最好在后端设一个上限,比如最大 100,防止有人恶意传个 10000 把数据库拖垮。
  • 返回的PageInfo直接序列化成 JSON,前端能拿到list、total、pageNum、pages等字段。

3.5 统一响应格式的设计

为了让前端处理起来方便,我一般会定义一个统一的响应对象:

public class Result<T> { private int code; private String message; private T data; // 省略 getter/setter 和静态工厂方法 }

这样前端拿到的数据结构就是{ code: 200, message: "success", data: { list: [...], total: 100, pages: 10 } },结构清晰,错误处理也统一。

4. 前端 Vue 侧的完整实现

4.1 表格与分页组件的选型

Vue 生态里做表格和分页,Element Plus 是绕不开的选择。它的el-table和el-pagination配合起来非常顺手,而且文档齐全,社区活跃。如果你用的是 Ant Design Vue 或者 Naive UI,思路是一样的,只是组件名和属性略有不同。

我选 Element Plus 的理由很简单:分页组件的total属性直接接收总记录数,组件自动算页数,不需要手动计算。这一点比某些需要自己算页数的组件省心太多。

4.2 数据状态的定义

在 Vue 3 的 Composition API 里,我这样定义状态:

import { ref, reactive, onMounted } from 'vue' const tableData = ref([]) const total = ref(0) const queryParams = reactive({ pageNum: 1, pageSize: 10, keyword: '' }) const loading = ref(false)

这里total单独用ref是因为它需要传给分页组件,而queryParams用reactive是因为它包含多个关联字段,整体替换的场景少,逐字段修改的场景多。

4.3 请求封装与数据获取

请求封装我习惯单独抽一个 api 文件:

// api/user.js import request from '@/utils/request' export function getUserList(params) { return request({ url: '/api/user/list', method: 'get', params }) }

然后在组件里调用:

const fetchData = async () => { loading.value = true try { const res = await getUserList(queryParams) tableData.value = res.data.list total.value = res.data.total } finally { loading.value = false } }

注意这里从res.data.list和res.data.total取值,对应后端PageInfo序列化后的结构。如果你的后端返回格式不同,这里要相应调整。

4.4 分页组件的绑定与事件处理

分页组件的模板部分:

<el-pagination v-model:current-page="queryParams.pageNum" v-model:page-size="queryParams.pageSize" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" @size-change="handleSizeChange" @current-change="handleCurrentChange" />

两个事件处理函数:

const handleSizeChange = (val) => { queryParams.pageSize = val queryParams.pageNum = 1 // 改变每页条数时回到第一页 fetchData() } const handleCurrentChange = (val) => { queryParams.pageNum = val fetchData() }

这里有个细节:改变每页条数时要把页码重置为 1。因为原来在第 5 页,每页 10 条,改成每页 100 条后,第 5 页可能根本不存在了,不重置页码会查到空数据。

4.5 搜索与分页的联动

搜索场景下,用户输入关键词后应该从第一页开始查:

const handleSearch = () => { queryParams.pageNum = 1 fetchData() }

这个逻辑必须写对,否则用户在第 3 页搜索,结果可能因为总页数变少而显示空列表,体验很差。

5. 实操过程中的关键环节与参数计算

5.1 分页参数的边界处理

后端接收分页参数时,必须做边界校验。我一般这样处理:

private void validatePageParams(int pageNum, int pageSize) { if (pageNum < 1) pageNum = 1; if (pageSize < 1) pageSize = 10; if (pageSize > 100) pageSize = 100; }

为什么要限制pageSize上限?假设有人传pageSize=100000,数据库要扫描全表并返回十万条数据,内存和网络都会承受巨大压力。设个 100 的上限,既满足正常业务需求,又防止意外情况。

5.2 总数查询的性能考量

PageHelper 默认会执行一条COUNT(*)查询来获取总数。在数据量大的表上,这条查询可能很慢。我遇到过一张千万级数据的表,COUNT(*)要跑好几秒。

优化思路有几个:

  • 如果业务允许,可以用近似值代替精确总数,比如用EXPLAIN估算。
  • 给查询条件涉及的字段加索引,让COUNT走索引扫描而不是全表扫描。
  • 如果前端不需要显示总页数,可以关掉 count 查询,只返回当前页数据。

PageHelper 提供了PageHelper.startPage(pageNum, pageSize, false)来关闭 count 查询,第三个参数设为false即可。但这样PageInfo里的total就是 0,前端分页组件会显示异常,所以只适合不需要显示总数的场景。

5.3 排序字段的处理

分页查询通常需要排序,比如按创建时间倒序。PageHelper 本身不处理排序,排序要写在 SQL 里:

<select id="selectByKeyword" resultType="UserVO"> SELECT * FROM user <where> <if test="keyword != null and keyword != ''"> AND name LIKE CONCAT('%', #{keyword}, '%') </if> </where> ORDER BY create_time DESC </select>

注意ORDER BY要放在WHERE之后,PageHelper 拼接LIMIT时会自动放在ORDER BY之后,顺序不会乱。

注意:如果排序字段是用户可控的,一定要做白名单校验,防止 SQL 注入。比如只允许按create_time、id、name这几个字段排序。

5.4 前端表格的加载状态

数据请求是异步的,加载状态必须处理好。我在el-table上绑定v-loading:

<el-table :data="tableData" v-loading="loading" border stripe> <!-- 列定义 --> </el-table>

这样请求过程中表格会显示加载遮罩,用户知道数据正在加载,不会以为页面卡死了。

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

6.1 分页不生效的几种典型情况

分页不生效是最常见的问题,表现是返回了全部数据而不是当前页。排查思路如下:

现象可能原因解决方法
返回全部数据startPage和查询之间插了其他查询把startPage移到查询前一行
返回全部数据依赖版本不匹配检查 PageHelper 版本与 SpringBoot 版本是否对应
返回全部数据查询方法被@Cacheable等注解拦截缓存和分页不能同时用,去掉缓存或改用其他方案
总数不对count SQL 被自定义 SQL 覆盖检查是否有手写的 count 查询干扰
页码越界返回空reasonable设为 false改为 true 或前端做页码校验

6.2 PageInfo 字段与前端对不上

有时候后端返回的字段名和前端期望的不一致。比如 PageHelper 返回的是pageNum,前端可能期望currentPage。这种问题一般出在接口文档没对齐。

我的做法是:后端返回的字段名一旦确定就不要改,前端按后端字段来适配。如果确实需要转换,在前端的请求拦截器里统一处理,而不是在每个组件里改。

6.3 大数据量下的性能问题

数据量大了之后,分页查询本身可能变慢。除了前面说的 count 查询优化,还有几个点:

  • 深分页问题:LIMIT 100000, 10这种查询,数据库要扫描前 100000 条再丢弃,效率极低。优化方式是改用游标分页,记录上一页最后一条的 ID,下一页从那个 ID 之后查。
  • 索引覆盖:确保ORDER BY的字段有索引,否则排序本身就很耗时。
  • **避免 SELECT ***:只查需要的字段,减少数据传输量。

6.4 前端页码跳转后数据不刷新

这个问题通常是因为fetchData没有在页码变化时被调用。检查@current-change事件是否绑定正确,以及queryParams.pageNum是否真的被更新了。Vue 3 的v-model:current-page会自动更新绑定的变量,但如果你手动改了queryParams.pageNum而没有触发fetchData,页面就不会刷新。

6.5 每页条数切换后页码错乱

前面提过,切换每页条数时要重置页码为 1。但还有一种情况:用户在第 5 页,每页 10 条,总共 50 条数据。切换到每页 20 条后,总页数变成 3 页,第 5 页不存在了。如果没重置页码,后端reasonable=true会返回最后一页数据,但前端页码显示还是 5,看起来就像卡住了。所以重置页码这一步不能省。

7. 一些让分页更稳的工程化建议

7.1 把分页参数封装成对象

每次传pageNum和pageSize两个参数有点啰嗦,我习惯封装一个PageQuery对象:

public class PageQuery { private int pageNum = 1; private int pageSize = 10; // getter/setter }

Controller 里直接接收这个对象,Service 里从对象里取参数。这样以后要加分页相关的其他参数(比如排序字段),只需要改这一个类。

7.2 前端分页组件的二次封装

如果项目里有很多列表页,每个页面都写一遍分页逻辑太重复。我一般会封装一个usePagination组合式函数:

export function usePagination(fetchApi) { const tableData = ref([]) const total = ref(0) const queryParams = reactive({ pageNum: 1, pageSize: 10 }) const fetchData = async () => { const res = await fetchApi(queryParams) tableData.value = res.data.list total.value = res.data.total } const handleSizeChange = (val) => { queryParams.pageSize = val queryParams.pageNum = 1 fetchData() } const handleCurrentChange = (val) => { queryParams.pageNum = val fetchData() } return { tableData, total, queryParams, fetchData, handleSizeChange, handleCurrentChange } }

这样每个列表页只需要传入自己的请求函数,分页逻辑全部复用。

7.3 接口返回结构的统一约定

前后端要约定好分页接口的返回结构,我一般这样定:

{ "code": 200, "message": "success", "data": { "list": [], "total": 100, "pageNum": 1, "pageSize": 10, "pages": 10 } }

这个结构一旦定下来,所有列表接口都按这个来,前端处理起来就统一了。

7.4 日志与监控

分页接口的耗时值得关注。我在项目里会给分页查询加日志,记录页码、每页条数、耗时。如果某个接口的耗时经常超过 1 秒,就要考虑优化了。监控方面,可以用 SpringBoot Actuator 暴露指标,配合 Prometheus 做可视化。

8. 我个人在实际操作中的几点体会

这套方案我在多个项目里用过,整体稳定性很好,但有几个点是我踩过坑之后才重视起来的。

第一个是PageHelper 的 ThreadLocal 清理。PageHelper 在查询完成后会自动清理 ThreadLocal,但如果查询过程中抛异常,清理可能不彻底。在 Web 环境下,线程是复用的,残留的分页参数可能影响下一次请求。我的做法是在 Service 方法上加try-finally,在finally里手动调用PageHelper.clearPage()。虽然大多数情况下不清理也没事,但加上这层保险更安心。

第二个是前端分页组件的total更新时机。有时候数据删除了,总数变了,但分页组件还显示旧的页数。我的做法是每次fetchData成功后都更新total,而不是只在初始化时更新一次。

第三个是深分页的替代方案。如果业务确实需要翻到很后面的页,LIMIT偏移量大的查询会很慢。我一般会跟产品沟通,看能不能改成“加载更多”的模式,或者用游标分页。如果必须保留页码跳转,那就得在数据库层面做优化,比如用覆盖索引减少回表。

最后分享一个小技巧:在开发阶段,我会在分页接口里加一个debug参数,开启后返回实际执行的 SQL 和耗时。这样排查分页问题时,能直接看到 PageHelper 拼接后的 SQL 长什么样,比盲猜快得多。这个参数只在开发环境生效,生产环境自动关闭。

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

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

立即咨询