Vue 3 组合式 API 实战:用 <script setup>
·
正文 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"><</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">></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. 打包 & 发布
-
修改
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"
}
-
新建
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' } }
}
}
})
-
打包 & 发布
bash
npm run build
npm publish
8. 复盘与思考
-
组合式 API 让“逻辑”与“视图”第一次真正分离,测试成本 ↓70%。
-
只要返回的是
Ref/ComputedRef,就能天然支持 v-model,比 Vue2 的.sync更直观。 -
小粒度 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 |
如果帮到你,给个 ⭐️ 吧!
更多推荐
所有评论(0)