Bootstrap 4分页组件实战:从页码算法到后端数据对接与常见坑 📅 发布时间:2026/9/15 6:22:54 👁 浏览次数: 做过 Web 开发的人应该都有体会分页这功能在需求文档里永远只占一行字可真做起来却能把人折腾到怀疑人生。我见过不少同事后端接口调通了、数据也返回了结果一接 Bootstrap 4 分页组件不是页码对不上就是点第二页没反应再一搜排查资料满屏都是 mybatisplus分页失效、oracle分页、非分页缓冲池占用过高这些看似相关、实则各说各话的词。搜索引擎里天天有人搜这些关键词说明分页这个看起来人畜无害的功能实际水相当深。这篇文章就从 Bootstrap 4 分页组件切入把组件样式怎么用、页码怎么算、后端数据怎么对接、哪些分页其实根本不归前端管一次讲清楚。1. 分清两种分页Bootstrap 4 管的是哪一段1.1 组件只管长得像分页不管数据怎么切很多刚接触 Bootstrap 4 的人以为分页组件带翻页功能天然会把数据切成两半、自动加载下一页。这是最大的误解。Bootstrap 4 的.pagination组件本质上只是一组带样式的超链接或按钮它不知道你的数据总量是多少不知道当前在第几页更不会替你去请求接口。打个比方分页组件是餐厅里的菜单上面印着第1页、第2页、下一页但真正做菜的是后厨。后厨就是你的后端接口或者前端内存里的数据切片逻辑。菜单本身不产菜它只负责把菜名摆得好看。所以排查分页问题之前先在心里画一条线界面层用 Bootstrap 4数据层是接口或数组切片两层之间靠一个当前页码和每页条数来通讯。谁出了问题就去查谁。1.2 分页这个词在不同语境里的三个意思搜索热度最高的几个词恰恰暴露了分页的多义性Web/数据库分页把大量记录按固定数量切成多个页比如 Bootstrap 4 分页、Oracle 分页、MyBatis Plus 分页。这是本文的重点。操作系统分页Windows 里的非分页缓冲池、分页文件大小这是内核内存管理和虚拟内存的机制和网页上一毛钱关系都没有但就是有人搜错。桌面组件分页比如 Qt 的 TableWidget 分页属于客户端界面逻辑和 Bootstrap 无直接关系但分页算法思想完全通用。先把这个概念立住后面才不会越查越乱。2. 从一段完整 HTML 开始分页组件的类名结构与状态切换2.1 基础结构为什么是ul 套 li 套 a三层Bootstrap 4 分页的标准写法长这样nav aria-label数据列表分页 ul classpagination li classpage-item a classpage-link href?page1上一页/a /li li classpage-item a classpage-link href?page11/a /li li classpage-item active a classpage-link href?page22/a /li li classpage-item a classpage-link href?page33/a /li li classpage-item a classpage-link href?page3下一页/a /li /ul /nav很多人不理解为什么要套三层li.page-item加状态类a.page-link才是真正的点击区域。原因有两点。第一ul.pagination是一个flex容器li是弹性项a吃掉内边距和边框这样每个分页按钮的点击热区可以做得比文字大一圈移动端上手指好点。如果直接在li上做样式点击区域只有 li 那一条视觉和交互都很难受。第二Bootstrap 4 对首尾圆角用的选择器是.page-item:first-child .page-link和.page-item:last-child .page-link。这套嵌套结构允许 CSS 精准命中第一个按钮的外侧圆角和最后一个按钮的外侧圆角中间按钮全部直角。这也是为什么官方不推荐你改成div平铺结构一改圆角就乱。这里有个容易忽略的小细节上一页/下一页的链接里href不要写死成当前页应该指向上一页下一页的真实地址。如果是纯前端渲染可以写成hrefjavascript:void(0)然后用 JS 接管但那样不利于 SEO 和中间键打开能上真实地址就上真实地址。2.2 active 和 disabled两个完全不同的状态当前页用.active不可点的页码用.disabledli classpage-item active a classpage-link href?page22/a /li li classpage-item disabled a classpage-link href?page1上一页/a /li.active的样式是把按钮变成主题色默认 Primary 蓝并让它在当前组里浮起来z-index: 1视觉上明确告诉用户你在这儿。.disabled在 Bootstrap 4 里做了一件很多人不知道的事它会给.page-link设置pointer-events: none也就是鼠标点上去直接没有点击事件。这比单纯改个灰色要彻底得多。但这里有个坑pointer-events: none只拦鼠标拦不住键盘。用户用 Tab 键仍然可以聚焦到被禁用的链接上按回车还是能触发跳转。所以做无障碍的时候要在禁用的a上补tabindex-1和aria-disabledtrueli classpage-item disabled a classpage-link href# tabindex-1 aria-disabledtrue上一页/a /li2.3 无障碍读屏器用户怎么看分页分页在视觉上很清楚但读屏器用户听到的只是1、2、3、下一页这样的裸文本完全不知道这些数字是什么意思。所以规范做法是给导航包裹层加aria-label给当前页加aria-currentpageli classpage-item active a classpage-link href?page2 aria-currentpage2/a /li注意aria-currentpage不会产生任何视觉样式蓝底仍然是.active类控制的。两件事互不替代一个给读屏器一个给眼睛。如果上一页下一页用的是laquo;raquo;这种符号读屏器会读成左书名号、右书名号所以要用sr-only补一个文字说明a classpage-link href?page1 aria-label上一页 span aria-hiddentruelaquo;/span span classsr-only上一页/span /a3. 尺寸、对齐和箭头样式定制的三个高频需求3.1 大中小三档一行类名搞定Bootstrap 4 分页只给了两档额外尺寸加默认一共三档ul classpagination pagination-lg.../ul ul classpagination.../ul ul classpagination pagination-sm.../ulpagination-lg把内边距和字号放大一档适合后台管理系统的列表页pagination-sm缩小一档适合表格密集的页面。实际项目里我 90% 用默认尺寸10% 用pagination-sm大号场景很少见。3.2 对齐不用自己写 CSS用 flex 工具类.pagination本身就是display: flex所以对齐直接用 Bootstrap 4 的通用工具类不用额外写样式ul classpagination justify-content-center.../ul ul classpagination justify-content-end.../ul默认左对齐居中加justify-content-center右对齐加justify-content-end。这比网上很多教程教的自己写text-align: center再给ul加inline-flex干净得多。text-align对 flex 容器根本没效果这就是很多人写了半天对齐没反应的原因。3.3 翻页箭头的两种常见做法我见过最多的是直接用 HTML 实体laquo;«和raquo;»好处是零依赖、字符紧凑a classpage-link href# aria-label上一页 span aria-hiddentruelaquo;/span span classsr-only上一页/span /a如果项目里有图标库用 SVG 内联箭头视觉效果更精致a classpage-link href# aria-label下一页 svg xmlnshttp://www.w3.org/2000/svg width16 height16 fillcurrentColor viewBox0 0 16 16 aria-hiddentrue path fill-ruleevenodd dM1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z/ /svg span classsr-only下一页/span /a两条都行关键是一律要配sr-only否则读屏器只会念符号。3.4 改主题色两条路别走偏如果只是想把蓝底改掉最简单的办法是覆盖.page-item.active .page-link { background-color: #e63946; border-color: #e63946; } .page-link { color: #e63946; }项目用了 Bootstrap 4 的 SCSS 源码更推荐直接改变量$pagination-color、$pagination-active-bg、$pagination-active-border-color等一组变量编译一次全局生效不用到处覆盖。4. 页码生成算法为什么不能像写死列表一样渲染分页4.1 总页数计算的边界情况接手分页时第一件事不是写组件而是先算清楚总页数const totalPages Math.max(1, Math.ceil(total / pageSize));Math.ceil保证 11 条数据、每页 10 条时得到 2 页。但有两个边界必须处理total为 0 时Math.ceil(0 / 10)是 0不处理的话分页组件会渲染出第 0 页所以要先Math.max(1, ...)让空数据也有 1 页。当然也可以选择直接隐藏分页组件。pageSize可能在接口配置里被传成 0 或负数前端要做防御取不到就默认 10。4.2 页码窗口算法1 ... 4 5 [6] 7 8 ... 20数据量大了之后不可能把所有页码都渲染出来十几页还好一百页直接渲染一百个按钮页面又长又难用。业界通用做法是当前页左右各 N 个首尾固定中间用省略号。我常用的生成函数长这样function buildPageList(totalPages, current, around 2) { const set new Set(); set.add(1); if (totalPages 1) set.add(totalPages); for (let p current - around; p current around; p) { if (p 1 p totalPages) set.add(p); } const sorted [...set].sort((a, b) a - b); const result []; let prev 0; for (const p of sorted) { if (p - prev 1) result.push(...); result.push(p); prev p; } return result; }比如 totalPages 是 20、当前页是 6、around 是 2返回结果就是[1, ..., 4, 5, 6, 7, 8, ..., 20]。用Set的目的是去重——当当前页靠近首页时current - around可能等于 11会被重复加入。排序后插入省略号时用p - prev 1判断中间是否有空隙有就补一个...这段逻辑简单但很实用。4.3 事件绑定用事件委托别一个按钮绑一个监听器渲染完一堆li.page-item之后给每个按钮单独addEventListener是最容易写、也最容易出问题的写法——删掉重渲染时还要记得解绑绑多了还会内存泄漏。分页按钮数量有限问题不大但更稳妥的做法是事件委托document.querySelector(#pagination).addEventListener(click, (e) { const link e.target.closest(.page-link); if (!link || link.parentElement.classList.contains(disabled)) return; const page parseInt(link.dataset.page, 10); if (!page || page currentPage) return; loadPage(page); });这里有个细节判断 disabled 要检查li.page-item上有没有.disabled而不是光看a.page-link。因为用户的点击目标可能落在a内部的文本节点上closest(.page-link)能拿到a但 disabled 类是加在外层li上的必须再parentElement.classList.contains(disabled)确认。4.4 请求竞态点快了晚回来的请求覆盖新数据这是前端分页最隐蔽的坑。用户先点了第 2 页又快速点了第 5 页两个请求同时发出。如果第 2 页的响应比第 5 页慢那么第 5 页的数据先渲染随后第 2 页的数据到达直接把界面打回到第 2 页但 URL 上的参数还是第 5 页——数据和状态就对不上了。解决方案是维护一个请求序号每次发请求时自增回调里只认最新序号let requestSeq 0; function loadPage(page) { const seq requestSeq; setLoading(true); fetch(/api/list?page${page}size10) .then((res) res.json()) .then((data) { if (seq ! requestSeq) return; // 过期响应直接丢弃 renderTable(data.records); renderPagination(data.total, page); setLoading(false); }); }这个教训我在真实项目里踩过不止一次尤其是接口响应时间波动大的时候几乎必现。调试时还特别难复现因为你正常点它不触发手一快就触发。5. 后端数据对接实录MyBatis Plus、Oracle、Redis 与 NC65 的分页现场5.1 MyBatis Plus 分页失效最常见的五类原因MyBatis Plus 分页是搜索热词里的重灾区搜出来的问题十有八九是下面几类。原因一没配分页插件。MyBatis Plus 3.x 的分页必须要先声明拦截器否则selectPage只是把Page对象传进去SQL 完全不拼LIMIT查回来的是全量数据。正确配置Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }原因二Mapper 方法第一个参数不是IPageT。MyBatis Plus 插件判断是否分页看的就是方法参数里有没有IPage。你把Page放到第二个参数、或者压根没传插件就不会触发。正确写法IPageUser selectUserPage(PageUser page, Param(name) String name);这里注意泛型要写对PageUser的泛型决定了返回记录集的类型。原因三Page类导错包。常见的是导成了org.apache.ibatis.session.RowBounds下面的Page或者自己项目里另外一个同名Page类。MyBatis Plus 的Page类全名是com.baomidou.mybatisplus.extension.plugins.pagination.PageIDE 自动补全时很容易选错。原因四count 查询出错或计数不准。插件在分页时默认会先执行一条COUNT语句。遇到DISTINCT、GROUP BY、多表 JOIN 这种复杂 SQL自动生成的 count SQL 可能算错甚至直接报错。这时可以在PaginationInnerInterceptor上关掉 count 优化或者直接提供自定义 countPaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setOptimizeJoin(false);这个optimizeJoin属性在 3.5.x 版本里对 join 查询的 count 有优化处理如果 count 结果不对优先在这里找原因。原因五多个拦截器顺序问题。项目里同时用了多租户插件、动态表名插件、乐观锁插件的时候PaginationInnerInterceptor的添加顺序会影响执行链路。特别是 3.5.9 之后分页插件对拦截顺序更加敏感。排查方法就是调整addInnerInterceptor的顺序逐个试。排查 MyBatis Plus 分页问题最快的方式不是看代码而是打开 SQL 日志。把 mybatis 的日志级别调到 debug看执行时有没有真的打印LIMIT和COUNT两条 SQL。有 limit 但没 count说明 total 是前端自己编的两条都没有说明分页插件压根没生效直接去检查拦截器配置。5.2 Oracle 分页三层嵌套的 ROWNUM 套路Oracle 经典分页和三层的 ROWNUM 嵌套分不开很多人第一次看会被绕晕。其实记住一个原则就够了先排序再拦上限最后过滤下限。SELECT * FROM ( SELECT t.*, ROWNUM rn FROM ( SELECT * FROM emp ORDER BY sal DESC ) t WHERE ROWNUM :page * :size ) WHERE rn (:page - 1) * :size最内层做真正的排序中间层用ROWNUM 页号 × 每页条数把超出的行截掉最外层用rn (页号 - 1) × 每页条数把前面页的数据丢掉。为什么不直接在最内层排序后套一层 ROWNUM因为 Oracle 的ROWNUM在排序前就会分配不先排序的话页码完全乱掉。Oracle 12c 及以上可以直接用OFFSET ... FETCH NEXTSELECT * FROM emp ORDER BY sal DESC OFFSET (:page - 1) * :size ROWS FETCH NEXT :size ROWS ONLY;这个写法直观得多但要先确认生产环境的 Oracle 版本 —— 很多老系统还停在 11g不支持。5.3 Redis 分页别拿 KEYS 硬分Redis 里做分页最容易踩的坑是先把所有 key 查出来再在内存里分页。用KEYS *在 key 数量大时会造成 Redis 阻塞生产环境绝对不要这么做。常见场景分三种List 结构用LRANGE key start stopstart 和 stop 是基于下标的天然适合分页。注意stop是闭区间LRANGE key 0 9返回 10 条。Sorted Set 结构用ZREVRANGE key (page-1)*size page*size-1 WITHSCORES适合做排行榜分页。但排行榜高频更新时基于 offset 的分页在翻页间隙数据漂移严重更适合用上一页最后一条的 score做游标继续往下取。大规模 Key 遍历用SCAN配合COUNT分批游标遍历。SCAN 返回的是一个游标和一批 key下次把游标传回去再继续不是传统意义上的第几页但确实是一种分页思路。Redis 分页最大的问题是总量统计成本高。LLEN取 List 长度没问题但 Sorted Set 的ZCARD在高并发下也可能和实际有偏差。前端分页组件需要 total 时要么返回近似值要么干脆不显示总页数只做上一页/下一页的翻页。5.4 NC65 这类老牌 ERP 查询接口的分页思路NC65 是典型的老牌企业级框架查询接口分页搜的人也不少。它的分页改造思路其实和普通 Java Web 没有本质区别关键要搞清楚查询入口在哪。如果走的是 NC 的查询模板框架它自带了分页逻辑你要做的是把查询条件封装好拿到返回结果后直接解析记录集。如果是在 DAO 层自己写 SQL那就按底层数据库方言来NC 多数部署在 Oracle 上用上一节的三层 ROWNUM 写法。给 NC 查询接口做前端改造时最忌讳的是双重分页——框架已经按页返回了数据前端拿到后又按自己的逻辑切了一次结果每页显示条数对不上。遇到这种情况先确认后端返回的到底是全量还是当页数据再决定前端要不要重新分页。我处理过几次这类老系统对接统一的做法是让后端返回一个规范的 JSON 结构{ records: [], total: 237, current: 1, size: 10, pages: 24 }不管后端用什么技术栈只要前端能拿到这五个字段Bootstrap 4 分页组件就可以稳定渲染。这个约定建议在项目里写进接口规范能省掉后面一大堆扯皮。6. 当分页跑到系统层面非分页缓冲池与分页文件的澄清6.1 非分页缓冲池占用过高这是内核内存问题搜非分页缓冲池占用过高的人大概率是被 Windows 任务管理器吓到了想搜怎么降内存。这里的非分页指的是 NonPaged Pool也就是不能被交换到磁盘的内核内存它在任务管理器里看确实叫分页缓冲池或非分页缓冲池但这个分页和网页分页、数据库分页完全是两个概念。非分页缓冲池持续走高最常见的原因是驱动泄漏。排障思路一般是打开任务管理器查看非分页缓冲池数值如果持续增长不回落重点检查网卡驱动、杀毒软件驱动、虚拟网卡这类内核态组件用 PoolMon 这类工具可以定位具体是哪个标签在涨。这不是前端或者后端开发能直接解决的通常要运维或系统管理员介入。但你得知道这条路上搜到这个页面的人要找的不是 Bootstrap 4。6.2 所有驱动器总分页文件大小怎么调虚拟内存设置这又是一个系统层的分页文件指的是 Windows 虚拟内存的 pagefile.sys。调整方法不复杂右键此电脑→属性→高级系统设置→性能-设置→高级→虚拟内存-更改取消勾选自动管理所有驱动器的分页文件大小然后手动设置。一般建议由系统自动管理或者把大小设为物理内存的 1 到 1.5 倍同时保证 C 盘留有足够空间。但这里要强调改分页文件大小解决不了非分页缓冲池占用过高因为非分页池就是不能被换页的越调分页文件越没用。这两个系统问题经常被混在一起问实际操作时方向完全不同。6.3 桌面组件里的分页TableWidget 只是多了个渲染层TableWidget 分页是 Qt 开发里的常见需求。逻辑核心和前端完全一样记录总数、每页条数、当前页、总页数四个变量算完然后从数据源里切出当页的数据渲染到表格上。区别只在于渲染方式。网页用 DOM 节点或者 innerHTMLQt 用setRowCount和setItem。如果数据已经一次性加载到内存直接按数组切片就好如果是数据库来的同样需要后端分页 SQL 配合。很多人在这上面卡住是因为误以为 TableWidget 自带分页其实它只是一个展示容器和 Bootstrap 的.pagination一样都只是菜单。所以无论前端、后端还是桌面端分页问题的第一诊断步骤都一样先搞清楚数据是谁切的。切数据的地方才是真正管分页的地方界面组件永远只负责展示和交互。7. 实战中沉淀下来的几个分页细节7.1 翻页后页面状态和查询条件不能丢列表页往往带着搜索条件、排序条件、Tab 页签。分页翻页时这些条件必须跟着当前页一起提交否则就会出现第一页是筛选后的结果第二页突然变成全量数据的诡异现象。我常用的方案是把查询条件和页码一起放进 URL 查询参数?keywordxxstatus1page2size10。好处有三个刷新页面不丢状态可以直接复制链接给别人浏览器前进后退按钮天然支持。唯一要注意的是参数多了 URL 会变长超长查询条件比如多选 ID 列表就不要再放 URL 了改用 POST 提交页码和条件。7.2 加载状态与空数据的处理接口返回期间如果用户连续点击页码除了前面说的请求竞态还会出现按钮被快速点很多次的视觉抖动。最简单有效的办法是加载期间给分页容器加一层半透明遮罩或者给按钮加disabled请求结束后再恢复。空数据也很容易漏处理接口返回total 0时建议列表区域显示暂无数据分页组件整体隐藏。否则会出现一个只有上一页且上一页还是禁用状态的孤立按钮看着非常不专业。7.3 删除数据后当前页越界必测的经典场景这个坑我几乎在每个项目里都会遇到当前在第 5 页每页 10 条第 5 页刚好只有 1 条数据用户删掉了这一条。此时后端返回的 total 变成了 40总页数从 5 变成了 4前端还在请求第 5 页结果返回空。处理方式是在拿到新数据后判断如果current totalPages就把当前页改成totalPages再重新请求一次。这个逻辑一定要放在渲染之前否则会闪现一下空白列表。7.4 最后分享一个测试小技巧我上生产前习惯用假数据把分页边界全测一遍total 为 0、total 为 1、total 正好等于 pageSize、total 等于 pageSize 加 1、当前页在中间、当前页在第一页、当前页在最后一页。这些边界听起来基础但 80% 的分页 bug 都藏在这里。特别是total 正好等于 pageSize 时下一页会不会多出来一个可点击的按钮——很多算法只在 total 能被 pageSize 整除时出这种问题不特意测根本发现不了。写一个简单的生成脚本造 0 到 237 条不同数量的数据把上面每种情况跑一遍比上线后被用户点到再修复要省心得多。分页这东西原理不复杂但边界恶心人认真测一遍后面能安静很久。