
1. 项目概述为什么一个“可复用的分页组件”值得花一整天重写三次在 Vue.js 项目里分页功能看似简单——无非是上一页、下一页、跳转页码、显示总条数。但真正接手过三个以上中后台系统的人都知道第一次写分页你用v-for硬写 5 个按钮第二次加搜索联动你把page和pageSize挂到data里watch一监听就发请求第三次遇到表格嵌套弹窗再嵌套分页你发现page1在父组件改了子组件里的page还是 2接口连发两次用户点一次刷新出两页数据。这不是代码能力问题是组件设计范式没对齐。我去年重构了公司 7 个业务线的分页逻辑从最初用propsemit硬传到后来抽象出usePagination组合式函数再到最终落地成一个零外部依赖、不侵入业务状态、支持服务端/客户端双模式、自带防抖节流、可透传任意 props 给内部按钮的VPage组件。它现在被 12 个项目引用npm 包下载量月均 8000而核心源码只有 327 行。关键不是代码多精巧而是它解决了三个真实痛点状态同步断裂、样式定制僵硬、服务端分页参数耦合。这个组件不依赖vue-router不强制要求axios甚至不用pinia—— 它只做一件事把「当前页」「总条数」「每页条数」这三个数字变成一套可预测、可调试、可组合的交互契约。你传total127它自动算出页码数组[1,2,3,...,13]你传page5它确保所有按钮点击后emit(update:page, 6)你传disabledtrue它让整个分页栏变灰且不可点击。没有魔法全是显式约定。接下来我会带你从零开始一行行写出这个组件重点不是语法而是每个if判断背后踩过的坑、每个ref声明背后的边界考量、每个computed里藏着的性能权衡。2. 核心设计思路拒绝“万能封装”坚持“契约驱动”2.1 为什么不用v-model而坚持update:xxx事件Vue 官方文档说v-model是语法糖但很多团队把它当黑盒用。我见过最典型的反模式在父组件写VPage v-modelpage /子组件内部却直接this.page 5—— 这违反了 Vue 的响应式原则因为page是props直接赋值会触发警告且在 Vue 3 的setup中根本无法编译通过。更严重的是当父组件用:page.sync或v-model:page时如果子组件忘记emit(update:page)状态就彻底断连。我们选择显式声明modelValueprop 和update:modelValue事件原因有三可追溯性在 Vue Devtools 中你能清晰看到每次emit的源头、参数、时间戳而v-model的双向绑定在调试器里是一团模糊的“响应式更新”可中断性业务需要“点击下一页前校验表单是否保存”用update:modelValue可以preventDefault()并弹窗提示而v-model没有拦截钩子兼容性v-model在 Vue 2 和 Vue 3 中行为不一致Vue 2 默认value/inputVue 3 默认modelValue/update:modelValue显式写法一次编码两端运行。提示modelValue不是必须名你可以定义pageprop update:page事件但modelValue是 Vue 官方推荐的统一命名配合defineModel()Vue 3.4可进一步简化。2.2 服务端分页 vs 客户端分页为什么必须由使用者决定分页逻辑常被错误地“一刀切”。有人认为“所有分页都该走接口”结果列表只有 20 条数据也发 10 次请求有人觉得“前端分页省事”结果用户导出 10 万条数据时页面卡死。我们的方案是组件不假设数据来源只提供两种模式开关。modeserver默认组件只负责展示页码和触发事件total、page、pageSize全部由父组件控制。你emit(change, { page: 3, pageSize: 20 })后父组件自己调接口、更新total、再把新page传回来modeclient组件接收list数组内部用computed切片。此时total会被忽略list.length自动成为总条数。关键区别在于total的语义服务端模式下total是接口返回的真实总数如 127客户端模式下total是冗余字段实际以list.length为准。这避免了“父组件传了total100但list只有 30 条页码显示到第 4 页却点不动”的诡异现象。2.3 样式解耦为什么放弃 scoped CSS 而用 BEM 命名很多人用scoped给分页组件写样式结果业务方想改“当前页按钮背景色”时要打开组件源码改.page-item.active再发版。我们采用 BEMBlock Element Modifier规范所有 class 名带前缀v-page__例如div classv-page button classv-page__btn v-page__btn--prev上一页/button span classv-page__item v-page__item--active1/span span classv-page__item2/span button classv-page__btn v-page__btn--next下一页/button /div这样做的好处是业务方只需在自己项目的全局 CSS 里覆盖.v-page__item--active { background: #1890ff; }无需修改组件源码。我们甚至预留了class-prefixprop允许传入my-page生成my-page__item类名彻底隔离样式污染。scoped CSS 的“安全”是以牺牲定制灵活性为代价的而 BEM 在大型项目中是经过验证的平衡点。3. 核心细节解析从 props 设计到防抖实现3.1 Props 接口设计每个字段都有明确的“责任边界”组件接收 12 个 props但并非全部必需。我们按使用频率和重要性分层Prop 名类型默认值必填说明实际案例modelValuenumber1✅当前页码从 1 开始用户点击页码 5 → 触发update:modelValue(5)totalnumber0❌总条数modeclient时无效接口返回{ total: 127, list: [...] }pageSizenumber10❌每页条数表格右上角下拉选 20/50/100modeserver | clientserver❌分页模式导出预览用client主列表用serverlistany[][]❌客户端模式下的原始数据list: users.filter(...)pageCountnumber5❌显示的页码按钮数量含省略号设为 3 时显示1 ... 5设为 7 时显示1 2 3 ... 6 7showJumperbooleantrue❌是否显示跳转框内部系统关闭对外 API 文档开启showSizeChangerbooleanfalse❌是否显示每页条数切换需要性能优化时开启disabledbooleanfalse❌整体禁用表单提交中置灰分页栏simplebooleanfalse❌极简模式仅上/下一页移动端小屏适配hideOnSinglePagebooleanfalse❌仅 1 页时隐藏日志查询默认隐藏classPrefixstringv-page❌CSS 类名前缀多主题系统中传theme-dark重点看pageCount它不是“最多显示多少页”而是“页码按钮的可见数量”。当total127、pageSize10时总页数是 13。若pageCount5则无论当前页是 1 还是 10都只显示 5 个按钮通过动态计算startPage和endPage实现居中效果。算法如下// 计算页码范围的核心逻辑 const startPage computed(() { if (props.pageCount 0) return 1; const half Math.floor(props.pageCount / 2); let start props.modelValue - half; if (start 1) start 1; else if (start props.pageCount - 1 totalPages.value) { start totalPages.value - props.pageCount 1; } return Math.max(1, start); });这个计算保证了当前页在中间时页码居中当前页靠近开头或结尾时页码自动贴边。比Array.from({ length: pageCount }, (_, i) i 1)这种静态数组方案更符合人眼习惯。3.2 防抖与节流为什么分页需要“延迟响应”用户快速点击“下一页”5 次你希望发 5 次请求还是只发最后一次答案取决于场景。对于搜索列表快速翻页意味着用户在试探结果应节流throttle——固定间隔内只执行一次对于日志滚动用户可能真想逐页查看应防抖debounce——等待用户停止操作后再执行。我们在组件内建了debounceDelayprop默认 300ms并用setTimeout手写防抖不依赖 Lodash减少包体积let debounceTimer null; const emitChange (newPage) { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer setTimeout(() { // 触发 change 事件携带完整参数 emit(change, { page: newPage, pageSize: props.pageSize, // 其他上下文... }); }, props.debounceDelay); };注意debounceDelay仅作用于change事件update:modelValue事件仍实时触发保证 UI 状态即时更新。这是关键设计——UI 响应要快数据请求可缓。测试时发现300ms 是用户感知不到延迟的阈值低于 200ms 显得急促高于 500ms 有卡顿感。3.3 按钮状态管理如何让“上一页”在第 1 页时自动禁用看似简单的需求实则暗藏陷阱。常见错误写法// ❌ 错误直接用 modelValue 1 判断 button :disabledmodelValue 1上一页/button问题在于当modelValue是ref(1)时比较的是RefImpl对象永远为false。正确写法是// ✅ 正确解包 ref button :disabledmodelValue.value 1上一页/button但更优雅的方式是用computed封装const isPrevDisabled computed(() props.modelValue 1); const isNextDisabled computed(() props.modelValue totalPages.value);这里totalPages是一个computed它根据mode动态计算const totalPages computed(() { if (props.mode client) { return Math.ceil(props.list.length / props.pageSize) || 1; } return Math.ceil(props.total / props.pageSize) || 1; });|| 1是防御性编程当total0或list[]时totalPages至少为 1避免页码数组为空导致渲染异常。4. 实操过程从零搭建可复用分页组件4.1 创建组件骨架与基础结构新建VPage.vue采用 Vue 3script setup语法。第一步不是写逻辑而是定义清晰的 props 接口和 emits 声明script setup import { defineProps, defineEmits, computed, ref, onBeforeUnmount } from vue; // 定义 props带类型和默认值 const props defineProps({ modelValue: { type: Number, default: 1, required: true }, total: { type: Number, default: 0 }, pageSize: { type: Number, default: 10 }, mode: { type: String, default: server, validator: (v) [server, client].includes(v) }, list: { type: Array, default: () [] }, pageCount: { type: Number, default: 5 }, showJumper: { type: Boolean, default: true }, showSizeChanger: { type: Boolean, default: false }, disabled: { type: Boolean, default: false }, simple: { type: Boolean, default: false }, hideOnSinglePage: { type: Boolean, default: false }, classPrefix: { type: String, default: v-page }, debounceDelay: { type: Number, default: 300 } }); // 定义事件明确告知使用者可监听哪些事件 const emit defineEmits([ update:modelValue, // v-model 支持 change, // 页码变更含防抖 size-change, // 每页条数变更 jumper-submit // 跳转框提交 ]); /script注意validator的使用对modeprop 做枚举校验避免传入api或local等非法值导致逻辑错乱。default: () []是对象/数组 prop 的正确写法防止多个组件实例共享同一引用。4.2 实现核心计算属性页码数组生成算法页码数组是分页组件的灵魂。需求是当总页数n13当前页p7显示数量c5时生成[5,6,7,8,9]当p1时生成[1,2,3,4,5]当p13时生成[9,10,11,12,13]。算法需处理三种边界起始页 ≤ 1直接从 1 开始结束页 ≥ 总页数从总页数 - c 1开始中间情况以当前页为中心左右各取floor(c/2)个。完整实现script setup // ... props 和 emits 已定义 // 计算总页数 const totalPages computed(() { if (props.mode client) { return Math.ceil(props.list.length / props.pageSize) || 1; } return Math.ceil(props.total / props.pageSize) || 1; }); // 计算页码数组 const pageNumbers computed(() { const { modelValue, pageCount, total, pageSize, mode, list } props; const totalPageCount totalPages.value; // 单页时直接返回 [1] if (totalPageCount 1) return [1]; // 如果 pageCount 小于等于 0不显示页码 if (pageCount 0) return []; // 计算起始页 const half Math.floor(pageCount / 2); let start modelValue - half; // 边界处理起始页不能小于 1 if (start 1) { start 1; } // 边界处理起始页 pageCount - 1 不能超过总页数 else if (start pageCount - 1 totalPageCount) { start totalPageCount - pageCount 1; } // 生成页码数组 const numbers []; for (let i 0; i pageCount; i) { const num start i; if (num 1 num totalPageCount) { numbers.push(num); } } return numbers; }); // 计算是否显示省略号 const showPrevEllipsis computed(() { return pageNumbers.value.length pageNumbers.value[0] 2; }); const showNextEllipsis computed(() { return pageNumbers.value.length pageNumbers.value[pageNumbers.value.length - 1] totalPages.value - 1; }); /scriptshowPrevEllipsis的判断逻辑是如果第一个显示的页码大于 2即[3,4,5]说明前面有页码被省略应显示...同理showNextEllipsis判断最后一个页码是否小于总页数-1。这样1 ... 3 4 5 ... 13的结构就自然形成了。4.3 编写模板用语义化 HTML 构建可访问性结构模板不是堆砌 div而是构建符合 WCAG 标准的可访问结构。关键点用nav aria-label分页导航包裹整个分页栏屏幕阅读器能识别这是导航区域每个页码按钮用button而非span并添加aria-currentpage标识当前页“上一页”“下一页”按钮添加aria-label如aria-label上一页当前第 5 页跳转框用input typenumber并绑定min1max属性。完整模板精简版template nav :aria-label分页导航共 ${totalPages} 页 :class${classPrefix} !-- 上一页按钮 -- button :class[${classPrefix}__btn, ${classPrefix}__btn--prev] :disabledisPrevDisabled || props.disabled clickhandlePrev :aria-label上一页当前第 ${props.modelValue} 页 :tabindexisPrevDisabled || props.disabled ? -1 : 0 slot nameprev-text上一页/slot /button !-- 页码区域 -- div :class${classPrefix}__pages !-- 首页 -- button v-ifpageNumbers.length pageNumbers[0] 1 :class[${classPrefix}__item, ${classPrefix}__item--number] clickhandleChange(1) :aria-currentprops.modelValue 1 ? page : undefined 1 /button !-- 前省略号 -- span v-ifshowPrevEllipsis :class${classPrefix}__item ${classPrefix}__item--ellipsis.../span !-- 中间页码 -- button v-fornum in pageNumbers :keynum :class[ ${classPrefix}__item, ${classPrefix}__item--number, { [${classPrefix}__item--active]: props.modelValue num } ] clickhandleChange(num) :aria-currentprops.modelValue num ? page : undefined {{ num }} /button !-- 后省略号 -- span v-ifshowNextEllipsis :class${classPrefix}__item ${classPrefix}__item--ellipsis.../span !-- 尾页 -- button v-ifpageNumbers.length pageNumbers[pageNumbers.length - 1] totalPages :class[${classPrefix}__item, ${classPrefix}__item--number] clickhandleChange(totalPages) :aria-currentprops.modelValue totalPages ? page : undefined {{ totalPages }} /button /div !-- 下一页按钮 -- button :class[${classPrefix}__btn, ${classPrefix}__btn--next] :disabledisNextDisabled || props.disabled clickhandleNext :aria-label下一页当前第 ${props.modelValue} 页 :tabindexisNextDisabled || props.disabled ? -1 : 0 slot namenext-text下一页/slot /button !-- 跳转框 -- div v-ifprops.showJumper :class${classPrefix}__jumper span跳至/span input typenumber :min1 :maxtotalPages :valueprops.modelValue inputhandleJumperInput keydown.enterhandleJumperSubmit :disabledprops.disabled / span页/span button clickhandleJumperSubmit :disabledprops.disabled确定/button /div /nav /template注意:tabindexisPrevDisabled || props.disabled ? -1 : 0禁用按钮时移除焦点避免键盘用户卡在不可操作元素上。这是可访问性的基本要求却被 80% 的分页组件忽略。4.4 实现交互逻辑从点击到事件触发的完整链路交互逻辑集中在handleChange、handlePrev、handleNext三个方法。核心原则所有用户操作最终都归结为handleChange(newPage)其他方法只是快捷方式。script setup // ... 其他代码 // 处理页码变更核心方法 const handleChange (newPage) { if (props.disabled) return; // 边界校验newPage 不能小于 1 或大于总页数 const validPage Math.min(Math.max(1, newPage), totalPages.value); // 触发 v-model 更新 emit(update:modelValue, validPage); // 触发 change 事件带防抖 emitChange(validPage); }; // 上一页 const handlePrev () { handleChange(props.modelValue - 1); }; // 下一页 const handleNext () { handleChange(props.modelValue 1); }; // 跳转框输入 const handleJumperInput (e) { const value parseInt(e.target.value) || 1; // 输入时实时更新 v-model但不触发 change避免频繁请求 emit(update:modelValue, value); }; // 跳转框提交 const handleJumperSubmit () { const input document.querySelector(.${classPrefix}__jumper input); const value parseInt(input?.value) || 1; handleChange(value); emit(jumper-submit, value); }; // 防抖函数独立声明便于复用 let debounceTimer null; const emitChange (newPage) { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer setTimeout(() { emit(change, { page: newPage, pageSize: props.pageSize, total: props.total, list: props.list, mode: props.mode }); }, props.debounceDelay); }; // 组件卸载时清除定时器防止内存泄漏 onBeforeUnmount(() { if (debounceTimer) { clearTimeout(debounceTimer); } }); /scripthandleJumperInput和handleJumperSubmit的分离是关键输入时只更新modelValueUI 反馈提交时才触发change数据请求。这样用户输错数字可以随时修改不会误触发请求。5. 常见问题与排查技巧实录那些文档里不会写的坑5.1 问题速查表高频报错与根因分析现象报错信息/表现根本原因解决方案页码不更新点击页码按钮UI 无变化modelValue未改变父组件未监听update:modelValue事件或v-model绑定错误检查父组件是否写VPage v-modelpage /确认page是ref或reactive响应式数据跳转框输入 NaN输入非数字字符如字母v-model绑定的page变成NaNinput typenumber的value是字符串parseInt(abc)返回NaN在handleJumperInput中增加isNaN(value)判断value isNaN(value) ? 1 : value总页数计算错误total100、pageSize10但显示只有 9 页Math.ceil(100/10)是 10但totalPages计算时用了Math.floor检查totalPages的computed是否用了Math.ceil确认total和pageSize是数字而非字符串禁用状态失效:disabledtrue但按钮仍可点击disabledprop 未传递给内部按钮或:disabled绑定错误检查模板中所有按钮是否都有 :disabledprops.disabled样式不生效自定义 CSS 覆盖不了.v-page__item业务项目 CSS 优先级低于组件 scoped CSS或未使用!important改用:deep(.v-page__item)Vue 3或在业务 CSS 中提高选择器权重如.my-container .v-page__item5.2 实操避坑指南来自 12 个项目的血泪经验坑一v-model绑定ref时的陷阱新手常写VPage v-modelpage /但page是普通变量let page 1不是响应式数据。结果是子组件emit(update:modelValue, 5)后父组件的page仍是 1。解决方案始终用ref()或reactive()创建响应式数据。Vue 3 中推荐const page ref(1)Vue 2 中用data() { return { page: 1 } }。坑二pageCount设置过大导致性能问题曾有项目将pageCount设为 100当totalPages1000时pageNumbers数组生成耗时 200ms页面卡顿。解决方案pageCount应为奇数如 5、7、9且最大不超过 11。我们内部加了警告if (props.pageCount 11) console.warn([VPage] pageCount should not exceed 11 for performance)。坑三服务端分页时total未及时更新用户搜索后接口返回total5但父组件忘记更新totalprop导致页码仍显示1...13。解决方案在父组件的onMounted和搜索回调中确保total和page同步更新。我们封装了useSearch组合式函数自动处理total和page的联动重置。坑四SSR 渲染时window未定义在 Nuxt 等 SSR 框架中组件初始化时访问window.innerWidth会报错。我们的分页组件虽不依赖 window但若业务方在mounted中调用resize监听就会出错。解决方案所有浏览器 API 调用包裹if (typeof window ! undefined)并在onMounted中注册onBeforeUnmount中清理。5.3 性能优化实战从 120ms 到 8ms 的渲染提速用 Chrome DevTools 的 Performance 面板录制分页组件渲染初始版本耗时 120ms主要在pageNumbers计算和v-for渲染。优化步骤缓存pageNumbers计算结果pageNumbers是computed但其内部循环在totalPages大时仍耗时。我们改用shallowRef存储数组并在props变化时手动更新const cachedPageNumbers shallowRef([]); watch([() props.modelValue, () props.pageCount, () totalPages.value], () { cachedPageNumbers.value generatePageNumbers(); // 独立函数 }, { immediate: true });虚拟滚动页码当totalPages 50时不渲染全部页码只渲染pageCount个按钮 两个省略号。这步已内置无需额外操作。懒加载跳转框showJumper默认true但很多页面不需要。我们改为v-ifprops.showJumper避免 DOM 节点创建。CSS 硬件加速对.v-page__item添加transform: translateZ(0)触发 GPU 加速滚动时更流畅。优化后totalPages1000时渲染时间降至 8msFPS 稳定在 60。6. 进阶扩展从单一分页到企业级分页生态6.1 组合式函数封装usePagination当项目中出现多个分页逻辑如搜索分页、筛选分页、导出分页重复写ref(1)、watch、computed很繁琐。我们抽离出usePagination// composables/usePagination.js import { ref, computed, watch } from vue; export function usePagination(options {}) { const { total 0, pageSize 10, initialPage 1, mode server } options; const page ref(initialPage); const pageSizeRef ref(pageSize); const totalPages computed(() { if (mode client) return 0; // client 模式由业务控制 return Math.ceil(total / pageSizeRef.value) || 1; }); const changePage (newPage) { page.value Math.min(Math.max(1, newPage), totalPages.value); }; const nextPage () changePage(page.value 1); const prevPage () changePage(page.value - 1); // 监听 total 变化自动重置 page避免 total 变小后 page 超出范围 watch(() total, (newTotal) { if (page.value Math.ceil(newTotal / pageSizeRef.value)) { page.value 1; } }); return { page, pageSize: pageSizeRef, totalPages, changePage, nextPage, prevPage }; }在组件中使用script setup import { usePagination } from /composables/usePagination; const { page, pageSize, totalPages, changePage } usePagination({ total: 127, pageSize: 10, mode: server }); // 传给 VPage 组件 /script6.2 主题定制支持暗色模式与品牌色通过provide/inject注入主题配置// main.js app.provide(paginationTheme, { activeBg: #1890ff, activeColor: #fff, borderColor: #d9d9d9, disabledOpacity: 0.5 });在VPage.vue中script setup import { inject } from vue; const theme inject(paginationTheme, { activeBg: #1890ff, activeColor: #fff, borderColor: #d9d9d9, disabledOpacity: 0.5 }); /script template button :style{ backgroundColor: props.modelValue num ? theme.activeBg : transparent, color: props.modelValue num ? theme.activeColor : inherit } {{ num }} /button /template6.3 测试覆盖Jest Vue Test Utils 实战我们为VPage编写了 12 个单元测试覆盖核心路径// tests/unit/VPage.spec.js import { mount } from vue/test-utils; import VPage from /components/VPage.vue; describe(VPage, () { it(renders correct page numbers when total127, pageSize10, page7, pageCount