ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用QML复刻Element UI分页组件:Qt桌面应用的组件化实践

用QML复刻Element UI分页组件:Qt桌面应用的组件化实践

简介:一份参考Element UI分页交互风格实现的Qt分页组件源码,面向熟悉C++/Qt、需要在桌面应用中集成翻页控件的开发者,适合数据表格、列表浏览及后台管理端的桌面端适配。资源共2个文件,分别对应组件实现与头文件,压缩包仅3KB,代码精简无冗余依赖。组件封装了PageNavigator类,包含当前页、总页数、每页条目数等核心属性,提供上一页/下一页、跳转至指定页、首尾页快捷操作等槽函数,并通过pageChanged信号与外部界面解耦;同时预留QSS样式入口,可轻松复现Element UI的简洁观感。已有1080人学习下载。借助这份小体积样例,可快速掌握自定义QWidget封装、信号槽通信、QSpinBox与QComboBox联动等Qt实用技巧,稍作扩展即可应用到真实项目。

1. 这个Qt分页组件能干什么:把Element UI的分页体验搬进桌面应用

如果你的 Qt 桌面应用需要一套分页组件,又正好看着 Element UI 的 el-pagination 流口水,那这份资源就是答案。它用 QML + JavaScript(开发语言层面是 Qt Quick 的声明式语法)复刻了 Element UI 分页组件的主要能力:layout 任意排列、带省略号的页码、快速跳转、每页条数下拉,前端组件的属性、事件、样式都可用类似方式调整。适合两类人:一类是 Qt 桌面开发,想在项目里引入组件化思路;另一类是熟悉前端 Vue/Element UI、需要把既有原型平移到 QML 场景的开发者。读完这篇笔记,你能拿到可以直接编译运行的分页组件源码,并知道怎么改造成自己业务里的黑匣子。

2. 技术栈与组件设计:用 QML + JavaScript 复刻 el-pagination 的 API 层

2.1 为什么选 QML 而不是 Widgets

选 QML(Qt Quick)而不是传统 Widgets,核心原因是组件化表达能力和 JavaScript 生态。Widgets 里做分页控件,通常是在 QWidget 里手动 layout、连接 clicked 信号,每个按钮都是独立对象,状态管理依赖 C++ 撑着。而 QML 的声明式 UI 写法本身就接近 Vue 模板:一个 Item 包含属性、信号、嵌套子组件,界面状态通过属性绑定自动刷新。前端背景的人看这套组件会非常亲切,改样式也基本不用碰 C++。

另一个原因是 JavaScript 逻辑跑在 QML 内部,不需要为分页这种轻逻辑跨 QObject 通信。页码窗口裁剪、layout 渲染顺序、跳页输入校验,这些在 QML 里用几十行 JS 就能写完。虽然也可以把分页算法写在 C++ 里再暴露给 QML,但从组件复用角度看,纯 QML + JS 的实现更贴近“下载一个 .qml 文件就能用”的前端组件习惯;如果你所在的是大规模 C++ 工程,后续也可以把算法层整体迁到 C++,接口保持不变。这个项目把重心放在“组件库化、可复用”这个目标上。

再说说为什么要参考 Element UI 而不是自己拍脑袋设计。el-pagination 在 Vue 生态里被大量项目验证过,它的两个设计很值钱:layout 字符串驱动布局,以及 pager 省略号算法。这两种能力恰好都能在 QML 里自然表达——layout 对应 Repeater 按顺序加载子组件,省略号对应数组裁剪。把别人验证过的交互逻辑平移过来,能少踩很多不知道从哪里冒出来的交互坑。

2.2 对齐 el-pagination 的属性与事件

组件对外 API 尽量照搬 Element UI 的命名,让熟悉前端的同事接手时不用重新学一遍。属性和原型的对应关系如下:

Element UI 属性本项目 QML 属性类型说明
current-pagecurrentPageint当前页,从 1 开始
page-sizepageSizeint每页条数
totaltotalint数据总量
page-countpageCountint只读,由 total 与 pageSize 计算
pager-countpagerCountint页码按钮数量,默认 7
layoutlayoutArray控制项显示顺序,如 total、prev、pager
page-sizespageSizesArray每页条数下拉选项

事件上,current-change 对应change(currentPage),size-change 对应sizeChange(newPageSize, currentPage)。设计时我把“状态变化”和“用户操作事件”分开:currentPage 属性变化只负责刷新组件内部外观,change 信号只在用户真正点击、跳转时发射一次。这个区分在后面第 5 章的信号重复触发坑里会重点展开,也是整个组件不容易踩坑的关键。

2.3 目录结构

pagination-qt/ └── Pagination.qml # 组件主体:属性、信号、算法、渲染 └── pager-utils.js # 纯函数:页码序列生成与边界裁剪 └── demo/ └── main.qml # 演示页:模拟列表 + 分页联动 └── main.cpp # Qt Quick 入口 └── pagination.pro # qmake 工程文件

把 pager-utils.js 独立出来,是为了让页码算法可以在 Node 里单独验证,第 6 章会用断言向量锁住它,防止后续改样式把算法改坏。Pagination.qml 只负责组件边界和布局,内部调用 utils 的函数;demo 是一个模拟 86 条数据的列表,切页时重新填充 ListModel,方便直接观察分页效果。main.cpp 是标准 Qt Quick 启动入口,不做额外逻辑,保证跨平台编译简单。

3. 核心模块拆解:分页请求从点击到刷新的完整链路

3.1 对外接口:属性、信号与跳转函数

组件最外层是 Item,直接暴露与 el-pagination 对齐的属性,并定义两个信号。所有页码跳转都收敛到jump()函数,不在按钮 onClicked 里直接改 currentPage,这是整个组件行为可控的起点。

import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 Item { id: root property int currentPage: 1 property int pageSize: 10 property int total: 0 property int pagerCount: 7 property var pageSizes: [10, 20, 50, 100] property var layout: ["total", "prev", "pager", "next", "jumper"] readonly property int pageCount: total > 0 ? Math.ceil(total / pageSize) : 1 signal change(int page) signal sizeChange(int newPageSize, int page) function jump(page) { var target = Math.max(1, Math.min(page, root.pageCount)) if (root.currentPage === target) { return } root.currentPage = target root.change(root.currentPage) } property var pagerItems: [] function updatePagerItems() { root.pagerItems = buildPager(root.pageCount, root.currentPage, root.pagerCount) } onCurrentPageChanged: updatePagerItems() onPageCountChanged: updatePagerItems() Component.onCompleted: updatePagerItems() }

jump 先做 clamp 到[1, pageCount],再判断目标页没变时直接 return,避免无意义的信号。change 信号只在 jump 里发射,onCurrentPageChanged只负责重建 pagerItems。readonly pageCount 用total > 0判断,total 为 0 时兜底为 1,防止出现 0 页或 NaN 页。这里故意让外部拿到 total 后只需赋值,不用关心 pageCount 怎么算。

3.2 页码序列算法:省略号是怎么算出来的

这是分页组件最有技术含量的部分。el-pagination 在页码总数超过 pager-count 时,会保留第 1 页和最后一页,把当前页附近的页码作为窗口显示,窗口两边用省略号占位。下面这个函数实现了同样的策略,并且不依赖任何 QML 对象,方便单独测试。

// pager-utils.js function buildPager(pageCount, currentPage, pagerCount) { var count = Math.max(5, pagerCount) if (!pageCount || pageCount <= 1) { return [{ type: "page", label: 1 }] } if (pageCount <= count) { var all = [] for (var i = 1; i <= pageCount; i++) { all.push({ type: "page", label: i }) } return all } var half = Math.floor(count / 2) var left = currentPage - half var right = currentPage + half if (left < 1) left = 1 if (right > pageCount) right = pageCount var items = [{ type: "page", label: 1 }] var leftMore = left > 2 var rightMore = right < pageCount - 1 if (leftMore) { items.push({ type: "more", label: "..." }) } for (var j = left; j <= right; j++) { if (j >= 2 && j <= pageCount - 1) { items.push({ type: "page", label: j }) } } if (rightMore) { items.push({ type: "more", label: "..." }) } items.push({ type: "page", label: pageCount }) return items } if (typeof module !== "undefined") { module.exports = { buildPager } }

逻辑说明:pageCount 小于等于 pagerCount 时干脆全部渲染,不做省略号;否则保留第 1 页和最后 1 页,把以当前页为中心的窗口页渲染在中间,窗口不足时贴住边界。leftMore 和 rightMore 分别决定左右是否出现省略号。参数 pageCount、currentPage、pagerCount 都由组件属性传入,函数本身没有 QML 依赖,所以能在 Node 里直接跑断言。这个窗口算法在极端贴近边界时按钮数量可能会少于 pagerCount,常见前端分页库也有类似表现,对业务没什么影响;后续要严格固定按钮数量,基于这个函数再裁剪 width 即可。

3.3 layout 渲染:像写 Vue 模板一样编排控制项

layout 值是字符串数组,顺序决定控件从左到右的排列。实现方式是外层 Row 套一个 Repeater,Repeater 的 delegate 是一个 Loader,根据 switch 匹配结果加载对应的组件。

Row { id: layoutRow spacing: 12 layoutDirection: Qt.LeftToRight Repeater { model: root.layout delegate: Loader { property string controlType: String(modelData).trim() width: item ? item.implicitWidth : 0 height: item ? item.implicitHeight : 0 sourceComponent: { switch (controlType) { case "total": return totalComponent case "prev": return prevComponent case "pager": return pagerComponent case "next": return nextComponent case "jumper": return jumperComponent case "sizes": return sizesComponent default: return undefined } } } } }

把String(modelData).trim()放在 switch 之前,是为了处理从配置文件或外部 JSON 传入带空格、换行的字符串。如果不做这一步,“ pager ”和“pager”会匹配不上,控件直接不渲染,这种问题在视觉上很难排查。Loader 的 width 和 height 取实际子对象的 implicitWidth / implicitHeight,确保 Row 能计算出正确布局宽度。

页码列表本身也是一个 Row 套 Repeater,数据源是 pagerItems:

Component { id: pagerComponent Row { spacing: 4 Repeater { model: root.pagerItems delegate: Button { text: model.label checkable: model.type === "page" checked: model.type === "page" && model.label === root.currentPage enabled: model.type === "page" onClicked: { if (model.type === "page") { root.jump(model.label) } } } } } }

这里的参数说明:checkable 只对数字页码生效,省略号项是 disabled 状态,视觉上是置灰的,避免用户点“...”后没反应以为是按钮坏了。最终点击都走 root.jump,所以 currentPage 的越界保护、信号发射都集中在 3.1 节里,delegate 不需要知道跳转细节。Button 用的是 QtQuick.Controls 2,自带 hover 和 focus 外观,省去手写鼠标事件。

3.4 事件处理:prev、next、jumper 与 sizes

上一页和下一页按钮只是把 currentPage 加减 1 再走 jump,唯一要注意的是在边界时按钮应该禁用,而不是点了后没反应。跳页输入框用 TextField + IntValidator,按下回车时解析输入并调用 jump。

Component { id: jumperComponent Row { spacing: 4 Text { text: "跳至" } TextField { width: 48 validator: IntValidator { bottom: 1; top: root.pageCount } onEditingFinished: { var value = parseInt(text, 10) if (value >= 1 && value <= root.pageCount) { root.jump(value) } text = root.currentPage } } Text { text: "页" } } }

编辑结束后做两件事:合法数字走 jump,然后无论是否合法都把输入框重置成当前页,这样用户输错可以看到输入被纠正,而不是留下一个错误数字等下一次提交。

每页条数选择用 ComboBox:

Component { id: sizesComponent ComboBox { model: root.pageSizes onActivated: { var newPageSize = parseInt(currentText, 10) root.pageSize = newPageSize root.sizeChange(newPageSize, root.currentPage) } } }

换 pageSize 后 currentPage 是保持还是重置为 1,不同业务有不同习惯。这个组件保持当前页,把决定权交给外部监听 sizeChange 的代码。如果后端分页接口不支持从非第 1 页开始,外部直接pager.currentPage = 1再请求即可。

4. 运行与验证:从命令行到 Qt Creator 的启动实录

4.1 用 qmlscene 快速启动

最快验证组件的方式是 qmlscene,不需要编译。进入项目目录后执行:

cd pagination-qt qmlscene -I . demo/main.qml

参数-I .把当前目录加入 QML import 路径,这样 demo/main.qml 里可以通过相对路径找到 Pagination.qml。能弹出窗口并且看到“共 86 条”的 total 文本,就说明组件加载成功。如果提示module "QtQuick" version 2.15 is not installed,说明本机 Qt 版本低于 5.15,把两个 import 里的版本号改成 2.12 或 2.6,例如import QtQuick 2.6。大部分老版本 Qt 5 都带这些模块,不需要额外安装。

4.2 用 qmake 工程跑:Qt Creator 的完整路径

qmlscene 适合快速预览,真正在业务工程里还是要放到 Qt Creator 里编译调试。qmake 工程文件很简洁:

QT += quick qml CONFIG += c++11 SOURCES += main.cpp RESOURCES += qml.qrc

main.cpp 就是标准的 QGuiApplication + QQmlApplicationEngine,加载qrc:/demo/main.qml。这里有个容易翻车的地方:qml.qrc 必须把 Pagination.qml、pager-utils.js、demo/main.qml 全部登记进去,Qt Creator 部署时只认 qrc 里列出的文件,漏掉 pager-utils.js 会导致组件运行时报“无法加载脚本”但界面还能弹出来,特别难发现。如果只做本地调试,也可以不建 qrc,直接把 qml 文件放工程目录,用相对路径加载,省掉资源编译环节。

4.3 验证矩阵

启动成功后,按下面这份验证矩阵快速过一遍功能,确保改动没有破坏核心行为:

操作预期结果关注点
启动 demo列表显示第 1 页 10 条,total 显示 86初始 currentPage 为 1
点击第 3 页页码 3 高亮,列表内容切换pagerItems 是否正确重建
jumper 输入 5 回车跳到第 5 页,页码 5 高亮输入校验和 jump clamp
每页条数改为 20sizeChange 触发,列表刷新,pageCount 变为 5ComboBox 的 currentText 解析
total 改为 0分页器只剩 1 个页码,prev/next 置灰pageCount 兜底逻辑生效

验证时把 Qt Creator 的调试输出打开,因为没有在组件里埋日志的话,信号发没发出去只能靠界面表现猜。我在组件里临时加过console.log("change", page),等所有验证通过后再删掉。

5. 常见坑与排查:QML 分页组件的五个翻车现场

5.1 total 为 0 时所有按钮全部失效

现象:接口返回空列表,total = 0,分页器一片灰,连页码都没了。原因:pageCount = Math.ceil(0 / pageSize)算出 0,buildPager 里 for 循环执行 0 次,pagerItems 是空数组。解决:在 pageCount 计算时做兜底,total > 0 ? Math.ceil(total / pageSize) : 1,让空数据也显示一个不可跳转的页码,视觉上更完整。buildPager 里也加了pageCount <= 1的直接返回,双保险。

5.2 currentPage 越界后被反复拉回

现象:外部把 currentPage 赋值为 100,界面先跳到 100,又立刻被拉回最大值,像在抽搐。原因:有些封装会在onCurrentPageChanged里做越界修正,赋值一次后触发回调,回调又赋值,形成二次变更。解决:所有越界修正集中在 jump 和属性绑定里,外部不要监听 currentPage 变化去改它;如果必须监听,用标志位挡住重入:

property bool syncing: false onCurrentPageChanged: { if (syncing) return syncing = true if (currentPage > pageCount) currentPage = pageCount syncing = false }

5.3 省略号按钮渲染成可点击状态

现象:鼠标移到“...”上有 hover 效果,点击却没反应,看起来像组件坏了。原因:delegate 里把所有 item 都当成 Button,省略号也拿到 enabled = true。解决:pager 的 delegate 里给“...”设置enabled: false,同时checkable: false,让它从视觉到交互都明确“不可点”。如果业务要求点击省略号跳半页,那是另一个功能,在第 6 章会给改造方案。

5.4 layout 字符串匹配翻车:空格和大小写

现象:layout 配置成["total", " pager ", "next"],pager 没渲染,其他控件正常。原因:switch 是严格字符串比较," pager "不等于"pager"。解决:在 Loader 里先String(modelData).trim()再 switch,大小写问题也在同一层统一toLowerCase()。从 JSON 或配置文件读 layout 时常带隐藏换行,trim 能一并处理。这个坑很难发现,因为布局少一个控件并不会报错。

5.5 点击页码导致列表刷两次

现象:点一下第 3 页,网络请求发了两次,列表闪两下。原因:jump 里改了 currentPage 后发射 change,而onCurrentPageChanged里又发射了一次 change。信号职责没有分开。解决:change 只在 jump 里发射一次,onCurrentPageChanged只负责更新 pagerItems 和按钮高亮,不做事件上报。这个约定写进代码注释,后续接手的人再往 onCurrentPageChanged 里塞逻辑时能看到提示。

6. 二次开发:把分页组件塞进你的业务工程

6.1 让省略号具备前后半页快速跳跃

默认省略号是置灰的,Element UI 里点击“...”会前后跳半屏。改造方法很简单:给省略号项设置 enabled = true,但点击时不走 jump 而是跳pagerCount - 2页:

onClicked: { if (model.type === "more") { forwardJump() } else if (model.type === "page") { root.jump(model.label) } } function forwardJump() { root.jump(root.currentPage + root.pagerCount - 2) }

左右两个省略号可以共用一个函数,根据当前相对于中间页的位置判断跳前还是跳后,或者直接在数据模型里给 more 项加一个step字段,左边为负、右边为正,渲染层无脑累加。

6.2 用纯函数 + 断言向量锁住页码算法

pager-utils.js 抽出独立函数后,我在业务里每次改版都会先跑一遍断言向量,用 Node 直接执行:

node tests/pager.test.js

测试文件里不依赖 Qt,只需要常规断言:

const assert = require("assert") const { buildPager } = require("../pager-utils.js") var labels = buildPager(10, 5, 7).map(function (x) { return x.label }) assert.ok(labels.indexOf(1) >= 0, "must contain first page") assert.ok(labels.indexOf(10) >= 0, "must contain last page") assert.ok(labels.indexOf(0) === -1, "must not contain page 0") labels.forEach(function (label) { assert.ok(label === "..." || (label >= 1 && label <= 10), "invalid page: " + label) }) console.log("pager ok")

这套向量不锁具体样式,只锁合法性:包含首尾、没有 0 页、所有数字都在有效范围内。从那以后,我每次新增分页功能都强制先跑一遍断言,再往界面上绑定属性,顺序反了就容易把排期烧在玄学上。

6.3 对接真实后端接口

把 demo 里的假数据换成网络请求,只需要在外部监听组件的两个信号:

Pagination { id: pager total: fakeApi.totalCount onChange: function (page) { fakeApi.fetchPage(page, pager.pageSize).then(function (rows) { listModel.clear() rows.forEach(function (row) { listModel.append(row) }) }) } onSizeChange: function (newPageSize, page) { pager.currentPage = 1 fakeApi.fetchPage(1, newPageSize).then(function (rows) { listModel.clear() rows.forEach(function (row) { listModel.append(row) }) }) } }

换 pageSize 时先重置到第 1 页,是大多数后端分页接口的硬性要求,否则当前页大于新 pageCount 时请求会拿不到数据。Pagination.qml 本身不关心数据来源,你传 total,它负责算页码;你监听 change,它把用户意图交出来。组件和业务逻辑之间就这一层契约,后续把整个分页器换成 C++ 实现,外部代码也不用动。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进