手把手实现高性能Pagination分页组件
先上代码,直接能跑
做后台管理系统或者内容列表页,分页这玩意儿几乎是逃不掉的。我之前一直用 UI 框架自带的 Pagination 组件,比如 Ant Design 或者 Element Plus 的,但最近一个项目用了 Vue 3 + Tailwind CSS,UI 框架没上,只能自己撸一个分页组件。
说实话,刚开始觉得这东西有啥难的?不就是几个数字加个上下页按钮嘛。结果一动手才发现,边界情况真不少,尤其页码太多的时候怎么显示省略号、当前页怎么高亮、点击跳转怎么处理……折腾了半天才整顺。
最后我写了个可复用的 <Pagination /> 组件,核心逻辑其实就几十行 JavaScript,样式用 Tailwind 搞得干干净净。下面直接贴代码,你拷过去就能用:
<template>
<div class="flex items-center justify-center space-x-1 mt-6">
<button
:disabled="currentPage === 1"
@click="changePage(currentPage - 1)"
class="px-3 py-1 border rounded text-gray-700 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100"
>
上一页
</button>
<!-- 页码区域 -->
<template v-for="page in pageList" :key="page">
<span
v-if="page === 'ellipsis'"
class="px-3 py-1 text-gray-400"
>
...
</span>
<button
v-else
@click="changePage(page)"
:class="[
'px-3 py-1 border rounded',
currentPage === page ? 'bg-blue-500 text-white border-blue-500' : 'text-gray-700 hover:bg-gray-100'
]"
>
{{ page }}
</button>
</template>
<button
:disabled="currentPage === totalPages"
@click="changePage(currentPage + 1)"
class="px-3 py-1 border rounded text-gray-700 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100"
>
下一页
</button>
</div>
</template>
<script setup>
import { computed, defineProps, defineEmits } from 'vue'
const props = defineProps({
total: { type: Number, required: true }, // 总条数
pageSize: { type: Number, default: 10 }, // 每页数量
currentPage: { type: Number, required: true } // 当前页
})
const emit = defineEmits(['update:currentPage'])
const totalPages = computed(() => Math.ceil(props.total / props.pageSize))
// 核心:生成页码数组,包含 ellipsis
const pageList = computed(() => {
const pages = []
const showPages = 5 // 显示多少个页码(含省略符)
const half = Math.floor(showPages / 2)
if (totalPages.value <= showPages) {
// 总页数少,直接全显示
for (let i = 1; i <= totalPages.value; i++) pages.push(i)
} else {
// 页数多,需要加省略号
let start = Math.max(1, props.currentPage - half)
let end = Math.min(totalPages.value, start + showPages - 1)
// 调整起点和终点
if (end - start < showPages - 1) {
start = Math.max(1, end - showPages + 1)
}
if (start > 1) {
pages.push(1)
if (start > 2) pages.push('ellipsis')
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (end < totalPages.value) {
if (end < totalPages.value - 1) pages.push('ellipsis')
pages.push(totalPages.value)
}
}
return pages
})
const changePage = (newPage) => {
if (newPage < 1 || newPage > totalPages.value || newPage === props.currentPage) return
emit('update:currentPage', newPage)
}
</script>
这个场景最好用
上面这个组件我在三个项目里都用了,包括一个电商后台的商品管理页、一个博客系统的文章列表、还有一个内部数据看板。只要是有分页的地方,直接 import 就完事了,不用再重复造轮子。
你可以这样用它:
<Pagination
:total="articleCount"
:current-page="currentPage"
@update:currentPage="currentPage = $event"
/>
如果你的数据是异步加载的,也很简单,在 changePage 触发后去请求接口就行:
const loadArticles = async (page = 1) => {
try {
const res = await fetch(https://jztheme.com/api/articles?page=${page}&limit=10)
const data = await res.json()
articles.value = data.list
articleCount.value = data.total
currentPage = page
} catch (err) {
console.error('加载失败', err)
}
}
注意这里我把 currentPage 做成了响应式变量,当用户点击页码时更新,然后重新调 loadArticles。这种模式很常见,亲测有效。
踩坑提醒:这三点一定注意
- 不要在计算属性里做副作用操作:我一开始在
pageList的 computed 里直接修改外部状态,结果页面疯狂 re-render,卡得要死。记住:computed 只负责返回值,别搞 side effect。 - 边界判断一定要加:比如用户点了第 0 页或负数页,虽然理论上不会出现,但万一 props 传错了呢?所以
changePage里面我加了if (newPage < 1 || ...)这种保护,防止异常跳转。 - disabled 状态别忘了加 cursor 提示:上一页按钮在首页时必须 disable 掉,不然用户体验很差。而且光 disable 不够,还得加
disabled:cursor-not-allowed和disabled:opacity-50,让用户一眼看出不能点。
还有一个小细节:我用的是 defineEmits(['update:currentPage']) 配合 v-model 语法糖,这样父组件可以用 v-model:current-page 来双向绑定,写起来更简洁。
高级技巧:支持跳页输入框
有些产品需求比较“野”,非要加个输入框让用户手动输入页码跳转。我也加上了,其实也不难,就是在 pagination 后面加个 input:
<div class="flex items-center justify-center space-x-1 mt-6">
<!-- 其他按钮... -->
<span class="flex items-center space-x-1">
<input
type="number"
v-model.number="gotoPage"
@keyup.enter="jumpToPage"
class="w-16 px-2 py-1 border rounded text-sm"
:min="1"
:max="totalPages"
placeholder="页码"
/>
<button @click="jumpToPage" class="text-sm px-2 py-1 bg-gray-200 rounded hover:bg-gray-300">
跳转
</button>
</span>
</div>
import { ref } from 'vue'
const gotoPage = ref('')
const jumpToPage = () => {
const pageNum = parseInt(gotoPage.value)
if (pageNum && pageNum >= 1 && pageNum <= totalPages.value) {
emit('update:currentPage', pageNum)
gotoPage.value = '' // 清空输入
} else {
alert(请输入 1-${totalPages.value} 之间的页码)
}
}
这里注意 v-model.number 加了 .number 修饰符,避免拿到字符串类型。另外跳转失败时我用了 alert,虽然土了点,但内部系统够用了。你要想高级点可以用 toast 提示。
性能优化:防抖处理高频点击
有个隐藏问题很多人忽略:用户连点“下一页”按钮,会连续触发多次请求。虽然接口本身可以防抖,但最好在组件层面也控制一下。
我在 changePage 外包了一层防抖:
import { ref } from 'vue'
const isChanging = ref(false)
const changePage = async (newPage) => {
if (isChanging.value || newPage === props.currentPage || newPage < 1 || newPage > totalPages.value) {
return
}
isChanging.value = true
try {
emit('update:currentPage', newPage)
// 如果外面有 loading,也可以 emit 一个事件通知
} finally {
// 实际项目中可以根据接口返回来重置,这里简化处理
setTimeout(() => { isChanging.value = false }, 500)
}
}
这样短时间内重复点击会被挡住,避免请求打架。当然更优的做法是监听实际 API 返回后再释放锁,但这个方案已经能解决大部分问题了。
移动端适配小建议
这个组件在手机上也能用,但我建议把按钮尺寸稍微调大一点,方便手指点击:
/* 在移动端增大点击区域 */
@media (max-width: 768px) {
.pagination button,
.pagination input {
min-height: 40px;
font-size: 16px;
}
}
或者你也可以改成左右箭头图标为主的操作方式,节省空间。不过那是另一个版本了,下次再分享。
总结一下
分页组件看似简单,真做起来细节一堆。我自己从最开始直接循环渲染所有页码,到后来学会加省略号、防重复点击、支持跳页输入,一步步优化过来的。
现在这个版本我已经打包成一个独立组件放在 utils 目录下了,以后新项目直接复制粘贴。虽然不是什么高大上的技术,但省下来的时间能早点下班,不香吗?
以上是我个人对这个 Pagination 组件的完整讲解,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多,比如结合路由参数持久化当前页、服务端渲染兼容等,后续会继续分享这类博客。
