Vue 3可复用分页组件设计:契约驱动、服务端/客户端双模式与BEM样式解耦

1. 项目概述:为什么一个“可复用的分页组件”值得花一整天重写三次

在 Vue.js 项目里,分页功能看似简单——无非是上一页、下一页、跳转页码、显示总条数。但真正接手过三个以上中后台系统的人都知道:第一次写分页,你用v-for硬写 5 个按钮;第二次加搜索联动,你把pagepageSize挂到data里,watch一监听就发请求;第三次遇到表格嵌套弹窗再嵌套分页,你发现page=1在父组件改了,子组件里的page还是 2,接口连发两次,用户点一次刷新出两页数据。这不是代码能力问题,是组件设计范式没对齐。

我去年重构了公司 7 个业务线的分页逻辑,从最初用props+emit硬传,到后来抽象出usePagination组合式函数,再到最终落地成一个零外部依赖、不侵入业务状态、支持服务端/客户端双模式、自带防抖节流、可透传任意 props 给内部按钮<VPage>组件。它现在被 12 个项目引用,npm 包下载量月均 8000+,而核心源码只有 327 行。关键不是代码多精巧,而是它解决了三个真实痛点:状态同步断裂、样式定制僵硬、服务端分页参数耦合

这个组件不依赖vue-router,不强制要求axios,甚至不用pinia—— 它只做一件事:把「当前页」「总条数」「每页条数」这三个数字,变成一套可预测、可调试、可组合的交互契约。你传total=127,它自动算出页码数组[1,2,3,...,13];你传page=5,它确保所有按钮点击后emit('update:page', 6);你传disabled=true,它让整个分页栏变灰且不可点击。没有魔法,全是显式约定。接下来我会带你从零开始,一行行写出这个组件,重点不是语法,而是每个if判断背后踩过的坑、每个ref声明背后的边界考量、每个computed里藏着的性能权衡。

2. 核心设计思路:拒绝“万能封装”,坚持“契约驱动”

2.1 为什么不用v-model而坚持update:xxx事件?

Vue 官方文档说v-model是语法糖,但很多团队把它当黑盒用。我见过最典型的反模式:在父组件写<VPage v-model="page" />,子组件内部却直接this.page = 5—— 这违反了 Vue 的响应式原则,因为pageprops,直接赋值会触发警告,且在 Vue 3 的setup中根本无法编译通过。更严重的是,当父组件用:page.syncv-model:page时,如果子组件忘记emit('update:page'),状态就彻底断连。

我们选择显式声明modelValueprop 和update:modelValue事件,原因有三:

  1. 可追溯性:在 Vue Devtools 中,你能清晰看到每次emit的源头、参数、时间戳,而v-model的双向绑定在调试器里是一团模糊的“响应式更新”;
  2. 可中断性:业务需要“点击下一页前校验表单是否保存”,用@update:modelValue可以preventDefault()并弹窗提示,而v-model没有拦截钩子;
  3. 兼容性v-model在 Vue 2 和 Vue 3 中行为不一致(Vue 2 默认value/input,Vue 3 默认modelValue/update:modelValue),显式写法一次编码,两端运行。

提示:modelValue不是必须名,你可以定义pageprop +update:page事件,但modelValue是 Vue 官方推荐的统一命名,配合defineModel()(Vue 3.4+)可进一步简化。

2.2 服务端分页 vs 客户端分页:为什么必须由使用者决定?

分页逻辑常被错误地“一刀切”。有人认为“所有分页都该走接口”,结果列表只有 20 条数据也发 10 次请求;有人觉得“前端分页省事”,结果用户导出 10 万条数据时页面卡死。我们的方案是:组件不假设数据来源,只提供两种模式开关

  • mode="server"(默认):组件只负责展示页码和触发事件,totalpagepageSize全部由父组件控制。你emit('change', { page: 3, pageSize: 20 })后,父组件自己调接口、更新total、再把新page传回来;
  • mode="client":组件接收list数组,内部用computed切片。此时total会被忽略,list.length自动成为总条数。

关键区别在于total的语义:服务端模式下total是接口返回的真实总数(如 127),客户端模式下total是冗余字段,实际以list.length为准。这避免了“父组件传了total=100,但list只有 30 条,页码显示到第 4 页却点不动”的诡异现象。

2.3 样式解耦:为什么放弃 scoped CSS 而用 BEM 命名?

很多人用scoped给分页组件写样式,结果业务方想改“当前页按钮背景色”时,要打开组件源码改.page-item.active,再发版。我们采用 BEM(Block Element Modifier)规范,所有 class 名带前缀v-page__,例如:

<div class="v-page"> <button class="v-page__btn v-page__btn--prev">上一页</button> <span class="v-page__item v-page__item--active">1</span> <span class="v-page__item">2</span> <button class="v-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总条数,mode="client"时无效接口返回{ total: 127, list: [...] }
pageSizenumber10每页条数表格右上角下拉选 20/50/100
mode'server' | 'client''server'分页模式导出预览用client,主列表用server
listany[][]客户端模式下的原始数据list: users.filter(...)
pageCountnumber5显示的页码按钮数量(含省略号)设为 3 时显示1 ... 5,设为 7 时显示1 2 3 ... 6 7
showJumperbooleantrue是否显示跳转框内部系统关闭,对外 API 文档开启
showSizeChangerbooleanfalse是否显示每页条数切换需要性能优化时开启
disabledbooleanfalse整体禁用表单提交中置灰分页栏
simplebooleanfalse极简模式(仅上/下一页)移动端小屏适配
hideOnSinglePagebooleanfalse仅 1 页时隐藏日志查询默认隐藏
classPrefixstring'v-page'CSS 类名前缀多主题系统中传'theme-dark'

重点看pageCount:它不是“最多显示多少页”,而是“页码按钮的可见数量”。当total=127pageSize=10时,总页数是 13。若pageCount=5,则无论当前页是 1 还是 10,都只显示 5 个按钮,通过动态计算startPageendPage实现居中效果。算法如下:

// 计算页码范围的核心逻辑 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 :disabled="modelValue === 1">上一页</button>

问题在于:当modelValueref(1)时,===比较的是RefImpl对象,永远为false。正确写法是:

// ✅ 正确:解包 ref <button :disabled="modelValue.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是防御性编程:当total=0list=[]时,totalPages至少为 1,避免页码数组为空导致渲染异常。

4. 实操过程:从零搭建可复用分页组件

4.1 创建组件骨架与基础结构

新建VPage.vue,采用 Vue 3<script 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 实现核心计算属性:页码数组生成算法

页码数组是分页组件的灵魂。需求是:当总页数n=13,当前页p=7,显示数量c=5时,生成[5,6,7,8,9];当p=1时生成[1,2,3,4,5];当p=13时生成[9,10,11,12,13]。算法需处理三种边界:

  1. 起始页 ≤ 1:直接从 1 开始;
  2. 结束页 ≥ 总页数:从总页数 - c + 1开始;
  3. 中间情况:以当前页为中心,左右各取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; }); </script>

showPrevEllipsis的判断逻辑是:如果第一个显示的页码大于 2(即[3,4,5]),说明前面有页码被省略,应显示...;同理,showNextEllipsis判断最后一个页码是否小于总页数-1。这样1 ... 3 4 5 ... 13的结构就自然形成了。

4.3 编写模板:用语义化 HTML 构建可访问性结构

模板不是堆砌 div,而是构建符合 WCAG 标准的可访问结构。关键点:

  • <nav aria-label="分页导航">包裹整个分页栏,屏幕阅读器能识别这是导航区域;
  • 每个页码按钮用<button>而非<span>,并添加aria-current="page"标识当前页;
  • “上一页”“下一页”按钮添加aria-label,如aria-label="上一页,当前第 5 页"
  • 跳转框用<input type="number">,并绑定min="1"max属性。

完整模板(精简版):

<template> <nav :aria-label="`分页导航,共 ${totalPages} 页`" :class="`${classPrefix}`"> <!-- 上一页按钮 --> <button :class="[`${classPrefix}__btn`, `${classPrefix}__btn--prev`] " :disabled="isPrevDisabled || props.disabled" @click="handlePrev" :aria-label="`上一页,当前第 ${props.modelValue} 页`" :tabindex="isPrevDisabled || props.disabled ? -1 : 0" > <slot name="prev-text">上一页</slot> </button> <!-- 页码区域 --> <div :class="`${classPrefix}__pages`"> <!-- 首页 --> <button v-if="pageNumbers.length && pageNumbers[0] > 1" :class="[`${classPrefix}__item`, `${classPrefix}__item--number`]" @click="handleChange(1)" :aria-current="props.modelValue === 1 ? 'page' : undefined" > 1 </button> <!-- 前省略号 --> <span v-if="showPrevEllipsis" :class="`${classPrefix}__item ${classPrefix}__item--ellipsis`">...</span> <!-- 中间页码 --> <button v-for="num in pageNumbers" :key="num" :class="[ `${classPrefix}__item`, `${classPrefix}__item--number`, { [`${classPrefix}__item--active`]: props.modelValue === num } ]" @click="handleChange(num)" :aria-current="props.modelValue === num ? 'page' : undefined" > {{ num }} </button> <!-- 后省略号 --> <span v-if="showNextEllipsis" :class="`${classPrefix}__item ${classPrefix}__item--ellipsis`">...</span> <!-- 尾页 --> <button v-if="pageNumbers.length && pageNumbers[pageNumbers.length - 1] < totalPages" :class="[`${classPrefix}__item`, `${classPrefix}__item--number`]" @click="handleChange(totalPages)" :aria-current="props.modelValue === totalPages ? 'page' : undefined" > {{ totalPages }} </button> </div> <!-- 下一页按钮 --> <button :class="[`${classPrefix}__btn`, `${classPrefix}__btn--next`] " :disabled="isNextDisabled || props.disabled" @click="handleNext" :aria-label="`下一页,当前第 ${props.modelValue} 页`" :tabindex="isNextDisabled || props.disabled ? -1 : 0" > <slot name="next-text">下一页</slot> </button> <!-- 跳转框 --> <div v-if="props.showJumper" :class="`${classPrefix}__jumper`"> <span>跳至</span> <input type="number" :min="1" :max="totalPages" :value="props.modelValue" @input="handleJumperInput" @keydown.enter="handleJumperSubmit" :disabled="props.disabled" /> <span>页</span> <button @click="handleJumperSubmit" :disabled="props.disabled">确定</button> </div> </nav> </template>

注意:tabindex="isPrevDisabled || props.disabled ? -1 : 0":禁用按钮时移除焦点,避免键盘用户卡在不可操作元素上。这是可访问性的基本要求,却被 80% 的分页组件忽略。

4.4 实现交互逻辑:从点击到事件触发的完整链路

交互逻辑集中在handleChangehandlePrevhandleNext三个方法。核心原则:所有用户操作最终都归结为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); } }); </script>

handleJumperInputhandleJumperSubmit的分离是关键:输入时只更新modelValue(UI 反馈),提交时才触发change(数据请求)。这样用户输错数字可以随时修改,不会误触发请求。

5. 常见问题与排查技巧实录:那些文档里不会写的坑

5.1 问题速查表:高频报错与根因分析

现象报错信息/表现根本原因解决方案
页码不更新点击页码按钮,UI 无变化,modelValue未改变父组件未监听update:modelValue事件,或v-model绑定错误检查父组件是否写<VPage v-model="page" />,确认pagerefreactive响应式数据
跳转框输入 NaN输入非数字字符(如字母),v-model绑定的page变成NaN<input type="number">value是字符串,parseInt('abc')返回NaNhandleJumperInput中增加isNaN(value)判断,value = isNaN(value) ? 1 : value
总页数计算错误total=100pageSize=10,但显示只有 9 页Math.ceil(100/10)是 10,但totalPages计算时用了Math.floor检查totalPagescomputed是否用了Math.ceil,确认totalpageSize是数字而非字符串
禁用状态失效:disabled="true",但按钮仍可点击disabledprop 未传递给内部按钮,或:disabled绑定错误检查模板中所有按钮是否都有 `:disabled="props.disabled
样式不生效自定义 CSS 覆盖不了.v-page__item业务项目 CSS 优先级低于组件 scoped CSS,或未使用!important改用:deep(.v-page__item)(Vue 3)或在业务 CSS 中提高选择器权重,如.my-container .v-page__item

5.2 实操避坑指南:来自 12 个项目的血泪经验

坑一:v-model绑定ref时的陷阱
新手常写<VPage v-model="page" />,但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,当totalPages=1000时,pageNumbers数组生成耗时 200ms,页面卡顿。解决方案pageCount应为奇数(如 5、7、9),且最大不超过 11。我们内部加了警告:if (props.pageCount > 11) console.warn('[VPage] pageCount should not exceed 11 for performance')

坑三:服务端分页时total未及时更新
用户搜索后接口返回total=5,但父组件忘记更新totalprop,导致页码仍显示1...13解决方案:在父组件的onMounted和搜索回调中,确保totalpage同步更新。我们封装了useSearch组合式函数,自动处理totalpage的联动重置。

坑四: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渲染)。优化步骤:

  1. 缓存pageNumbers计算结果pageNumberscomputed,但其内部循环在totalPages大时仍耗时。我们改用shallowRef存储数组,并在props变化时手动更新:

    const cachedPageNumbers = shallowRef([]); watch([() => props.modelValue, () => props.pageCount, () => totalPages.value], () => { cachedPageNumbers.value = generatePageNumbers(); // 独立函数 }, { immediate: true });
  2. 虚拟滚动页码:当totalPages > 50时,不渲染全部页码,只渲染pageCount个按钮 + 两个省略号。这步已内置,无需额外操作。

  3. 懒加载跳转框showJumper默认true,但很多页面不需要。我们改为v-if="props.showJumper",避免 DOM 节点创建。

  4. CSS 硬件加速:对.v-page__item添加transform: translateZ(0),触发 GPU 加速,滚动时更流畅。

优化后,totalPages=1000时渲染时间降至 8ms,FPS 稳定在 60。

6. 进阶扩展:从单一分页到企业级分页生态

6.1 组合式函数封装:usePagination

当项目中出现多个分页逻辑(如搜索分页、筛选分页、导出分页),重复写ref(1)watchcomputed很繁琐。我们抽离出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 组件 </script>

6.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> </template>

6.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 total=127, pageSize=10, page=7, pageCount=