十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Bootstrap4分页实战:从组件到SQL与打印页码全解析

Bootstrap4分页实战:从组件到SQL与打印页码全解析 做管理系统的人几乎没有哪一天能躲开分页。前端摆一排页码按钮后端写一条带 limit 的 SQL看起来各自安好可真把 Bootstrap4 分页从页面样式到接口数据、从 MySQL 查询到打印输出完整捋一遍你会发现坑比想象中多得多。这些年我接手过的后台项目里因为分页翻车的例子一只手数不过来第二页和第一页数据重复、翻到 50 页以后接口越来越慢、搜索完之后页码没重置、后端返回结构和前端对不上、列表打印出来页码在纸面上到处乱跑。这篇文章就专门围绕 Bootstrap4 分页把组件用法、前后端数据约定、MySQL 和 Oracle 的分页写法、MyBatis Plus 的常见坑、打印页码处理这些事一次说清楚。适合正在写后台管理系统、需要自己从零对接列表分页的初中级前端也适合后端同学想搞明白前端分页组件到底怎么接数据。1. Bootstrap4 分页组件先把样式和结构吃透1.1 三层嵌套结构为什么不是一层搞定Bootstrap4 的分页组件没有内置任何 JavaScript 行为它就是一套 CSS 样式。这一点很多人第一次接触时不适应——你拿到的只是长相不是行为。它的基础结构是这样的nav aria-labelPage navigation ul classpagination li classpage-itema classpage-link href#上一页/a/li li classpage-itema classpage-link href#1/a/li li classpage-item activea classpage-link href#2/a/li li classpage-itema classpage-link href#3/a/li li classpage-itema classpage-link href#下一页/a/li /ul /navnav 负责语义化让屏幕阅读器和搜索引擎知道这是一块导航区域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 标签直接换成 spanli classpage-item disabled span classpage-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-endnav classd-flex justify-content-center ul classpagination.../ul /nav图标分页也很常见上一页下一页用 « 和 » 代替文字。Bootstrap4 内置了 .page-link 的样式塞 HTML 实体或 SVG 都行li classpage-item a classpage-link href# aria-labelPrevious span aria-hiddentruelaquo;/span span classsr-only上一页/span /a /lisr-only 类给屏幕阅读器念出上一页纯视觉用户看到的是箭头。这个细节很小但无障碍评分和实际用户体验都会用到建议养成习惯。2. 分页数据流设计前后端参数与响应约定2.1 先统一分页语言再谈组件对接Bootstrap4 只是展示层真正让分页跑起来的是数据。我见过太多项目死在前端要 A 字段后端给 B 字段这种蠢问题上所以第一步一定是把前后端的分页语言谈拢。请求参数最常见的约定是 page 和 limit或 sizepage 从 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比如 page5、size20offset(5-1)*2080SQL 就是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;子查询只扫索引列不回表数据量少很多速度能提升一个量级。二是游标分页不要用页码改成传上一页最后一条记录的 idSELECT * 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 10Oracle 会返回空结果——因为它是在取第一条的时候判断 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-iftotalPages 1 aria-labelPage navigation ul classpagination mb-0 li classpage-item :class{ disabled: current 1 } a classpage-link hrefjavascript:; clickgo(current - 1)上一页/a /li li v-forp in pageList :keyp classpage-item :class{ active: p current, disabled: p ... } a v-ifp ! ... classpage-link hrefjavascript:; clickgo(p) {{ p }}/a span v-else classpage-link…/span /li li classpage-item :class{ disabled: current totalPages } a classpage-link hrefjavascript:; clickgo(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几个细节值得注意。一是 hrefjavascript:; 配合 click避免点击时页面跳转或滚动位置被重置。二是 v-iftotalPages 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 参数分页条件被忽略查询无 limit3自定义 SQL 的 count 是否异常报错被吞掉退化成不带分页查询4前端传参是否正常page/size 在后端被覆盖或丢失第一点前面已经讲过了注册 MybatisPlusInterceptor 的时候 DbType 要和数据库一致。第二点要看你的 mapper 接口PaginationInnerInterceptor 只对包含 IPage 参数的方法生效IPageOrder selectOrderPage(Page? page, Param(wrapper) WrapperOrder 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 索引、深分页策略和排查顺序来解决。希望这篇基于我的实际经验的内容能帮你少踩几个坑下次再做分页功能的时候心里能有个完整的图谱。
返回列表