ARTICLE DETAIL

资讯详情

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

View UI Table 与 Page 组件分页实战:前端分页与服务端分页完整指南

View UI Table 与 Page 组件分页实战:前端分页与服务端分页完整指南 1. 分页到底在解决什么问题先想清楚场景再动手先讲个现象。我见过不少刚接触 View UI以前叫 iView的开发者拿到 Table 和 Page 组件后第一件事就是照着文档抄一遍代码抄完发现表格能显示数据、页码也能点以为万事大吉。结果一上生产环境就出问题数据量到了几千条页面直接卡顿、翻页时请求重复发送、搜索之后页码还停留在旧位置、删掉当前页最后一条数据后表格变成空白页……每一个都是线上事故级别的体验问题。其实分页这件事本质上不是把数据切开一页页展示那么简单它背后有两层逻辑一是减少单次渲染的数据量二是把展示状态和业务状态解耦。你不把这两层想清楚写出来的分页代码永远是在打补丁。用 View UI 的 Table 和 Page 组合做分页是 Vue 生态里很经典的一套方案。Table 负责展示数据Page 负责提供交互入口两者通过 data 和事件串联起来。这套组合在我的项目里用了很多年今天把完整思路、踩过的坑、以及封装成通用组件的方案一次性写完希望能帮你省掉一些不必要的折腾。先说基础概念方便后面统一语言current / currentPage当前页码从 1 开始。pageSize / page-size每页显示条数。total数据总条数。dataTable 当前页实际要渲染的数据数组。on-changePage 组件页码变化时触发的回调函数。2. 前端分页还是服务端分页这个选择题决定代码结构很多人在第一步就选错了方向。我接到过的分页相关咨询里至少有一半人分不清前端分页和服务端分页该在什么场景下用。这里直接给结论数据量小几百到一千条以内且一次性从接口拿全量数据用前端分页。数据量大几千条以上或者接口本身就支持分页参数用服务端分页。什么情况下前端分页会出问题举个例子一次接口返回 5000 条数据你把它们全塞进 Table虽然 Table 会一次性渲染出所有行但 Page 组件的页码计算、浏览器 DOM 节点数量、重排重绘带来的性能消耗都会让你在低端设备上体验到明显的卡顿。更严重的是如果表格列的字段很多5000 行乘以 10 列那就是 5 万个单元格浏览器直接崩给你看。服务端分页则是把当前页显示哪些数据这个责任交给后端前端只告诉后端我要第几页、每页多少条后端返回当前页的数据和总数。这种方式的优点是前端性能压力小缺点是每次翻页都要等网络请求交互上必须处理好 loading 状态和错误重试。我个人的判断标准很简单如果接口返回的数据总量超过 1000 条或者接口本身已经给了 page 和 size 参数就毫不犹豫走服务端分页。分页的本质是数据访问的边界控制这个边界越靠前系统的可扩展性越好。下面这张表是我选型时的常用参考标准供你直接抄作业对比维度前端分页服务端分页数据量建议1000 条以内任意数量尤其适合大数据量接口请求次数1 次页面加载时每次翻页 1 次交互响应速度快无网络等待慢依赖网络延迟加载状态处理基本不需要必须处理后端改动无需要支持分页参数维护成本低中推荐场景数据字典、配置列表、报表预览用户列表、订单列表、操作日志3. Table 和 Page 的基础组合从一份能跑通的代码说起确定了分页方式后我们直接写代码。这里先用一份前端分页的完整示例来拆解核心逻辑因为它的代码链路最短最适合理解 Table 和 Page 的协作关系。假设我们从接口拿回一份 200 条数据的用户列表每页显示 10 条需要在表格底部用 Page 组件控制翻页。先看完整代码template div classuser-list Table :columnscolumns :datapageData stripe/Table Page :totalmockData.length :currentcurrentPage :page-sizepageSize show-total on-changehandlePageChange stylemargin-top: 16px; text-align: right / /div /template script export default { name: UserList, data() { return { columns: [ { title: ID, key: id, width: 80 }, { title: 姓名, key: name, minWidth: 120 }, { title: 邮箱, key: email, minWidth: 200 }, { title: 创建时间, key: created_at, minWidth: 180 } ], // 模拟全量数据实际场景中来自接口 mockData: [], currentPage: 1, pageSize: 10 } }, computed: { pageData() { const start (this.currentPage - 1) * this.pageSize const end start this.pageSize return this.mockData.slice(start, end) } }, created() { this.loadMockData() }, methods: { async loadMockData() { // 模拟接口请求 const res await fetch(/api/user/list) this.mockData await res.json() }, handlePageChange(page) { this.currentPage page } } } /script这套代码的核心逻辑只有三步全量数据存在 mockData 里作为分页的数据源。computed 里的 pageData 负责切页根据当前页码和每页条数动态算出当前页该显示哪些数据。Page 组件的 on-change 事件负责更新 currentPagepageData 随之重新计算表格自动刷新。这里有个关键设计Table 绑定的是pageData而不是mockData。这个slice的动作就是前端分页的核心它保证了 Table 每次只拿到 10 条数据而不是把 200 条全部渲染出来。这样做的直接好处是 DOM 节点数量大幅减少页面重排开销明显下降。但如果你只看到了这一步那还没入门。我前两年接手过一个项目同事把这段逻辑写成了handlePageChange(page) { this.mockData this.mockData.slice(page * 10) // 错误写法 }他以为翻页就是把数据切掉一段结果翻到第 3 页后每翻一次数据就少掉一部分再往回翻全乱了。正确做法永远是保留全量数据源用计算属性去切当前页而不是去修改数据源本身。数据源是全集当前页是视图这个边界不能破。4. 服务端分页的完整链路参数、loading、竞态控制一次说清服务端分页是实际业务中占比最高的形态因为它能真正解决大数据量场景下的性能瓶颈。我用一个订单列表的案例来讲透完整链路。4.1 请求参数的组装服务端分页的第一件事是把分页参数传给接口。View UI 的 Page 组件在页码变化时触发on-change这个回调参数就是新的页码我们需要把它同步给后端。这里要注意字段名对齐的问题。不同后端团队定义的分页参数不一样有的用pageNum、pageSize有的用page、limit还有的用current、size。建议前端在封装请求层时统一做一层参数转换不要在每个页面里散落各种字段名。我在项目中通常这样处理methods: { buildPageParams() { return { page: this.currentPage, // 页码从 1 开始 pageSize: this.pageSize, // 每页条数 // 其他查询条件... keyword: this.keyword, status: this.status } }, async fetchOrderList() { this.loading true try { const params this.buildPageParams() const res await getOrderList(params) this.tableData res.data.records || [] this.total res.data.total } catch (error) { // 统一错误处理 this.$Message.error(订单列表加载失败) } finally { this.loading false } } }这里有个非常容易踩的坑接口返回的字段名不一致。有的后端返回{ list: [], totalCount: 100 }有的返回{ rows: [], total: 100 }还有的包装成{ data: { records: [], total: 0 } }。建议把接口返回结构统一收敛到一个normalizeResponse方法里把字段名都转换成语义明确的内部结构。4.2 页码变化时的完整逻辑服务端分页的handlePageChange不能像前端分页那样只更新一个页码变量它要触发重新请求数据async handlePageChange(page) { if (page this.currentPage) return this.currentPage page await this.fetchOrderList() }这段代码看似简单实际生产环境里还会遇到更多情况我展开讲三个我反复踩过的坑。4.3 竞态问题快速翻页时响应顺序会错乱这是我在真实项目中遇到的最隐蔽的 bug。用户快速点击下一页、下一页、下一页前端会发出三个并发的异步请求。由于网络延迟不同先发的请求未必先返回。如果最后一次点击的响应先回来了把表格数据更新为第 3 页的内容但紧接着第一次点击的响应才姗姗来迟又把表格覆盖成第 1 页的内容——而此时的页码按钮却停留在第 3 页。数据和页码错位用户看到的就是页码是 3表格内容却是第一页的灵异现象。解决方式有几种我推荐用一个简单的请求序号标记methods: { async fetchOrderList() { const requestId this.requestSequence // 每次请求自增 this.loading true try { const res await getOrderList(this.buildPageParams()) // 如果已经不是最新的请求直接丢弃这次结果 if (requestId ! this.requestSequence) return this.tableData res.data.records this.total res.data.total } finally { if (requestId this.requestSequence) { this.loading false } } } }原理就是每次请求前用一个自增序号标记这是第几个请求当响应回来时如果当前的序号已经不是自己发出的那次了说明有更新的请求已经发出这次旧响应直接丢弃不再更新数据。这个方法比axios的CancelToken更轻量也不需要额外引入库已经足够应对大部分业务场景。4.4 loading 状态的正确打开方式服务端分页必须处理 loading因为翻页时有一段网络空白期。不处理的话用户翻页后会看到表格内容停留在旧数据上容易误以为是不是自己点错了。View UI 的 Table 组件自带一个loading属性传入布尔值即可Table :columnscolumns :datatableData :loadingloading/Table翻页的时候建议使用 Page 组件的on-change回调里第一时间把 loading 置为 true并在请求 finally 中置为 false。这样可以保证翻页交互期间表格上方出现 loading 遮罩避免用户误操作。4.5 总数获取与展示Page 组件的total属性是数据总条数不是总页数。它需要从接口返回的 total 字段中获取。很多人初学时会犯一个错误把total设置为当页返回的数据条数导致 Page 组件永远只有一页。这个坑我见得太多了必须单独拎出来说。接口返回的 total 是服务端根据查询条件统计出来的完整数据量Page 组件拿到 total 后自己会计算总页数并在页码栏右侧渲染出共 X 条的文字配合show-total属性。5. 合并查询条件的分页搜索、页码重置、参数同步一个都不能少真实项目里表格分页几乎总是和搜索条件绑在一起的。这一章的坑比基础分页多得多值得单独开一节来说。5.1 搜索时页码必须重置到第一页这是一个看起来是小事、做错了就是事故的点。假设用户正在浏览第 8 页的数据此时他在搜索框里输入了新的关键词点击查询按钮。如果查询逻辑只是简单地把tableData重新赋值为新接口的返回结果而currentPage还停留在 8会出现两种情况接口返回的是第 8 页的数据但符合条件的数据总共可能只有 2 页前端拿到的就是第 8 页不存在的数据——往往是空数组。即使后端对超出总页数的页码做了容错返回最后一页用户体验依然是混乱的我明明搜的是新关键词为什么页码还停在第 8正确做法是搜索条件变化时把 currentPage 重置为 1同时把查询参数传给后端。handleSearch() { this.currentPage 1 this.fetchOrderList() }如果把搜索框和页码联动封装到一个统一的handleQuery方法里还可以进一步简化handleQuery(resetPage true) { if (resetPage) { this.currentPage 1 } this.fetchOrderList() }5.2 查询参数的深拷贝陷阱前端传搜索条件给后端时如果直接把响应绑定的对象传给请求函数这些参数对象其实是 Vue 的响应式代理内部带着各种 getter/setter。在序列化传输时某些情况下会出现参数丢失或附加多余字段的问题。具体表现是搜索条件明明在界面上看得见但后端收到的请求参数里却没有。这个坑在 axios 结合 Vue 2 的响应式系统时偶有发生排查起来非常隐蔽。我的建议是组装请求参数时使用一个新对象buildPageParams() { return { page: this.currentPage, pageSize: this.pageSize, keyword: this.keyword ? this.keyword.trim() : , status: this.status, dateRange: this.dateRange ? [...this.dateRange] : [] } }不要直接把this.searchForm整个传给请求函数而是手动选取需要的字段组装新对象。这样既避免了响应式代理的序列化坑也让请求参数变得可控和可调试。5.3 搜索后页码是保留了但查询参数对不上还有一种常见 bug搜索关键词为 A结果用户翻页时把关键词改成了 B然后点下一页请求参数却是关键词 B 第 2 页。如果后端不校验页码和查询条件组合的合法性就会返回一个混合结果。这个问题的根源在于页码和关键词是两个来源不同、更新时机不同的状态。解决思路是在翻页回调时确保用的是当前最新的查询条件async handlePageChange(page) { this.currentPage page await this.fetchOrderList() }只要fetchOrderList每次读取的是最新的this.keyword、this.status等数据而不是在搜索那一刻就拍扁的快照这个问题就不会出现。所以组装请求参数时务必在请求方法内动态读取组件状态而不是在某个初始化阶段把参数固化下来。6. 边界场景与真实事故复盘删除、编辑后页码漂移怎么处理分页代码写完之后考验功力的是边界场景。这部分内容网上很难找到系统性总结大多是从一次次的线上问题里摸爬滚打出来的在这里一并分享。6.1 删除当前页最后一条数据后的页码回退假设当前是第 3 页每页 10 条这页有 3 条数据。用户删掉了其中 2 条此时第 3 页可能只剩下 1 条数据甚至删掉最后一条后整页变空。如果你只是简单刷新当前页表格很可能显示暂无数据而实际上前面还有第 1、2 页的数据。用户会以为数据被删光了实际上是被空页给挡住了。我的经验是删除后重新请求数据并且根据返回的总数判断当前页码是否越界。更稳妥的做法是先删数据成功后重新请求总数如果当前页的起始索引大于等于总数就把页码回退一页。async handleDelete(row) { const success await deleteOrder(row.id) if (!success) return // 重新拉取数据判断页码是否需要回退 await this.fetchOrderList() const maxPage Math.max(1, Math.ceil(this.total / this.pageSize)) if (this.currentPage maxPage) { this.currentPage maxPage await this.fetchOrderList() } }注意判断条件是currentPage maxPage而不是简单的total 0。因为可能出现当前页 5 条删了 1 条还剩 4 条的情况不需要回退但如果是当前页最后一条被删就必须往回退一页否则用户就看不到前面的数据了。6.2 编辑数据后必须当前页码重新拉取编辑场景和删除不一样用户在第 2 页编辑了一条数据保存后如果直接跳到第一页重查用户的阅读位置就丢了体验很糟糕。正确做法是停留在当前页码重新拉取数据。async handleEditSave(formData) { await updateOrder(formData) await this.fetchOrderList() // 保持 currentPage 不变 }这样既刷新了表格内容也保留了用户的浏览位置。这里要特别提醒编辑成功后不要顺手把 currentPage 重置为 1除非业务方明确要求编辑后回到第一页。很多产品经理不会明说这个细节但作为开发你要有判断力。6.3 多 Tab 切换后分页状态的保持与重置如果页面里用了 Tabs 组件每个 Tab 都是一个列表每个列表都有独立的分页状态。这里有个微妙的设计取舍有的产品希望切换 Tab 后保留每个 Tab 的页码方便用户回来继续看。有的产品希望切换 Tab 后重置为第一页认为用户重新进入某个 Tab 就是一次新的浏览。我建议在data中为每个 Tab 维护独立的分页状态而不是共用一个currentPagedata() { return { pagerMap: { tabA: { currentPage: 1, pageSize: 10, total: 0 }, tabB: { currentPage: 1, pageSize: 10, total: 0 } }, activeTab: tabA } }, computed: { activePager() { return this.pagerMap[this.activeTab] } }这样每个 Tab 的页码互不干扰切换回来时用户能回到原来的位置。满足 保留浏览位置 这一体验要求。6.4 Page 组件尺寸和显示优化的几个细节View UI 的 Page 组件在业务中的使用频率很高有几个属性配置建议直接借鉴show-total在左侧显示共 X 条比单独放一行文字更直观。show-elevator显示跳页输入框数据量大、页码多时很有用。show-sizer显示每页条数选择器让用户自主调整 pageSize。placement在有 show-sizer 且组件空间受限时可以通过 placement 控制 poptip 弹出方向。加上这些属性后的基础写法Page :totaltotal :currentcurrentPage :page-sizepageSize show-total show-elevator show-sizer :page-size-opts[10, 20, 50, 100] on-changehandlePageChange on-page-size-changehandlePageSizeChange /6.5 pageSize 切换时也要回到第一页当用户通过 show-sizer 将每页条数从 10 改成 50 时当前页码不能保持原样。举个例子用户在 10 条/页时停留在第 8 页此时改成 50 条/页第 8 页其实只对应原来的第 5 页左右数据内容会完全错位。必须把页码重置为 1 再重新查询才能保证展示逻辑自洽handlePageSizeChange(newSize) { this.pageSize newSize this.currentPage 1 this.fetchOrderList() }7. 封装一个通用分页表格组件把重复劳动一次解决当一个项目里有十几个列表页都需要分页时每次都复制粘贴currentPage、pageSize、total、loading、fetchXxx这五件套会非常痛苦。我在实际项目中会封装一个通用组件PagedTable把 Table 和 Page 的组合逻辑收纳进去业务页面只关心怎么取数。7.1 组件设计思路组件的核心设计是让父组件决定数据从哪来让子组件统一管理分页状态和交互。我选择用「传入一个返回 Promise 的取数函数 查询参数对象」这样的组合方式template div classpaged-table Table :columnscolumns :datatableData :loadingloading v-bind$attrs / div classpaged-table__footer Page :totaltotal :currentcurrentPage :page-sizepageSize show-total show-elevator show-sizer :page-size-optspageSizeOpts on-changehandlePageChange on-page-size-changehandlePageSizeChange / /div /div /template组件的 props 可以这样设计属性名类型说明columnsArray表格列配置fetchDataFunction接收分页参数返回 PromisequeryParamsObject查询条件对象pageSizeNumber每页条数默认 10pageSizeOptsArray可选的每页条数列表immediateLoadBoolean是否创建时立即加载组件的核心逻辑要感知查询参数的变化当父组件传入的queryParams变化时自动重置页码并重新请求数据。这一步可以通过在组件内监听watch: { queryParams: { deep: true, handler() { this.currentPage 1 this.loadTableData() } } }这里有个细节deep: true的成本不低如果项目中有大量这样的组件同时监听对象会有性能压力。我的做法是在业务页面主动调用组件的reload()方法来替代深监听见下方的 7.3 小节。7.2 组件的完整逻辑实现下面是我在项目中使用过的完整PagedTable业务组件实现。它是一个「行为收敛」的封装适用于基于 Promise 接口的中后台 CRUD 列表场景。export default { name: PagedTable, props: { columns: { type: Array, required: true }, fetchData: { type: Function, required: true }, queryParams: { type: Object, default: () ({}) }, defaultPageSize: { type: Number, default: 10 }, pageSizeOpts: { type: Array, default: () [10, 20, 50, 100] }, immediateLoad: { type: Boolean, default: true } }, data() { return { tableData: [], total: 0, currentPage: 1, pageSize: this.defaultPageSize, loading: false, requestSequence: 0 } }, created() { if (this.immediateLoad) { this.loadTableData() } }, methods: { async loadTableData() { const requestId this.requestSequence this.loading true try { const res await this.fetchData({ page: this.currentPage, pageSize: this.pageSize, ...this.queryParams }) if (requestId ! this.requestSequence) return this.tableData res.records this.total res.total // 额外处理若当前页已经被删空自动回退页码 if (this.tableData.length 0 this.currentPage 1) { this.currentPage - 1 return this.loadTableData() } } catch (e) { this.$Message.error(数据加载失败) } finally { if (requestId this.requestSequence) { this.loading false } } }, handlePageChange(page) { this.currentPage page this.loadTableData() }, handlePageSizeChange(size) { this.pageSize size this.currentPage 1 this.loadTableData() }, reload() { this.loadTableData() }, reset() { this.currentPage 1 this.loadTableData() } } }在「当前页已被删空」的处理上我在前面 6.1 小节提到的是「删除后判断页码是否越界再回退」而封装组件时我倾向于用更稳的兜底策略如果接口返回的当前页数据为空且当前页码大于 1就自动往前退一页并重新加载。这样即使是批量删除、排序后行数变化、多端同时操作导致的数据量突变也能自动修正页码不会出现空白页。7.3 父组件怎么用这个组件父组件里只需要把取数函数和查询条件对象传进去template div div classfilter-bar Input v-modelkeyword placeholder搜索订单号 clearable on-enterhandleSearch / Button typeprimary clickhandleSearch查询/Button /div PagedTable refpagedTable :columnscolumns :fetch-datafetchOrderList :query-params{ keyword, status } / /div /template script import PagedTable from /components/PagedTable export default { components: { PagedTable }, methods: { // 注意这个函数要保证 this 正确返回 { records, total } fetchOrderList({ page, pageSize, ...rest }) { return getOrderList({ page, pageSize, ...rest }) }, handleSearch() { this.$refs.pagedTable.reset() } } } /script这样封装的好处是业务页面不再需要关心 currentPage、total、loading 这些状态只需要关注接口怎么调、列怎么配。当项目里列表变多时这种封装的复利效应会非常明显。不过要注意封装组件不要过度设计。如果你的项目只有两个列表页硬套这个组件反而增加了理解和维护成本。我在实际项目中通常先在两个页面里跑通这种写法觉得顺了再抽成组件属于先重复再抽象的节奏。8. 实测中容易忽略的性能与体验细节代码能跑通只是第一步线上体验才是分页的真正考场。这一章集中讲我实测中重点注意的几处性能与交互细节。8.1 大数据量下避免一次性渲染过多表格行即使走了服务端分页如果 pageSize 设置成 100 甚至更大Table 要在一帧内渲染 100 行乘以若干列的 DOM在低端设备上依然会产生明显的白屏。比如你的报表页允许用户选择每页 200 条在移动端或者性能一般的电脑上视觉上会感觉点了翻页之后卡了半秒多。我建议开发阶段做一次性能压测打开浏览器的 Performance 面板把 pageSize 调到 100连续快速切换 5 页观察每一帧的耗时。如果 Long Task 超过 100ms就需要考虑控制 pageSize 上限比如最高 100或者引导用户使用更高粒度的过滤条件来缩小结果集。8.2 快速翻页时的节流策略前面 4.3 节用 requestSequence 解决了响应顺序错乱的问题但如果你连频繁点击翻页都不希望发生前端可以再加一层节流。最简单的方式是在handlePageChange里加一个时间锁handlePageChange(page) { if (this.isFetching) return this.currentPage page this.loadTableData() }在loadTableData开始和结束的地方分别把isFetching置为 true 和 false这样在请求未返回时用户点击任何页码都会被忽略。这在操作频繁的管理后台里非常实用能显著降低后端请求压力。8.3 页码变化但总分页数为 1 时的 UI 处理如果接口返回的 total 本来就是小于等于 pageSize 的值Page 组件会渲染出 1 页。这没问题但如果同时开启了 show-sizer用户把 pageSize 改大后total 可能依然不变。要注意 Page 组件的 total 始终是符合条件的总条数而不是当前页的总数。总条数不会因为改 pageSize 而变化的。另外total 是提前知道还是请求返回才知道在服务端分页中首次请求前 total 为空Page 组件渲染出来是空的这会造成一点布局抖动。如果对布局稳定性有要求可以给 Page 组件加一个初始 total 为 0并在 table 外层容器给一个最小高度。8.4 空数据 vs 总数为 0 的文案区分表格数据为空时View UI 的 Table 默认显示暂无数据。但如果 total 为 0 且当前页为 1属于正常空态如果 total 大于 0 但当前页数据为空说明页码越界或存在脏数据。这两种情况要分开处理正常空态保持暂无数据不需要任何操作。页码越界空态触发页码回退逻辑如第 7 章组件中的兜底策略并建议在控制台打印一条日志方便排查是哪个环节造成的越界。我之前排查过一个线上问题某个订单列表在切换 Tab 后偶尔出现空白页就是Tab 切换后保留了当前第 8 页的页码但新 Tab 的数据总量只有 3 页导致的。加上页码回退逻辑后问题直接消失。9. 最后再分享两个小技巧第一个技巧关于请求参数的调试。服务端分页的交互链路长定位问题时要学会用 curl 复现 的方法。在 Chrome 的 Network 面板里拿到分页请求的完整 URL然后复制成 curl 命令在终端执行看响应结构。这比在代码里打断点更直接能快速分清是前端参数问题还是后端返回问题。第二个技巧关于 Table 组件的行高一致性。分页后表格每页的渲染高度可能不同翻页时页面会出现跳动。可以在 Table 外层设置一个固定最小高度比如把数据区域的 min-height 定为(pageSize 1) * 行高这样翻页时页面不会突然变矮或变高。这个细节在小屏幕终端上特别明显值得为它做一次适配。分页这个功能说难不难说简单也绝不简单。核心还是想清楚数据流的来源与出口把前端展示状态和服务端数据请求的边界理干净。配合 View UI 的 Table 和 Page 组件只要把页码状态、查询参数、请求竞态、边界兜底这四件事处理扎实线上的分页体验基本就能稳住。希望这篇分享能帮你少踩几个坑。
返回列表