1. 为什么我们需要自己实现分页组件?
在Vue3项目中,分页功能几乎成了标配。你可能用过Element Plus、Ant Design Vue等UI库的分页组件,但真正自己动手实现过的开发者并不多。最近我在重构一个后台管理系统时,发现现有的分页组件无法满足特殊业务需求:需要在分页器中显示自定义统计信息,还要支持特殊的页码跳转逻辑。这就是我决定自己造轮子的契机。
前端分页和后端分页是两种常见方案。前端分页适合数据量不大(通常小于1万条)的场景,它的优势在于:
- 响应速度快,无需频繁请求服务器
- 减轻后端压力,一次请求获取全部数据
- 实现简单,不需要额外接口支持
- 适合需要即时筛选、排序的场景
但要注意,当数据量过大时(比如超过5万条),前端分页会导致内存占用过高,这时就应该考虑后端分页方案了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分页组件的核心设计思路
2.1 基础功能拆解
一个完整的分页组件需要包含以下核心功能:
- 页码按钮展示(包括省略号逻辑)
- 上一页/下一页控制
- 跳转到指定页码
- 每页显示条数选择
- 总条数显示
- 当前页数据范围显示(如"1-10 of 100")
在Vue3中,我们会使用Composition API来实现这些功能。先来看基础的数据结构设计:
typescript复制interface PaginationState {
currentPage: number; // 当前页码
pageSize: number; // 每页条数
totalItems: number; // 总数据量
pageCount: number; // 总页数
pageRange?: number; // 显示页码数量
showPrevNext?: boolean; // 是否显示上一页/下一页
showFirstLast?: boolean; // 是否显示首页/末页
}
2.2 页码计算逻辑
页码显示是分页组件的核心难点,需要考虑多种边界情况。以下是计算页码数组的关键算法:
typescript复制function getPageNumbers(current: number, total: number, range = 5) {
if (total <= range) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const half = Math.floor(range / 2);
let start = current - half;
let end = current + half;
if (start < 1) {
start = 1;
end = range;
} else if (end > total) {
end = total;
start = total - range + 1;
}
const pages = [];
if (start > 1) pages.push(1, '...');
for (let i = start; i <= end; i++) pages.push(i);
if (end < total) pages.push('...', total);
return pages;
}
这个算法会处理三种情况:
- 总页数小于等于显示范围:显示所有页码
- 当前页靠近开头:显示前N页,后跟省略号和末页
- 当前页靠近结尾:显示首页和省略号,后跟最后N页
提示:range参数建议设置为奇数,这样当前页可以保持在中间位置,视觉效果更好。
3. Vue3分页组件完整实现
3.1 组件模板结构
基于上述设计,我们先搭建组件的模板结构:
html复制<template>
<div class="pagination">
<div class="pagination-info">
显示 {{ startItem }}-{{ endItem }} 条,共 {{ totalItems }} 条
</div>
<div class="pagination-controls">
<button
:disabled="currentPage === 1"
@click="changePage(1)"
>
首页
</button>
<button
:disabled="currentPage === 1"
@click="changePage(currentPage - 1)"
>
上一页
</button>
<template v-for="(page, index) in pageNumbers" :key="index">
<button
v-if="page === '...'"
class="ellipsis"
disabled
>
...
</button>
<button
v-else
:class="{ active: page === currentPage }"
@click="changePage(page)"
>
{{ page }}
</button>
</template>
<button
:disabled="currentPage === pageCount"
@click="changePage(currentPage + 1)"
>
下一页
</button>
<button
:disabled="currentPage === pageCount"
@click="changePage(pageCount)"
>
末页
</button>
</div>
<div class="pagination-size">
<select v-model="localPageSize" @change="handleSizeChange">
<option v-for="size in pageSizes" :key="size" :value="size">
每页 {{ size }} 条
</option>
</select>
</div>
</div>
</template>
3.2 组件逻辑实现
下面是使用Vue3 Composition API实现的组件逻辑:
typescript复制<script setup lang="ts">
import { computed, ref, watch } from 'vue';
const props = defineProps({
currentPage: { type: Number, default: 1 },
pageSize: { type: Number, default: 10 },
totalItems: { type: Number, required: true },
pageSizes: { type: Array as () => number[], default: () => [10, 20, 50, 100] },
pageRange: { type: Number, default: 5 },
showPrevNext: { type: Boolean, default: true },
showFirstLast: { type: Boolean, default: true },
});
const emit = defineEmits(['update:currentPage', 'update:pageSize', 'page-change']);
const localPageSize = ref(props.pageSize);
const pageCount = computed(() =>
Math.ceil(props.totalItems / localPageSize.value)
);
const startItem = computed(() =>
(props.currentPage - 1) * localPageSize.value + 1
);
const endItem = computed(() =>
Math.min(props.currentPage * localPageSize.value, props.totalItems)
);
const pageNumbers = computed(() => {
const range = props.pageRange;
const current = props.currentPage;
const total = pageCount.value;
if (total <= range) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const half = Math.floor(range / 2);
let start = current - half;
let end = current + half;
if (start < 1) {
start = 1;
end = range;
} else if (end > total) {
end = total;
start = total - range + 1;
}
const pages = [];
if (start > 1) pages.push(1, '...');
for (let i = start; i <= end; i++) pages.push(i);
if (end < total) pages.push('...', total);
return pages;
});
function changePage(page: number | string) {
if (page === '...' || page === props.currentPage) return;
const newPage = Math.max(1, Math.min(Number(page), pageCount.value));
emit('update:currentPage', newPage);
emit('page-change', {
page: newPage,
pageSize: localPageSize.value
});
}
function handleSizeChange() {
emit('update:pageSize', localPageSize.value);
emit('page-change', {
page: 1,
pageSize: localPageSize.value
});
}
watch(() => props.pageSize, (newVal) => {
localPageSize.value = newVal;
});
</script>
3.3 样式设计要点
分页组件的样式设计需要注意以下几点:
css复制<style scoped>
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
margin: 20px 0;
font-size: 14px;
}
.pagination-controls {
display: flex;
gap: 5px;
}
button {
min-width: 32px;
height: 32px;
padding: 0 8px;
border: 1px solid #ddd;
background: #fff;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
}
button:hover:not(:disabled) {
border-color: #409eff;
color: #409eff;
}
button.active {
background: #409eff;
color: white;
border-color: #409eff;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ellipsis {
border: none;
background: transparent;
}
select {
height: 32px;
padding: 0 8px;
border: 1px solid #ddd;
border-radius: 4px;
outline: none;
}
select:focus {
border-color: #409eff;
}
</style>
注意:样式使用了scoped属性确保只影响当前组件。按钮的active状态和hover状态要有明显区分,提升用户体验。
4. 高级功能扩展
4.1 添加过渡动画
为了提升用户体验,我们可以为页码切换添加过渡动画:
html复制<template>
<transition-group name="page" tag="div" class="pagination-controls">
<!-- 按钮代码 -->
</transition-group>
</template>
<style scoped>
.page-enter-active,
.page-leave-active {
transition: all 0.3s;
}
.page-enter-from,
.page-leave-to {
opacity: 0;
transform: translateY(10px);
}
</style>
4.2 支持自定义插槽
为了让组件更灵活,我们可以添加插槽支持:
html复制<template>
<div class="pagination">
<slot name="info" :start="startItem" :end="endItem" :total="totalItems">
<div class="pagination-info">
显示 {{ startItem }}-{{ endItem }} 条,共 {{ totalItems }} 条
</div>
</slot>
<!-- 其他代码 -->
</div>
</template>
这样使用者可以完全自定义信息展示方式:
html复制<MyPagination :total-items="100">
<template #info="{ start, end, total }">
<div class="custom-info">
当前展示: <strong>{{ start }}-{{ end }}</strong> / {{ total }}
</div>
</template>
</MyPagination>
4.3 添加键盘导航支持
提升用户体验,支持键盘左右键翻页:
typescript复制import { onMounted, onUnmounted } from 'vue';
// 在setup函数中添加
onMounted(() => {
window.addEventListener('keydown', handleKeyDown);
});
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown);
});
function handleKeyDown(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
if (e.key === 'ArrowLeft' && props.currentPage > 1) {
changePage(props.currentPage - 1);
} else if (e.key === 'ArrowRight' && props.currentPage < pageCount.value) {
changePage(props.currentPage + 1);
}
}
5. 实际应用与性能优化
5.1 与表格组件配合使用
分页组件通常与表格一起使用,下面是一个典型的使用示例:
html复制<template>
<div>
<el-table :data="paginatedData" style="width: 100%">
<!-- 表格列定义 -->
</el-table>
<MyPagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total-items="tableData.length"
@page-change="handlePageChange"
/>
</div>
</template>
<script setup>
import { computed, ref } from 'vue';
const tableData = ref([]); // 从API获取的原始数据
const currentPage = ref(1);
const pageSize = ref(10);
const paginatedData = computed(() => {
const start = (currentPage.value - 1) * pageSize.value;
const end = start + pageSize.value;
return tableData.value.slice(start, end);
});
function handlePageChange({ page, pageSize }) {
// 可以在这里添加额外的逻辑
console.log('Page changed:', page, pageSize);
}
</script>
5.2 大数据量优化
当处理大数据量时(比如10万条以上),直接在前端分页可能会导致性能问题。这时可以考虑以下优化方案:
- 虚拟滚动分页:只渲染可视区域的数据
- 分块加载:将数据分成多个块,按需加载
- Web Worker:在后台线程处理数据分页
这里展示一个简单的分块加载实现:
typescript复制const chunks = ref([]);
const currentChunk = ref(0);
const chunkSize = 5000; // 每块5000条数据
function chunkData(data) {
const result = [];
for (let i = 0; i < data.length; i += chunkSize) {
result.push(data.slice(i, i + chunkSize));
}
return result;
}
// 初始化时
chunks.value = chunkData(rawData);
// 分页计算改为基于当前块
const paginatedData = computed(() => {
const chunk = chunks.value[currentChunk.value] || [];
const start = (currentPage.value - 1) * pageSize.value;
const end = start + pageSize.value;
return chunk.slice(start, end);
});
function loadNextChunk() {
if (currentChunk.value < chunks.value.length - 1) {
currentChunk.value++;
currentPage.value = 1;
}
}
5.3 可访问性改进
为了让分页组件对屏幕阅读器等辅助设备更友好,我们可以添加ARIA属性:
html复制<template>
<nav aria-label="分页导航">
<ul class="pagination-controls">
<li>
<button
aria-label="第一页"
:disabled="currentPage === 1"
@click="changePage(1)"
>
首页
</button>
</li>
<!-- 其他按钮 -->
</ul>
</nav>
</template>
<style>
.pagination-controls {
list-style: none;
display: flex;
padding: 0;
}
</style>
6. 常见问题与解决方案
6.1 页码显示异常
问题:当数据量变化时,当前页码可能超出范围
解决方案:添加watch监听totalItems变化
typescript复制watch([() => props.totalItems, localPageSize], () => {
if (props.currentPage > pageCount.value) {
emit('update:currentPage', Math.max(1, pageCount.value));
}
});
6.2 性能问题
问题:大数据量下分页操作卡顿
解决方案:使用防抖和节流技术
typescript复制import { throttle } from 'lodash-es';
const throttledChangePage = throttle((page) => {
emit('update:currentPage', page);
emit('page-change', {
page,
pageSize: localPageSize.value
});
}, 300);
function changePage(page: number | string) {
if (page === '...' || page === props.currentPage) return;
const newPage = Math.max(1, Math.min(Number(page), pageCount.value));
throttledChangePage(newPage);
}
6.3 样式冲突
问题:在特定UI框架中样式被覆盖
解决方案:使用更具体的选择器和CSS变量
css复制:host {
--pagination-primary: #409eff;
--pagination-border: #ddd;
}
.pagination-controls button.active {
background-color: var(--pagination-primary) !important;
border-color: var(--pagination-primary) !important;
}
6.4 国际化支持
需求:支持多语言环境
解决方案:使用插槽或props传递文本
typescript复制const props = defineProps({
// ...其他props
texts: {
type: Object,
default: () => ({
prev: '上一页',
next: '下一页',
first: '首页',
last: '末页',
info: '显示 {start}-{end} 条,共 {total} 条',
size: '每页 {size} 条'
})
}
});
// 在模板中使用
<div class="pagination-info">
{{ texts.info.replace('{start}', startItem)
.replace('{end}', endItem)
.replace('{total}', totalItems) }}
</div>
7. 测试与调试技巧
7.1 单元测试要点
使用Vitest编写单元测试,主要覆盖以下场景:
typescript复制import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import Pagination from './Pagination.vue';
describe('Pagination', () => {
it('正确计算总页数', () => {
const wrapper = mount(Pagination, {
props: {
totalItems: 100,
pageSize: 10
}
});
expect(wrapper.vm.pageCount).toBe(10);
});
it('页码变化时触发事件', async () => {
const wrapper = mount(Pagination, {
props: {
totalItems: 100,
currentPage: 1
}
});
await wrapper.findAll('button')[2].trigger('click'); // 点击页码2
expect(wrapper.emitted()['update:currentPage'][0]).toEqual([2]);
});
it('禁用上一页按钮当在第一页', () => {
const wrapper = mount(Pagination, {
props: {
totalItems: 100,
currentPage: 1
}
});
expect(wrapper.findAll('button')[0].attributes('disabled')).toBe('');
expect(wrapper.findAll('button')[1].attributes('disabled')).toBe('');
});
});
7.2 边界情况测试
需要特别测试的边界情况:
- 总数据量为0时
- 当前页码超出范围时
- 每页条数变化时
- 总数据量刚好是每页条数的整数倍时
- 显示页码数(pageRange)为偶数时
7.3 浏览器兼容性
常见兼容性问题及解决方案:
- IE11不支持:使用Vue3默认不支持IE11,如有需要考虑降级方案
- Safari的flex布局问题:添加-webkit前缀
- 移动端触摸反馈:添加active样式
css复制button:active {
transform: scale(0.98);
}
8. 与其他技术集成
8.1 与Pinia状态管理集成
对于大型应用,可以将分页状态存储在Pinia中:
typescript复制// stores/pagination.js
import { defineStore } from 'pinia';
export const usePaginationStore = defineStore('pagination', {
state: () => ({
currentPage: 1,
pageSize: 10,
totalItems: 0
}),
getters: {
pageCount: (state) => Math.ceil(state.totalItems / state.pageSize)
},
actions: {
setPage(page) {
this.currentPage = Math.max(1, Math.min(page, this.pageCount));
},
setSize(size) {
this.pageSize = size;
this.currentPage = 1;
}
}
});
8.2 与TypeScript深度集成
增强类型检查,确保props和emit的类型安全:
typescript复制interface PaginationProps {
currentPage?: number;
pageSize?: number;
totalItems: number;
pageSizes?: number[];
pageRange?: number;
showPrevNext?: boolean;
showFirstLast?: boolean;
}
interface PaginationEmits {
(e: 'update:currentPage', page: number): void;
(e: 'update:pageSize', size: number): void;
(e: 'page-change', payload: { page: number; pageSize: number }): void;
}
const props = withDefaults(defineProps<PaginationProps>(), {
currentPage: 1,
pageSize: 10,
pageSizes: () => [10, 20, 50, 100],
pageRange: 5,
showPrevNext: true,
showFirstLast: true
});
const emit = defineEmits<PaginationEmits>();
8.3 与Vue Router集成
在URL中保持分页状态,支持前进后退导航:
typescript复制import { useRoute, useRouter } from 'vue-router';
const route = useRoute();
const router = useRouter();
// 初始化时从URL读取状态
if (route.query.page) {
currentPage.value = Number(route.query.page);
}
if (route.query.size) {
localPageSize.value = Number(route.query.size);
}
// 页码变化时更新URL
function changePage(page: number | string) {
if (page === '...' || page === props.currentPage) return;
const newPage = Math.max(1, Math.min(Number(page), pageCount.value));
router.push({
query: {
...route.query,
page: newPage
}
});
// 其他逻辑...
}
9. 发布为可复用组件
9.1 打包配置
使用vite打包组件库,vite.config.js配置示例:
javascript复制import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
build: {
lib: {
entry: 'src/components/Pagination.vue',
name: 'Pagination',
fileName: (format) => `pagination.${format}.js`
},
rollupOptions: {
external: ['vue'],
output: {
globals: {
vue: 'Vue'
}
}
}
}
});
9.2 文档编写
使用Vitepress编写组件文档,示例:
markdown复制# Pagination 分页
分页组件,支持自定义样式和功能扩展。
## 基本用法
```html
<template>
<Pagination
:total-items="100"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
/>
</template>
```
## API
### Props
| 参数 | 说明 | 类型 | 默认值 |
|------|------|------|-------|
| currentPage | 当前页码 | number | 1 |
| pageSize | 每页条数 | number | 10 |
| totalItems | 总数据量 | number | - |
| pageRange | 显示页码数量 | number | 5 |
| showPrevNext | 是否显示上一页/下一页 | boolean | true |
### Events
| 事件名 | 说明 | 回调参数 |
|-------|------|---------|
| page-change | 页码或每页条数变化时触发 | { page: number, pageSize: number } |
9.3 发布到npm
- 配置package.json:
json复制{
"name": "vue3-pagination",
"version": "1.0.0",
"main": "dist/pagination.umd.js",
"module": "dist/pagination.es.js",
"files": ["dist"],
"peerDependencies": {
"vue": "^3.0.0"
}
}
- 构建并发布:
bash复制npm run build
npm login
npm publish
10. 从开源项目中学习
研究优秀开源分页组件的实现可以学到很多技巧:
-
Element Plus Pagination:
- 支持多种布局模式
- 完善的国际化支持
- 可自定义页码渲染
-
Ant Design Vue Pagination:
- 简洁的API设计
- 支持快速跳转
- 小型化设计
-
Vuetify Pagination:
- 丰富的样式变体
- 圆形页码设计
- 与Material Design完美融合
分析这些组件的源码,可以学到:
- 如何设计更灵活的API
- 如何处理各种边界情况
- 如何优化渲染性能
- 如何实现可访问性
11. 性能优化实战
11.1 虚拟滚动分页
对于超大数据集(10万+),实现虚拟分页:
html复制<template>
<div class="virtual-scroller" @scroll="handleScroll">
<div class="scroll-content" :style="{ height: `${totalHeight}px` }">
<div
v-for="item in visibleItems"
:key="item.id"
class="item"
:style="{ transform: `translateY(${item.offset}px)` }"
>
{{ item.content }}
</div>
</div>
</div>
<Pagination
:total-items="totalItems"
v-model:current-page="currentPage"
:page-size="visibleCount"
/>
</template>
<script setup>
import { computed, ref } from 'vue';
const itemHeight = 50;
const visibleCount = 20;
const totalItems = 100000;
const scrollTop = ref(0);
const startIndex = computed(() =>
Math.floor(scrollTop.value / itemHeight)
);
const visibleItems = computed(() => {
const start = Math.max(0, startIndex.value - 5);
const end = Math.min(totalItems, startIndex.value + visibleCount + 5);
return Array.from({ length: end - start }, (_, i) => ({
id: start + i,
content: `Item ${start + i + 1}`,
offset: (start + i) * itemHeight
}));
});
const totalHeight = computed(() => totalItems * itemHeight);
function handleScroll(e) {
scrollTop.value = e.target.scrollTop;
currentPage.value = Math.floor(scrollTop.value / (itemHeight * visibleCount)) + 1;
}
</script>
11.2 Web Worker分页
将大数据处理放到Web Worker中:
javascript复制// worker.js
self.addEventListener('message', (e) => {
const { data, page, pageSize } = e.data;
const start = (page - 1) * pageSize;
const end = start + pageSize;
const result = data.slice(start, end);
self.postMessage(result);
});
组件中使用:
typescript复制const worker = ref(null);
const paginatedData = ref([]);
onMounted(() => {
worker.value = new Worker('./worker.js');
worker.value.onmessage = (e) => {
paginatedData.value = e.data;
};
});
function changePage(page) {
worker.value.postMessage({
data: rawData.value,
page,
pageSize: localPageSize.value
});
}
12. 移动端适配策略
12.1 触摸优化
为移动端添加更好的触摸反馈:
css复制button {
min-width: 44px; /* 最小触摸目标尺寸 */
min-height: 44px;
padding: 0 12px;
-webkit-tap-highlight-color: transparent;
}
@media (max-width: 768px) {
.pagination-controls {
flex-wrap: wrap;
justify-content: center;
}
.pagination {
flex-direction: column;
gap: 10px;
}
}
12.2 简化移动端UI
在小屏幕上显示简化版分页:
html复制<template>
<div class="pagination" :class="{ 'is-mobile': isMobile }">
<template v-if="!isMobile">
<!-- 完整分页 -->
</template>
<template v-else>
<button @click="changePage(currentPage - 1)" :disabled="currentPage === 1">
上一页
</button>
<span class="mobile-page">
{{ currentPage }} / {{ pageCount }}
</span>
<button @click="changePage(currentPage + 1)" :disabled="currentPage === pageCount">
下一页
</button>
</template>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const isMobile = ref(false);
onMounted(() => {
const checkMobile = () => {
isMobile.value = window.innerWidth < 768;
};
checkMobile();
window.addEventListener('resize', checkMobile);
});
</script>
13. 设计系统集成
将分页组件集成到设计系统中,需要考虑:
- 主题化:支持通过CSS变量自定义样式
- 尺寸变体:提供sm/md/lg等不同尺寸
- 风格变体:线框、填充、简约等不同风格
主题化实现示例:
css复制:host {
--pagination-color: #333;
--pagination-bg: #fff;
--pagination-border: #ddd;
--pagination-active-color: #fff;
--pagination-active-bg: #409eff;
--pagination-disabled-color: #999;
--pagination-disabled-bg: #f5f5f5;
--pagination-hover-color: #409eff;
--pagination-hover-bg: #f5f5f5;
}
button {
color: var(--pagination-color);
background: var(--pagination-bg);
border-color: var(--pagination-border);
}
button:hover:not(:disabled) {
color: var(--pagination-hover-color);
background: var(--pagination-hover-bg);
}
button.active {
color: var(--pagination-active-color);
background: var(--pagination-active-bg);
border-color: var(--pagination-active-bg);
}
button:disabled {
color: var(--pagination-disabled-color);
background: var(--pagination-disabled-bg);
}
14. 测试覆盖率提升
完善的测试应该覆盖:
- 计算逻辑:页码计算、范围计算
- 用户交互:点击页码、改变每页条数
- 边界情况:第一页、最后一页、数据为空
- 响应式:窗口大小变化时的表现
使用Testing Library编写更接近用户行为的测试:
typescript复制import { render, fireEvent } from '@testing-library/vue';
import Pagination from './Pagination.vue';
test('点击页码应该触发事件', async () => {
const { getByText, emitted } = render(Pagination, {
props: {
totalItems: 100,
currentPage: 1
}
});
await fireEvent.click(getByText('2'));
expect(emitted()['update:currentPage'][0]).toEqual([2]);
});
test('改变每页条数应该重置到第一页', async () => {
const { getByRole, emitted } = render(Pagination, {
props: {
totalItems: 100,
currentPage: 3,
pageSize: 10
}
});
const select = getByRole('combobox');
await fireEvent.update(select, '20');
expect(emitted()['update:currentPage'][0]).toEqual([1]);
expect(emitted()['update:pageSize'][0]).toEqual([20]);
});
15. 持续优化方向
- 动态页码范围:根据容器宽度自动计算可显示的页码数
- 预测加载:预加载下一页数据
- 无限滚动:滚动到底部自动加载下一页
- 多视图同步:多个表格共享同一个分页状态
- 服务端渲染优化:更好的SSR支持
动态页码范围实现思路:
typescript复制const containerRef = ref(null);
const visiblePageCount = ref(5);
onMounted(() => {
const observer = new ResizeObserver((entries) => {
const width = entries[0].contentRect.width;
visiblePageCount.value = Math.max(3, Math.floor(width / 40)); // 每个按钮约40px
});
if (containerRef.value) {
observer.observe(containerRef.value);
}
});
16. 总结与个人实践心得
在实现这个分页组件的过程中,我深刻体会到几个关键点:
-
边界情况处理比核心逻辑更重要。实际使用中,90%的问题都出现在数据为空、总页数变化等边界场景。
-
性能优化要适度。对于大多数场景,简单的slice分页已经足够,过早优化(如Web Worker)反而会增加复杂度。
-
可访问性不容忽视。添加适当的ARIA属性和键盘支持,虽然工作量增加不多,但对用户体验提升很大。
-
设计灵活性决定组件的复用价值。通过插槽和props提供足够的自定义能力,可以让组件适应更多场景。
在实际项目中,我建议:
- 中小型项目直接使用UI库的分页组件
- 当有特殊需求时,可以基于开源组件二次开发
- 只有需求非常独特时,才考虑完全自己实现
最后分享一个实用技巧:在分页组件中添加一个"回到顶部"按钮,在移动端尤其有用:
html复制<button
v-if="currentPage > 1"
class="back-to-top"
@click="scrollToTop"
>
↑ 回到顶部
</button>
<script>
function scrollToTop() {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
}
</script>
