Bootstrap4分页实战:从组件到SQL与打印页码全解析
2026/9/7 22:58:13 网站建设 项目流程

做管理系统的人,几乎没有哪一天能躲开分页。前端摆一排页码按钮,后端写一条带 limit 的 SQL,看起来各自安好,可真把 Bootstrap4 分页从页面样式到接口数据、从 MySQL 查询到打印输出完整捋一遍,你会发现坑比想象中多得多。这些年我接手过的后台项目里,因为分页翻车的例子一只手数不过来:第二页和第一页数据重复、翻到 50 页以后接口越来越慢、搜索完之后页码没重置、后端返回结构和前端对不上、列表打印出来页码在纸面上到处乱跑。这篇文章就专门围绕 Bootstrap4 分页,把组件用法、前后端数据约定、MySQL 和 Oracle 的分页写法、MyBatis Plus 的常见坑、打印页码处理这些事一次说清楚。适合正在写后台管理系统、需要自己从零对接列表分页的初中级前端,也适合后端同学想搞明白前端分页组件到底怎么接数据。

1. Bootstrap4 分页组件:先把样式和结构吃透

1.1 三层嵌套结构,为什么不是一层搞定

Bootstrap4 的分页组件没有内置任何 JavaScript 行为,它就是一套 CSS 样式。这一点很多人第一次接触时不适应——你拿到的只是"长相",不是"行为"。它的基础结构是这样的:

<nav aria-label="Page navigation"> <ul class="pagination"> <li class="page-item"><a class="page-link" href="#">上一页</a></li> <li class="page-item"><a class="page-link" href="#">1</a></li> <li class="page-item active"><a class="page-link" href="#">2</a></li> <li class="page-item"><a class="page-link" href="#">3</a></li> <li class="page-item"><a class="page-link" href="#">下一页</a></li> </ul> </nav>

nav 负责语义化,让屏幕阅读器和搜索引擎知道这是一块导航区域;ul.pagination 是真正的容器;li.page-item 是每个页码的外壳,a.page-link 是可见可点的按钮。为什么包这么多层?因为 Bootstrap4 把"状态"和"视觉"彻底分开了——你在 li 上控制 active、disabled,在 a 上统一管理链接样式。这个设计对你后期动态渲染特别有利,尤其是用 Vue 或 jQuery 生成页码时,只需要操作 li 的 class 就能切状态,完全不碰链接本身的样式。

有一点必须强调:分页的点击事件、异步加载数据、当前页高亮的切换,全部要你自己写。Bootstrap4 官方文档明确说了,这个组件只提供 CSS 和少量辅助类,交互逻辑交给开发者。很多新手以为加上 .pagination 就能自动翻页,这是对 Bootstrap4 分页最大的误解。

1.2 active 和 disabled:两个状态最容易用错的地方

当前页用 .active,不可点击的页码用 .disabled,这是直觉,但细节里有大坑。

.active 会通过伪元素把当前页按钮的背景色加深,并且默认情况下分页链接的圆角会因为这个状态产生"断口"——Bootstrap4 的处理方式是在 .page-item.active 的兄弟节点上调整边框颜色,让视觉上保持连贯。所以你如果手动用 JavaScript 添加或移除 active 类,一定要确保操作的是 li.page-item,而不是 a.page-link。

.disabled 的坑更大。它只是把链接的点击效果去掉了(pointer-events 在部分版本里甚至没处理),href 还挂在 a 上,用户依然可以用键盘回车或右键新标签打开触发跳转。官方推荐的做法是:禁用状态下不要用 a 标签,直接换成 span:

<li class="page-item disabled"> <span class="page-link">上一页</span> </li>

这样既保留了样式,又从结构上杜绝了误触。我在项目里见过好多次,上一页在首页时还能点,点了以后页面刷新回第一页,就是因为只加了 disabled 类、没管 href。要么用 span,要么在点击事件里判断并阻止默认行为,二选一,别偷懒。

1.3 尺寸、对齐和图标:工具类的正确用法

Bootstrap4 分页支持两种额外的尺寸类:.pagination-lg 和 .pagination-sm,加在 ul 上就行,适合不同密度场景。比如列表数据很多、页码区在底部空间紧张时用 sm,管理后台的主列表页用默认尺寸,桌面端报表可以用 lg。这个看产品需求灵活选,没有绝对标准。

对齐方式要借助 Flex 工具类,因为 .pagination 本身就是 display: flex。默认靠左,居中加 .justify-content-center,靠右加 .justify-content-end:

<nav class="d-flex justify-content-center"> <ul class="pagination">...</ul> </nav>

图标分页也很常见,上一页下一页用 « 和 » 代替文字。Bootstrap4 内置了 .page-link 的样式,塞 HTML 实体或 SVG 都行:

<li class="page-item"> <a class="page-link" href="#" aria-label="Previous"> <span aria-hidden="true">&laquo;</span> <span class="sr-only">上一页</span> </a> </li>

sr-only 类给屏幕阅读器念出"上一页",纯视觉用户看到的是箭头。这个细节很小,但无障碍评分和实际用户体验都会用到,建议养成习惯。

2. 分页数据流设计:前后端参数与响应约定

2.1 先统一"分页语言",再谈组件对接

Bootstrap4 只是展示层,真正让分页跑起来的是数据。我见过太多项目死在"前端要 A 字段,后端给 B 字段"这种蠢问题上,所以第一步一定是把前后端的分页"语言"谈拢。

请求参数最常见的约定是 page 和 limit(或 size),page 从 1 开始,limit 是每页条数。为什么用 limit 而不是用过时的 pageSize?没有硬性规定,纯粹是看团队习惯,但一个项目里必须统一。响应结构我推荐长这样:

{ "code": 0, "message": "success", "data": { "total": 125, "page": 2, "size": 10, "pages": 13, "records": [] } }

total 是总条数,pages 是总页数。前端拿到 total 和 pages 才能渲染页码按钮。有的后端只返回 total,让前端自己算 pages,这也能接受,但要约定好统一,别一个系统里有的接口返回 pages、有的不返回,前端组件就要写两套兼容逻辑,纯属给自己找事。

还有一个容易忽略的点:total 的类型。Java 的 Long 超过 2^53 时,JavaScript 侧会丢精度,导致总数显示成奇怪的大数。如果你用 Jackson 默认序列化,后端 total 是 Long 类型且结果很大(基本不会,但别踩),前端 parseInt 后可能不准。稳妥做法是在序列化层把 Long 转成 String,或者确认业务总条数在安全范围内。这个问题碰到的概率低,但碰到一次就是线上事故。

2.2 MySQL 分页:LIMIT 的 offset 不是页码

MySQL 最常用的分页写法是 LIMIT offset, size,很多人翻车就翻在把页码当成 offset。第二页、每页 10 条,应该是 LIMIT 10, 10,跳过前 10 条取第 11 到 20 条,而不是 LIMIT 2, 10——那样会漏数据。公式很简单:

offset = (page - 1) * size

比如 page=5、size=20,offset=(5-1)*20=80,SQL 就是:

SELECT * FROM orders WHERE status = 1 ORDER BY create_time DESC LIMIT 80, 20;

这只是入门,真正的坑在性能。分页查询慢,绝大多数不是分页本身造成的,而是"没走索引"和"深分页"。前端第一页第二页都很快,翻到第 50 页突然变成几秒,原因就是 LIMIT 后面那个 offset 太大。MySQL 执行 LIMIT 100000, 20 时,要先把前 100000 行全查出来再丢弃,前面的扫描成本全算在查询里。就算有索引,InnoDB 也要一条一条回表读取数据行,越深越慢。

应对方案有两个方向。

一是"延迟关联"(延迟联接):先用覆盖索引快速定位主键,再回原表取数据:

SELECT o.* FROM orders o INNER JOIN ( SELECT id FROM orders WHERE status = 1 ORDER BY create_time DESC LIMIT 100000, 20 ) t ON o.id = t.id ORDER BY o.create_time DESC;

子查询只扫索引列,不回表,数据量少很多,速度能提升一个量级。

二是"游标分页":不要用页码,改成传"上一页最后一条记录的 id":

SELECT * FROM orders WHERE status = 1 AND id < 上一页最大id ORDER BY id DESC LIMIT 20;

这种写法没有 offset,无论翻多深都只扫描 20 条,性能恒定。缺点是页码不能随便跳,只支持上一页下一页,适合 App 信息流和无限滚动场景,不适合后台管理表格这种需要跳页的界面。而且游标分页对排序字段要求高,必须要一个稳定的唯一字段(通常是 id)配合。

我自己的习惯是:管理后台保持传统的 page 分页,但后端强制加 ORDER BY 和合适的索引;对 C 端列表或日志查询,优先推荐游标分页。两种思路要根据"是否要跳页"来选,没有银弹。

2.3 Oracle 分页:ROWNUM 子查询和 12c 新语法

Oracle 和 MySQL 完全是两种风格。Oracle 12c 之前没有 LIMIT 语法,分页要靠伪列 ROWNUM,而且 ROWNUM 不能直接在 WHERE 里写大于某个值,必须先查出来排好序,再在外层套子查询过滤:

SELECT * FROM ( SELECT t.*, ROWNUM rn FROM ( SELECT * FROM employees ORDER BY employee_id ) t WHERE ROWNUM <= 20 ) WHERE rn > 10;

这段逻辑拆开看是三层:最内层先排序,中间层给每一行算 ROWNUM 并控制上界,最外层用 ROWNUM 的别名 rn 过滤下界。如果只套一层,直接写 WHERE ROWNUM > 10,Oracle 会返回空结果——因为它是在取第一条的时候判断 ROWNUM > 10 不成立,整条查询直接终止了,这是 Oracle 新手最容易懵的地方。

Oracle 12c 及以上版本提供了标准写法:

SELECT * FROM employees ORDER BY employee_id OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY;

OFFSET 是跳过多少行,FETCH NEXT 是取多少行,语义和 MySQL 的 LIMIT 一致,但注意顺序:必须先 ORDER BY,再 OFFSET,再 FETCH。最新版本(18c/19c/21c)还支持 FETCH FIRST 带 PERCENT、WITH TIES 这些高级选项,普通项目用基础版就够。

如果项目里同时用多个数据库,或者 MyBatis 的 XML 里写了方言相关的分页 SQL,建议尽量用数据库自身语法,把分页抽到公共方法里。后面我会讲 MyBatis Plus 怎么通过插件屏蔽这些差异,但理解底层 SQL 仍然很重要,因为一旦插件失效,排查问题全靠你对这些语法的熟悉程度。

2.4 MyBatis Plus 分页插件:配置对了才不分页失效

Java 后端的同学大概率接触过 MyBatis Plus,它内置了分页插件 PaginationInnerInterceptor,能自动把普通查询套上分页,并生成 count 查询。但"分页不生效"是这个插件被搜最多的关键词之一,我自己也踩过,问题基本集中在四个地方。

第一,拦截器没注册。Spring Boot 场景下要配一个 MybatisPlusInterceptor Bean,并把 PaginationInnerInterceptor 加进去:

@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }

注意 DbType 要和你实际数据库匹配,填错了方言会导致 SQL 拼接错误。

第二,SQL 里用了自定义的复杂语句或者本身在大表上 count 很慢。PaginationInnerInterceptor 对简单的 selectList 很智能,但如果你在 XML 里写了 join 或 group by,插件生成的 count 语句可能不准确,甚至会报错,然后整个分页退化成不带分页的查询,表现就是"接口返回了所有数据,前端页码没作用"。

第三,参数没传 page 和 size。分页插件是拦截方法参数里的 IPage 对象才生效的,如果你 mapper 方法的入参是普通的实体类,插件找不到分页信息,自然不处理。

第四,排序字段没进索引。这部分和插件无关,但很多人在 page 正常、limit 正常的情况下依然慢,回头查才发现 ORDER BY create_time 的字段没有索引,MySQL 每次都要 filesort。

遇到分页失效,最快的排查方法是打开 MyBatis 的 SQL 日志,看打印出来的语句里有没有 LIMIT。如果没有,说明插件没生效,按上面四个方向逐个核对。

3. Vue + Bootstrap4 分页实战:从组件到接口联调

3.1 封装可复用的分页组件

Bootstrap4 本身不提供 JS 行为,所以用 Vue 的时候,最好的做法是把它封装成一个组件,接收当前页、总页数、总条数这些 props,向外抛出 change 事件。下面是一个我在项目中直接抄过 N 次的模板:

<template> <nav v-if="totalPages > 1" aria-label="Page navigation"> <ul class="pagination mb-0"> <li class="page-item" :class="{ disabled: current <= 1 }"> <a class="page-link" href="javascript:;" @click="go(current - 1)">上一页</a> </li> <li v-for="p in pageList" :key="p" class="page-item" :class="{ active: p === current, disabled: p === '...' }" > <a v-if="p !== '...'" class="page-link" href="javascript:;" @click="go(p)" >{{ p }}</a> <span v-else class="page-link">…</span> </li> <li class="page-item" :class="{ disabled: current >= totalPages }"> <a class="page-link" href="javascript:;" @click="go(current + 1)">下一页</a> </li> </ul> </nav> </template> <script> export default { name: 'BootstrapPagination', props: { current: { type: Number, default: 1 }, totalPages: { type: Number, default: 0 } }, computed: { pageList() { // 省略号策略的计算逻辑 return generatePageList(this.current, this.totalPages) } }, methods: { go(page) { if (page < 1 || page > this.totalPages || page === this.current) return this.$emit('change', page) } } } </script>

几个细节值得注意。一是 href="javascript:;" 配合 @click,避免点击时页面跳转或滚动位置被重置。二是 v-if="totalPages > 1",只有一页时不渲染分页条,省得用户看到一个孤零零的"1"还点不了。三是在 go 方法里做边界拦截,当前页等于目标页时直接 return,避免重复请求。

3.2 页码过多时的省略策略

数据量一大,总页数可能上百。这时候把 1 到 100 全部渲染出来,用户要疯,浏览器也累。通用的策略是"首尾 + 当前页附近",中间用省略号代替。我常用的窗口大小是当前页前后各 2 个:

function generatePageList(current, totalPages) { const pages = [] if (totalPages <= 7) { for (let i = 1; i <= totalPages; i++) pages.push(i) return pages } pages.push(1) if (current > 4) pages.push('...') for (let i = Math.max(2, current - 2); i <= Math.min(totalPages - 1, current + 2); i++) { pages.push(i) } if (current < totalPages - 3) pages.push('...') pages.push(totalPages) return pages }

这段逻辑不复杂,但要注意边界:当前页在 1、2、3 附近时,不要把 1 重复塞进中间区域;当前页在倒数几页时同理。上面的 Math.max、Math.min 就是为了处理这种边界。省略号的点击事件要禁用,否则用户点了一个"..."发出请求就尴尬了。

省略号的具体位置可以根据产品要求调整,有些系统喜欢"当前页前后各 1 个",数据量没那么大时更简洁。核心原则是:第一页和最后一页永远可见,当前页永远可见,中间用省略号代替连续的大段页码。

3.3 搜索条件与分页参数联动

这是后台系统分页最容易出现隐性 bug 的地方。用户在第一页输入关键词搜索,结果列表只有 3 条,点第二页——发出的请求里搜索关键词可能丢了,或者 page 还保持在搜索前的值。正确做法是:搜索条件变化时,page 强制重置为 1。

我习惯把查询参数集中管理:

data() { return { query: { page: 1, size: 10, keyword: '', status: '' } } }, methods: { handleSearch() { this.query.page = 1 this.fetchList() }, async fetchList() { const { data } = await axios.get('/api/orders', { params: this.query }) this.list = data.records this.total = data.total } }

handleSearch 里先重置 page 再请求,顺序不能反。如果先发请求再重置 page,接口拿到的是上一次的页码,搜索后的列表可能从第 5 页开始,用户一脸懵。另一个常见坑是后端在接收 keyword 时用了 trim,但前端传参时空字符串、null、undefined 都会原样传过去,有些后端框架的字符串类型对 "" 和 null 处理不一致,导致搜索条件筛选结果不同。建议前端请求前统一做一次参数清洗,去掉空值的字段。

4. 分页问题排查与避坑技巧实录

4.1 MyBatis Plus 分页失效?按顺序排查这四个点

分页失效是后台开发的高频问题,我整理了一个排查顺序,基本能覆盖 90% 的场景。

排查顺序检查项表现
1拦截器是否注册、方言是否匹配日志里 SQL 没有 LIMIT,返回全量数据
2Mapper 方法是否接收 IPage 参数分页条件被忽略,查询无 limit
3自定义 SQL 的 count 是否异常报错被吞掉,退化成不带分页查询
4前端传参是否正常page/size 在后端被覆盖或丢失

第一点前面已经讲过了,注册 MybatisPlusInterceptor 的时候 DbType 要和数据库一致。第二点要看你的 mapper 接口,PaginationInnerInterceptor 只对包含 IPage 参数的方法生效:

IPage<Order> selectOrderPage(Page<?> page, @Param("wrapper") Wrapper<Order> wrapper);

如果你的方法签名里没有 Page 类型参数,插件无从下手。第三点比较隐蔽,分组聚合类的 SQL 在插件生成 count 时容易出错,一旦 count 失败,插件有时会走"不拦截"的兜底路径,表现就是接口正常返回数据但不分页。第四点容易被忽略,但前端调试时打开 Network 面板一眼就能确认,不用过多纠结。

4.2 非分页缓冲池占用过高:是一个容易跑偏的干扰项

后台系统变慢,监控里看到内存飙高,有人搜着搜着就跑到"非分页缓冲池占用过高"去了。这里必须先澄清:非分页缓冲池(Nonpaged Pool)是 Windows 内核用于存储无法分页到磁盘的内核对象的内存区域,它的占用升高通常和驱动程序泄漏、内核模块异常有关,跟你业务代码里 LIMIT、page、size 没有直接关系。如果你看到的是系统级别的"非分页缓冲池"持续上涨,排查方向是驱动、杀毒软件、网络过滤驱动这类内核组件,而不是你的分页 SQL。

那分页查询会不会导致内存高?会,但机制完全不同。常见场景是后端有人图省事,写了一个查询不带分页,一次性把几十万条数据查出来丢给前端,前端再自己做内存分页。这种做法的本质问题不是分页,而是数据传输量过大。另一种是分页条数设置过大,比如 size 设成 10000,一次查一万条,页面虽然"分页"了,但每页数据量碾压浏览器渲染能力,内存自然就爆了。我的建议是:管理后台的每页条数控制在 10 到 50 之间,最多给个 100 的选项,再大就该怀疑设计是不是有问题了。

4.3 打印分页页码:页面有分页,纸上也要有页码

最后一个高频需求是打印。产品经理经常会说:"这个列表你得能打印出来,而且每一页纸上要有页码。"需求听起来很自然,做起来全是坑。

页面上的分页组件和打印的"页"是两个维度的东西:页面上的分页是数据分页,打印的分页是纸张分页。你要做的是让打印内容在 A4 纸上自动分页,并且每张纸底部显示"第 x 页 / 共 y 页"。

CSS 方案的核心是 @media print 和 page-break 相关属性。比如列表每行不能拆到两张纸上,可以给行加 break-inside: avoid;会话级别的区块之间要强制分页,用 break-after: page。页码这块用 CSS 计数器比较方便:

@page { @bottom-center { content: counter(page) " / " counter(pages); } }

但要注意,@page 的 @bottom-center 这类 margin box 语法在 Chrome 里支持有限,实际项目中往往要退一步用 JS 方案。

前端打印常用 vue-plugin-hiprint,它能处理复杂模板和精准分页。关于"前端打印分页页码如何显示",hiprint 的模板编辑里可以在页眉页脚区域插入内置的页码变量,打印时会自动渲染成当前页码和总页数。如果你不想引入这么重的库,也可以自己用 JavaScript 在打印前动态把页码写入每个分页区域的底部,但难点是"如何知道当前内容在打印时会分几页"——这需要你根据打印纸张高度和内容高度估算,或者用分页容器手动切分内容。

我实际项目里简单方案是:给内容区设置固定高度对应 A4 打印高度(约 257mm 减去页边距),然后 JS 遍历内容,超过固定高度就插入一个带页码的 section。这样每次打印前动态生成页码,虽然需要写点逻辑,但稳定可控,不依赖第三方库。需要说明的是,这个属于"常见实践里的补充方案",具体实现还得结合你的内容结构和打印插件来选择。

以小见大,分页这件事从来不是"贴个组件、写条 SQL"那么简单。从 Bootstrap4 的类名细节,到 MySQL 和 Oracle 的语法差异,再到 MyBatis Plus 插件的配置、打印页码的处理,每一层都有它自己的惯性和陷阱。我的经验是:先弄清楚分页组件只是"壳",数据约定才是"魂",前后端把 page、size、total、pages 这四个字段的定义对齐,一半的问题自动消失;剩下的一半,靠 SQL 索引、深分页策略和排查顺序来解决。希望这篇基于我的实际经验的内容能帮你少踩几个坑,下次再做分页功能的时候,心里能有个完整的图谱。

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

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

立即咨询