手把手实现高性能Pagination分页组件

♫纪娜 组件 阅读 2,358
赞 24 收藏
二维码
手机扫码查看
反馈

先上代码,直接能跑

做后台管理系统或者内容列表页,分页这玩意儿几乎是逃不掉的。我之前一直用 UI 框架自带的 Pagination 组件,比如 Ant Design 或者 Element Plus 的,但最近一个项目用了 Vue 3 + Tailwind CSS,UI 框架没上,只能自己撸一个分页组件。

手把手实现高性能Pagination分页组件

说实话,刚开始觉得这东西有啥难的?不就是几个数字加个上下页按钮嘛。结果一动手才发现,边界情况真不少,尤其页码太多的时候怎么显示省略号、当前页怎么高亮、点击跳转怎么处理……折腾了半天才整顺。

最后我写了个可复用的 <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}&amp;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-alloweddisabled: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 组件的完整讲解,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多,比如结合路由参数持久化当前页、服务端渲染兼容等,后续会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论
W″东俊
这篇文章帮我学会了如何在团队中解决技术难题,提升了团队的技术能力。
点赞
2026-03-21 14:25