
分页这套东西看着简单真上手全是细节先说结论Bootstrap5 的分页组件是我在项目里见过最“小而美”的UI模块之一。它不像轮播图要配一堆JS也不像表单校验那样需要来回调参数但你只要在一个真正的业务页面上把它接上数据、跑通交互就会发现里面藏着一大堆值得琢磨的细节。尤其是从后端接口拿数据这种场景光是一套分页样式就有“静态展示”“前端切页”“服务端查询”三种状态要处理更别提页码跳转、总数展示、边界空数据这些边边角角的 bug 点。这篇文章我把自己的实操过程完整拆开写出来从结构、类名、自定义、事件绑定到和后端接口联动再到最常见的几个“翻车”问题。无论你是刚接触 Bootstrap 的前端新手还是在公司里来回做后台管理系统的老手这套东西都能直接用得上。我尽量用大白话讲原理因为只有理解了分页组件为什么这么设计你踩过的坑才不会换个场景又踩一次。1. 分页组件的前世今生为什么 Bootstrap5 的分页值得单独聊聊1.1 从 Bootstrap 3 到 5一个 pagination 类名走了十年Bootstrap 的分页组件从第三代开始就有当时写法是ul.pagination li a样式上自带圆角、边框和 hover 高亮。到了 Bootstrap 4组件改成了ul.pagination li.page-item a.page-link把每个页码单元的类名和链接类名分开这套结构一直沿用到了 Bootstrap 5几乎没有大变动。这说明一件事Bootstrap 官方对分页组件的结构设计很克制也很成熟。它没有像栅格系统那样动不动就改 Col 的写法也没有像按钮那样从btn-default改成btn-secondary折腾一圈。所以你今天在 Bootstrap5 里写的分页结构跟三四年前的 Bootstrap4 项目几乎是通用的迁移成本极低。反过来说很多老项目从 Bootstrap3 升到 5分页部分基本只需要调整page-item/page-link这两个类名就能无缝对接。但结构稳定不等于没有坑。Bootstrap5 把组件库彻底改成了“无 jQuery 依赖”的纯 JS 架构这意味着你在处理分页事件时不能再指望.on(click)这种 jQuery 写法直接挂在翻页按钮上你得换一种思路去绑定事件或者手动触发特定逻辑。很多从老项目转过来的人第一反应就是“为什么我 $(#pagination a).click 不生效”其实不是分页组件变了而是整个事件体系变了。这个点放在后面的实操部分详细说。1.2 分页到底在解决什么问题从数据库到 UI 的完整链路聊到分页很多人第一反应就是“放 20 条数据点第二页再放 20 条”这只是最表层的理解。分页设计的本质其实是在有限的时间内把用户需要看的那一小段数据从全量数据池里捞出来并以可预期的顺序放到眼前。全量数据池在真实项目里可能是 50 万条销售记录可能是 2000 条文章列表也可能是 300 个城市配置项。无论数据多少底层数据库都没法一次性把全部记录返回到页面上——第一是网络开销和时间成本第二是浏览器渲染 DOM 的压力第三是用户根本记不住那么多信息。所以一个完整的分页链路涉及三层数据层数据库里的LIMIT offset, pageSize或者其他方言的写法决定“每页 20 条”具体是哪 20 条。服务层后端接口计算总数count(*)、总页数、当前页的数据列表再拼成一个带分页信息的结果集返回给前端。展示层前端拿到这些数据之后把页码按钮渲染成 Bootstrap5 的pagination结构同时处理好当前页高亮、上一页/下一页禁用、数据为空时显示“暂无数据”。很多人做分页只盯着展示层觉得“反正后端给我 page、size、total 三个字段就行”。真正做过对接之后你就会发现每个层都可能出问题后端返回的页码从 0 开始还是从 1 开始总数是 long 类型会不会精度丢失前端要展示“共 100 条”该怎么拿这个数字这些都是在“Bootstrap5 分页”这个关键词背后你真正需要面对的技术细节。2. HTML 结构拆解Bootstrap5 分页的骨架与核心类名2.1 一行代码快速搭建基础分页Bootstrap5 里的基础分页直接照抄官方文档就行nav aria-labelPage navigation example ul classpagination li classpage-item a classpage-link href# aria-labelPrevious span aria-hiddentruelaquo;/span /a /li li classpage-itema classpage-link href#1/a/li li classpage-itema classpage-link href#2/a/li li classpage-itema classpage-link href#3/a/li li classpage-item a classpage-link href# aria-labelNext span aria-hiddentrueraquo;/span /a /li /ul /nav这段代码看起来平平无奇但里面有三个容易被忽略的细节第一nav标签不是随便套的。官方推荐用nav包裹分页是为了让屏幕阅读器把整个区域识别为导航区块同时配合aria-label告诉辅助技术“这里是一组分页”。很多初学者直接把ul.pagination扔在页面上功能是有的但可访问性这块不达标。做政务类、企业后台类项目时无障碍访问往往是要过验收的硬指标这个标签值得养成习惯。第二aria-hiddentrue用在span上。它表示隐藏的左右箭头图标字体里的laquo;和raquo;对读屏软件不可见因为真正需要被朗读的是aria-labelPrevious和aria-labelNext这两段文本。如果两个都能读出来用户会听到重复的内容属于典型的“好心办坏事”。第三上一页/下一页的按钮结构和普通页码结构完全一样。它们都是li.page-item套a.page-link区别只在于内容里放了箭头字符。这意味着你做事件绑定时不需要单独区分“这是不是箭头按钮”只要针对page-link统一处理click就行后端返回的页码列表里这些按钮的实际页码值再单独通过自定义属性传过去即可。2.2 核心类名逐个拆解pagination、page-item、page-linkBootstrap5 分页组件一共用到几个核心类我拆开说.pagination是容器类作用是把内部的li变成水平排列、带有合适间距的列表。默认情况下它基于 flex 布局并且会去掉列表默认的圆点和缩进。这也是为什么你不能拿div去替代ul再加一堆span来模拟分页——除非你把 flex 布局和间距全自己实现一遍否则意义不大。.page-item是每一页的容器。它本身只负责定位和间距真正的样式重点集中在内部链接上。还有一个常见用法是通过它在父级li上添加active或disabled状态表示“当前页”和“不可点击页”。.page-link是分页组件的绝对主角。边框、圆角、字体颜色、hover 背景色、点击态阴影全部定义在这个类上。这个类你仔细观察会发现它其实长得非常像一个“后缀为链接的按钮”——有:hover、:focus、:active三个状态还有cursor: pointer的默认行为。下面用表格对比一下三个类各自的职责区域类名作用位置主要职责常用修饰类.pagination最外层ulflex 排列、取消列表默认样式.pagination-lg、.pagination-sm.page-item每一页的li管理间距、持有状态位.active、.disabled.page-link每个a标签视觉呈现、点击区域无独立修饰类靠父级状态控制这种三级结构的好处是你可以单独控制某一页的状态而不影响整体布局。比如第五页当前高亮你只需要在第五个li上加active其他li完全不用动CSS 选择器会通过li.active a.page-link自动改变链接的配色。2.3 尺寸、对齐与自定义样式实操Bootstrap5 在尺寸上做了很实用的两头控制“大的页面控制权”给了容器尺寸则用修饰类搞定。想要大号分页在ul上加pagination-lg想要小号分页加pagination-sm默认不加就是中等尺寸。对齐方式可以直接用普通的 flex 工具类比如在nav上加d-flex justify-content-center让分页居中或者justify-content-end让它靠右。但真正项目里经常遇到的问题是设计稿里分页样式和 Bootstrap 默认差异比较大。比如页码是方角而不是圆角比如当前页背景色是主题深蓝而不是 Bootstrap 的默认蓝比如页码之间间距更紧凑。这时候我的习惯是写一层自定义类覆盖而不是直接改 Bootstrap 源码例如.pagination-custom .page-link { border-radius: 0; border-color: #e2e8f0; color: #334155; padding: 0.375rem 0.75rem; } .pagination-custom .page-item.active .page-link { background-color: #4f46e5; border-color: #4f46e5; color: #fff; } .pagination-custom .page-link:hover { background-color: #eef2ff; border-color: #c7d2fe; color: #4f46e5; }然后把列表的ul写成ul classpagination pagination-custom就行。这个方法比改源码稳得多——Bootstrap 升级时不会把你的类名覆盖掉而且同一个页面里不同区域的列表可以拥有完全不同的分页风格互不影响。有一点得注意覆盖时不要写!important除非你非常确定。Bootstrap5 的样式优先级不算高大部分情况下只要你的选择器写得具体一点、加载顺序排在后边就能正常覆盖。动不动加!important不仅维护起来痛苦而且一旦以后想换主题色光删 important 就能删半天。3. 分页状态管理从静态展示到动态交互3.1 禁用状态与激活状态的处理细节一个分页组件有没有认真做看两个状态就够了active和disabled。active是当前页高亮逻辑上要求“当前页码的li同时存在active类”。举个例子假设当前在第 2 页总共有 5 页那么第二页码的li应该是li classpage-item active aria-currentpage a classpage-link href#2/a /li注意我加了一个aria-currentpage属性。这是 Bootstrap5 官方文档推荐的写法作用是告诉读屏软件“当前这个按钮代表用户所在的页面”。没有这个属性抓取无障碍报告的自动化工具会直接告警属于可访问性审查里最常见的扣分项。disabled则用在两处一处是第一页时“上一页”按钮要禁用第二处是最后一页时“下一页”按钮要禁用。实现上就是在对应li上添加disabled类li classpage-item disabled a classpage-link href# tabindex-1 aria-disabledtrue上一页/a /li这里有个小坑Bootstrap5.3 之前disabled状态通常还需要手动加tabindex-1和aria-disabledtrue才能把“禁用”表达完整。只有disabled类只改视觉比如去除 hover 背景、降低灰度和禁止点击手势但不改键盘逻辑——用户还是可以用 Tab 键聚焦到链接上按回车触发跳转。虽然新版样式已经有了pointer-events: none辅助防点不过真正的工程安全还是在事件处理函数里判断“如果父级有disabled就忽略本次点击”双保险最靠谱。3.2 页面变更事件原生 JS 和 jQuery 两种绑定方式分页组件如果只是静态展示那跟一张图片没有区别。真实场景是点击页码要去请求新数据然后把表格和分页状态一起刷新。先说事件绑定思路既然下一页、上一页、数字页码都是a.page-link最省事的方案是给ul.pagination绑定一个事件委托然后用closest(.page-item)去判断当前点击落在哪个页码上。这种方法不管后来怎么增删页码 DOM都不用重新绑定事件性能也好。document.getElementById(pagination).addEventListener(click, function (e) { const link e.target.closest(.page-link); if (!link) return; const pageItem link.closest(.page-item); // 点击了被禁用的按钮直接忽略 if (pageItem pageItem.classList.contains(disabled)) return; const page Number(link.dataset.page); if (Number.isNaN(page)) return; loadData(page); });如果你在一个老项目中工作页面上还留着 jQuery用 jQuery 写也差不多$(#pagination).on(click, .page-link, function () { const $item $(this).closest(.page-item); if ($item.hasClass(disabled)) return; const page $(this).data(page); loadData(page); });我的建议是如果你掌握原生写法就尽量别为了分页专门引 jQuery。Bootstrap5 本身不依赖 jQuery你再挂一个完全没必要。当然如果是接旧项目就不强行改了能用就行。关键是你的a.page-link里必须能取到页码。很多人喜欢直接在链接上塞href?page2然后让浏览器自己跳转这样也能工作但刷新整页体验比较差不符合现在主流单页局部刷新的习惯。我一般会在链接上加>const allData [...]; // 假设这里是从接口拿到的完整数组 const pageSize 10; let currentPage 1; function renderTable() { const start (currentPage - 1) * pageSize; const end start pageSize; const pageData allData.slice(start, end); // 渲染表格行 const tbody document.getElementById(tbody); tbody.innerHTML pageData.map(item tr.../tr).join(); // 根据当前页和总页数重新生成分页 renderPagination(); } function renderPagination() { const totalPages Math.ceil(allData.length / pageSize); let html ; for (let i 1; i totalPages; i) { html li classpage-item${i currentPage ? active : } a classpage-link href#>GetMapping(/api/orders) public Result pageOrders(RequestParam(defaultValue 1) int page, RequestParam(defaultValue 10) int pageSize) { PageOrder pageInfo orderMapper.selectPage(new Page(page, pageSize)); MapString, Object data new HashMap(); data.put(records, pageInfo.getRecords()); data.put(total, pageInfo.getTotal()); data.put(pages, pageInfo.getPages()); data.put(current, pageInfo.getCurrent()); return Result.success(data); }这里是个典型用法用类似 MyBatis Plus 的Page对象接收page和pageSize分页拦截器会在底层自动拼上LIMIT同时返回总数和总页数。当然page入参一定要做基本校验pageSize超限就强制改回默认值防止有人利用大pageSize一次拉全量数据把接口拖垮。再看前端实现核心是loadData(page)这个函数let currentPage 1; const pageSize 10; async function loadData(page) { const res await fetch(/api/orders?page${page}pageSize${pageSize}); const json await res.json(); const { records, total, pages } json.data; renderTable(records); renderPagination(page, pages); renderSummary(page, pageSize, total, pages); } function renderPagination(current, pages) { const ul document.getElementById(pagination); let html ; // 上一页 html li classpage-item${current 1 ? disabled : } a classpage-link href#>function getPageItems(current, pages) { const items new Set([1, pages, current, current - 1, current - 2, current 1, current 2]); const sorted [...items].filter(p p 1 p pages).sort((a, b) a - b); const result []; let prev 0; for (const p of sorted) { if (prev p - prev 1) result.push(...); result.push(p); prev p; } return result; }用的时候把result里的数字依次渲染成li.page-item把...渲染成一个带disabled和span的普通列表项既美观又不会抢焦点。这个方法我一直用在各个项目里没有出过问题。5. 常见分页问题与排查实录那些让我加班到深夜的坑5.1 最经典的坑总页数算错、页码错位后端给的是total而不是pages时前端就得自己做一次换算。这一步出错概率极高。错误写法是这类const pages Math.floor(total / pageSize); // 你有 21 条数据pageSize10Math.floor 得 2实际应该是 3 页为什么会算错因为少处理了“有余数就加一页”的情况。正确写法是Math.ceil(total / pageSize)或者更稳妥地写成Math.floor((total pageSize - 1) / pageSize)。这两种算法都能处理 21 变 3 页的情况。我建议你把这一行当成“模板代码”每次写分页的时候直接抄正确版本不要每次现场推导。页码错位则是另一个典型问题后端接口的page从 0 开始前端从 1 开始又没有做加一减一处理。后端返回“第 0 页”前端傻乎乎渲染成“第 1 页”然后点了第 2 页实际请求第 1 页的数据永远卡在第二页的展示状态。排查这种问题建议在接口日志里直接打印请求参数对比前端>function updateUrl(page) { const url new URL(window.location.href); url.searchParams.set(page, page); window.history.replaceState(null, , url.toString()); } function getPageFromUrl() { return Number(new URL(window.location.href).searchParams.get(page)) || 1; }这样刷页面后初始化时先调用getPageFromUrl()就能恢复页码。这里有个配套的坑浏览器前进后退时popstate事件里的页码状态也要重新加载数据否则 URL 变了表格数据不跟着变用户会觉得按钮坏了。5.3 样式不生效和 Bootstrap 版本冲突排查分页不显示样式最常见的原因有三个没有引入 Bootstrap5 的 CSS。别笑真有人把 JS 文件引了CSS 忘了引或者引错了 CDN 路径页面结构没问题但完全没有 Bootstrap 的样式。和别的 UI 库的样式冲突。比如同时引入了 Element UI 或者自定义的pagination全局样式覆盖了 Bootstrap5。排查方式是用开发者工具看.pagination和.page-link上实际生效的样式来源看到底是谁覆盖了谁。类名拼错。常见是把page-item写成pageItem或者把page-link写成pageLink。Bootstrap5 的类名都是中划线风格跟 JavaScript 的驼峰命名习惯不一样连续写 JS 的人特别容易顺手带走。排查时用浏览器 DevTools 检查元素看 Elements 面板里的 Computed 样式快速找到是哪个选择器覆盖了样式。这个基本功建议早点练熟因为分页样式问题排查起来比绝大多数 CSS 兼容问题都快。5.4 从“分页失效”热搜词发散MyBatis Plus 分页失效的排查思路写在最后因为搜索“分页”相关热词时经常能看到“mybatisplus分页失效”这个问题。它虽然和后端框架有关但其实和前端分页组件联调息息相关——后端分页一旦失效接口返回的就不是当前页数据前端拿到的永远是全量数据发到页面上的效果就是“分页看起来没用点哪里数据都一样”。我遇到过的 MyBatis Plus 分页失效原因一般是这几类没用分页拦截器只引入了mybatis-plus-boot-starter但没有配置PaginationInnerInterceptor导致Page对象虽然传了SQL 却没有拼接LIMIT。多个数据源时拦截器路由不对分页拦截器配在了一个不生效的拦截器链里。插件顺序问题多租户插件、分页插件、乐观锁插件之间顺序不对分页 SQL 在拼接阶段就被截断了。自定义 SQL 里用了Page但是返回值写错了比如PageOrder方法里return orderMapper.selectList(...)返回的不是IPage而是List分页信息拿不到。排查思路也很机械打开 MyBatis 的 SQL 日志看控制台实际执行的 SQL 到底有没有LIMIT子句。没有LIMIT就是拦截器没生效有LIMIT但结果数据不对再去检查page偏移。这个思路同样适用于 Oracle、SQL Server 等其他数据库的分页写法你能确认“SQL 层已经分页了”前端展示层才有讨论的意义。最后分享一点我自己的体会分页这个东西单独拎出来不到一百行代码但它横跨了数据库、后端接口、前端渲染三层逻辑。我在每个项目里几乎都会见到的场景是后端同事觉得自己把数据返回就完事了前端同事觉得后端应该把所有分页字段都算好两边一配合就互相甩锅。所以后来我在项目里养成一个习惯接口文档里如果涉及分页我必定会写清楚 page 从几开始、pageSize 最大值是多少、total 是 int 还是 long、pages 表示总页数这四件事写明白联调基本不会吵架。做前端分页组件时我也慢慢体会到一个道理组件本身只是一个视觉框架真正的分页设计核心永远是“状态”是当前页、总页数、总数、加载态、空数据态这些要不要算得清清楚楚。把这些状态先想明白再回头用 Bootstrap5 拼 HTML你会发现一切都顺理成章。有空的话你也可以把分页组件的渲染逻辑封装成独立函数甚至抽成一个小工具后续不管 React、Vue 还是原生项目都可以快速拿过来复用。