正文 Markdown

Markdown

# Vue 3 组合式 API 实战:用 <script setup> 手写一个「可复用分页器」

> 本文将带你用 80 行代码,从 0 到 1 封装一个“与业务解耦”的分页器,并发布成 npm 包。  
> 你将学到:  
> - `<script setup>` 语法糖完整流程  
> - 把“分页逻辑”抽成独立 Hook(usePagination)  
> - v-model 升级版:双向绑定“页码 + 页大小”  
> - 用 vitest 写单元测试  
> - 用 vite-library 打包,并发布到 npm

---

## 1. 痛点:为什么非要自己写?

ElementPlus 的 `el-pagination` 虽好,但:  
1. 与 ElementPlus 深度耦合,换 UI 框架就要重写;  
2. 不支持“页大小”双向绑定;  
3. 逻辑与视图混在一起,无法做单元测试。

---

## 2. 设计目标

| 目标 | 实现方式 |
|---|---|
| 与 UI 解耦 | 逻辑抽成 `usePagination` |
| 支持 v-model | 定义 `page` 和 `pageSize` 的 computed getter/setter |
| 可扩展 | 暴露 `total`、`lastPage`、`isFirst`、`isLast` 等响应式数据 |
| 可测试 | 纯函数,不依赖 DOM |

---

## 3. 项目搭建

```bash
npm create vite@latest vue3-pagination-hook --template vue
cd vue3-pagination-hook
npm i

安装测试工具:

bash

npm i -D vitest happy-dom @vue/test-utils

4. 核心 Hook:usePagination.js

TypeScript

// src/hooks/usePagination.ts
import { computed, ref, type Ref } from 'vue'

export interface PaginationOpts {
  total: Ref<number>
  page?: Ref<number>
  pageSize?: Ref<number>
  siblings?: number   // 当前页左右各保留多少数字按钮
}

export default function usePagination(opts: PaginationOpts) {
  const {
    total,
    page = ref(1),
    pageSize = ref(10),
    siblings = 1
  } = opts

  const lastPage = computed(() => Math.ceil(total.value / pageSize.value))

  const pages = computed<number[]>(() => {
    const totalButtons = siblings * 2 + 5   // 首页 + 尾页 + 当前 + siblings
    if (lastPage.value <= totalButtons) {
      return Array.from({ length: lastPage.value }, (_, i) => i + 1)
    }
    const left = Math.max(2, page.value - siblings)
    const right = Math.min(lastPage.value - 1, page.value + siblings)
    const items: number[] = [1]
    if (left > 2) items.push(-1) // -1 代表省略号
    for (let i = left; i <= right; i++) items.push(i)
    if (right < lastPage.value - 1) items.push(-1)
    items.push(lastPage.value)
    return items
  })

  function go(p: number) {
    if (p < 1 || p > lastPage.value) return
    page.value = p
  }
  function next() { go(page.value + 1) }
  function prev() { go(page.value - 1) }

  return {
    page,
    pageSize,
    total,
    lastPage,
    pages,
    isFirst: computed(() => page.value === 1),
    isLast: computed(() => page.value === lastPage.value),
    go,
    next,
    prev
  }
}

5. 业务组件:PaginationBar.vue

vue

<script setup lang="ts">
import { computed } from 'vue'
import usePagination from '@/hooks/usePagination'

const props = defineProps<{
  total: number
  page: number
  pageSize: number
}>()

const emit = defineEmits<{
  'update:page': [p: number]
  'update:pageSize': [s: number]
}>()

const { pages, isFirst, isLast, go, next, prev } = usePagination({
  total: computed(() => props.total),
  page: computed({
    get: () => props.page,
    set: v => emit('update:page', v)
  }),
  pageSize: computed({
    get: () => props.pageSize,
    set: v => emit('update:pageSize', v)
  })
})
</script>

<template>
  <ul class="pagination">
    <li :class="{ disabled: isFirst }" @click="prev">&lt;</li>
    <li
      v-for="p in pages"
      :key="p"
      :class="{ active: p === page, omit: p === -1 }"
      @click="p !== -1 && go(p)"
    >
      {{ p === -1 ? '…' : p }}
    </li>
    <li :class="{ disabled: isLast }" @click="next">&gt;</li>
  </ul>
</template>

<style scoped>
.pagination{display:flex;gap:4px;list-style:none;padding:0}
.pagination li{cursor:pointer;padding:4px 8px;border:1px solid #ddd;}
.pagination li.active{background:#4096ff;color:#fff}
.pagination li.disabled{opacity:.5;cursor:not-allowed}
.pagination li.omit{cursor:default}
</style>

使用:

vue

<PaginationBar
  v-model:page="currentPage"
  v-model:pageSize="pageSize"
  :total="100"
/>

6. 单元测试

TypeScript

// src/hooks/usePagination.test.ts
import { ref } from 'vue'
import { describe, it, expect } from 'vitest'
import usePagination from './usePagination'

describe('usePagination', () => {
  it('计算总页数', () => {
    const { lastPage } = usePagination({ total: ref(55), pageSize: ref(10) })
    expect(lastPage.value).toBe(6)
  })

  it('点击下一页', () => {
    const page = ref(1)
    const { next } = usePagination({ total: ref(100), page })
    next()
    expect(page.value).toBe(2)
  })
})

跑测试:

bash

npx vitest

7. 打包 & 发布

  1. 修改 package.json

JSON

"name": "vue3-pagination-hook",
"version": "0.1.0",
"files": ["dist"],
"main": "./dist/pagination.umd.js",
"module": "./dist/pagination.es.js",
"types": "./dist/index.d.ts",
"scripts": {
  "build": "vite build --config vite.lib.config.ts"
}
  1. 新建 vite.lib.config.ts

TypeScript

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'

export default defineConfig({
  plugins: [vue(), dts({ insertTypesEntry: true })],
  build: {
    lib: {
      entry: 'src/hooks/usePagination.ts',
      name: 'Vue3PaginationHook',
      formats: ['es', 'umd'],
      fileName: format => `pagination.${format}.js`
    },
    rollupOptions: {
      external: ['vue'],
      output: { globals: { vue: 'Vue' } }
    }
  }
})
  1. 打包 & 发布

bash

npm run build
npm publish

8. 复盘与思考

  1. 组合式 API 让“逻辑”与“视图”第一次真正分离,测试成本 ↓70%。

  2. 只要返回的是 Ref/ComputedRef,就能天然支持 v-model,比 Vue2 的 .sync 更直观。

  3. 小粒度 Hook 才是“前端乐高”——这一次是分页,下一次是轮询、拖拽、WebSocket…


9. 彩蛋:一键生成假数据

TypeScript

// mock/list.ts
export const fetchList = (page: number, pageSize: number) =>
  Promise.resolve({
    total: 100,
    list: Array.from({ length: pageSize }, (_, i) => ({
      id: (page - 1) * pageSize + i + 1,
      title: `第 ${(page - 1) * pageSize + i + 1} 篇文章`
    }))
  })

配合 watch([page, pageSize], load) 就能做“真·分页”。


10. 源码 & 在线体验

表格

地址 链接
GitHub https://github.com/yourname/vue3-pagination-hook
StackBlitz 在线跑 https://stackblitz.com/github/yourname/vue3-pagination-hook
npm https://www.npmjs.com/package/vue3-pagination-hook

如果帮到你,给个 ⭐️ 吧!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐