前端分页教程(Vue3/Vue2)

前端分页是前端开发中高频需求,核心是在前端对已有全量数据(如后端返回的 100 条、500 条数据)进行分段展示,通过 “页码切换” 控制显示不同范围的数据,避免一次性渲染大量 DOM 导致页面卡顿。本教程从「核心原理→分步实现→进阶优化→避坑指南」全流程讲解,兼顾 Vue3(组合式 API)和 Vue2(选项式 API),新手也能轻松上手。

一、前置知识(必懂,否则看不懂代码)

在开始写代码前,先掌握 3 个核心基础,否则会陷入 “抄代码但不懂逻辑” 的误区:

1. 响应式数据(Vue 核心)

ref(Vue3)/ data(Vue2)声明的变量,值变化时会自动触发页面更新(比如切换页码后,列表自动刷新)。

2. 计算属性 computed

根据已有响应式数据推导新值,自带缓存(依赖不变时不会重复计算),是分页的核心(推导当前页要渲染的数据)。

3. 数组 slice 方法(分页的底层逻辑)

array.slice(start, end):截取数组中「从 start 索引到 end 索引」的片段,左闭右开(包含 start,不包含 end)。

示例:

javascript

1
2
3
const arr = [1,2,3,4,5,6,7,8,9,10];
arr.slice(0, 5); // 取索引0-4 → [1,2,3,4,5](第1页,每页5条)
arr.slice(5, 10); // 取索引5-9 → [6,7,8,9,10](第2页,每页5条)

二、核心原理(前端分页的底层逻辑)

前端分页的本质是「参数计算 + 数组截取 + 交互控制」,先记住 5 个核心参数和它们的关系:

参数名 含义 计算方式(示例:总数据 100 条,每页 10 条)
listData 全量数据(后端返回) 直接接收后端数据,如 100 条数组
pageSize 每页显示条数 自定义(固定值,如 10;或让用户切换,如 10/20)
currentPage 当前页码(默认 1) 初始值 1,点击 “上一页 / 下一页 / 页码” 时更新
totalPages 总页数 Math.ceil(总数据长度 / pageSize)(向上取整)
paginatedData 当前页要渲染的数据 listData.slice((currentPage-1)*pageSize, currentPage*pageSize)

举个具体例子(100 条数据,每页 10 条):

  • 第 1 页:slice(0, 10) → 索引 0-9(1-10 条)
  • 第 2 页:slice(10, 20) → 索引 10-19(11-20 条)
  • 第 10 页:slice(90, 100) → 索引 90-99(91-100 条)

三、环境准备(零基础也能跑)

本教程以 Vue3 为主(2025 年主流),最后补充 Vue2 适配版。无需复杂脚手架,直接用 Vue 单文件组件即可,也可在 Vue SFC Playground 在线运行代码。

四、Vue3 完整实现(分步拆解,带详细注释)

步骤 1:搭建基础模板(列表 + 分页控件)

先写 HTML 结构,包含「数据列表」和「分页操作区」(上一页、页码、下一页):

vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<template>
<div class="pagination-demo">
<!-- 1. 数据列表区域:渲染当前页数据 -->
<div class="data-list">
<!-- 空数据处理 -->
<div v-if="paginatedData.length === 0" class="empty-tip">
暂无数据
</div>
<!-- 渲染当前页数据 -->
<div v-else class="list-item" v-for="(item, index) in paginatedData" :key="item.id">
{{ item.id }} - {{ item.content }}
</div>
</div>

<!-- 2. 分页控件区域:操作页码 -->
<div class="pagination-controls" v-if="totalDataLength > 0">
<!-- 上一页按钮:当前页=1时禁用 -->
<button
class="page-btn"
@click="handlePrevPage"
:disabled="currentPage === 1"
>
上一页
</button>

<!-- 页码按钮:只显示当前页前后3个(避免页码过多) -->
<button
class="page-num"
v-for="page in showPageNumbers"
:key="page"
@click="handlePageClick(page)"
:class="{ active: currentPage === page }"
>
{{ page }}
</button>

<!-- 下一页按钮:当前页=总页数时禁用 -->
<button
class="page-btn"
@click="handleNextPage"
:disabled="currentPage === totalPages"
>
下一页
</button>

<!-- 分页信息:显示总条数/总页数 -->
<div class="page-info">
共 {{ totalDataLength }} 条 / 每页 {{ pageSize }} 条 / 共 {{ totalPages }} 页
</div>
</div>
</div>
</template>

步骤 2:编写核心逻辑(数据 + 分页计算 + 交互)

用 Vue3 组合式 API 实现「数据初始化、分页计算、交互方法」,每一行都加注释:

vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
<script setup>
// 导入Vue核心API
import { ref, computed, onMounted } from 'vue';

// ===================== 1. 初始化全量数据(模拟后端返回) =====================
// 响应式变量:存储后端返回的全量数据
const listData = ref([]);

// 页面挂载后初始化数据(实际项目替换为接口请求:axios.get('/api/data').then(res => listData.value = res.data))
onMounted(() => {
// 生成100条模拟数据(模拟后端返回)
for (let i = 1; i <= 100; i++) {
listData.value.push({
id: i, // 唯一标识(必选,v-for的key)
content: `这是第${i}条数据` // 数据内容
});
}
});

// ===================== 2. 定义分页核心参数(响应式) =====================
const currentPage = ref(1); // 当前页码,默认第1页
const pageSize = ref(10); // 每页显示10条,可自定义

// ===================== 3. 计算属性:推导分页相关数据(核心) =====================
// 计算总数据长度(依赖listData)
const totalDataLength = computed(() => {
return listData.value.length;
});

// 计算总页数(向上取整,比如95条/10条=9.5 → 10页)
const totalPages = computed(() => {
// 避免除以0(pageSize为0时总页数=0)
if (pageSize.value === 0) return 0;
return Math.ceil(totalDataLength.value / pageSize.value);
});

// 计算当前页要渲染的数据(核心:数组slice截取)
const paginatedData = computed(() => {
// 计算起始索引:(当前页-1)*每页条数
const startIndex = (currentPage.value - 1) * pageSize.value;
// 计算结束索引:当前页*每页条数
const endIndex = currentPage.value * pageSize.value;
// 截取对应范围的数据并返回
return listData.value.slice(startIndex, endIndex);
});

// 优化页码显示:只显示当前页前后3个页码(避免100页时页码占满屏幕)
const showPageNumbers = computed(() => {
const pages = [];
// 计算显示的起始页码(最少从1开始)
const start = Math.max(1, currentPage.value - 3);
// 计算显示的结束页码(最多到总页数)
const end = Math.min(totalPages.value, currentPage.value + 3);
// 生成页码数组
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
});

// ===================== 4. 分页交互方法(控制页码) =====================
// 点击“上一页”
const handlePrevPage = () => {
// 边界校验:当前页>1时才减1
if (currentPage.value > 1) {
currentPage.value--;
}
};

// 点击“下一页”
const handleNextPage = () => {
// 边界校验:当前页<总页数时才加1
if (currentPage.value < totalPages.value) {
currentPage.value++;
}
};

// 点击具体页码
const handlePageClick = (page) => {
// 避免重复点击当前页
if (page === currentPage.value) return;
// 更新当前页
currentPage.value = page;
};
</script>

步骤 3:添加样式(美化界面,提升体验)

写 CSS 样式,让列表和分页控件更美观,重点处理「激活页码」「禁用按钮」的样式:

vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
<style scoped>
.pagination-demo {
width: 800px;
margin: 50px auto;
font-family: "Microsoft Yahei";
}

/* 列表样式 */
.data-list {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
min-height: 300px;
}
.empty-tip {
text-align: center;
color: #999;
line-height: 300px;
}
.list-item {
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
}
.list-item:last-child {
border-bottom: none;
}

/* 分页控件样式 */
.pagination-controls {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.page-btn {
padding: 8px 16px;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
cursor: pointer;
transition: all 0.2s;
}
.page-btn:hover:not(:disabled) {
border-color: #409eff;
color: #409eff;
}
.page-btn:disabled {
cursor: not-allowed;
color: #999;
background: #f5f5f5;
border-color: #eee;
}
.page-num {
padding: 8px 16px;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
cursor: pointer;
transition: all 0.2s;
}
.page-num.active {
background: #409eff;
color: #fff;
border-color: #409eff;
}
.page-num:hover:not(.active) {
border-color: #409eff;
color: #409eff;
}
.page-info {
margin-left: 20px;
color: #666;
font-size: 14px;
}
</style>

步骤 4:运行验证

将代码复制到 Vue SFC Playground 或本地 Vue 项目,运行后效果:

  1. 初始显示第 1 页(1-10 条数据);
  2. 点击 “下一页”,切换到第 2 页(11-20 条);
  3. 点击页码 “5”,切换到第 5 页(41-50 条);
  4. 点击 “上一页”,回到第 4 页(31-40 条);
  5. 第 1 页时 “上一页” 禁用,第 10 页时 “下一页” 禁用;
  6. 页码只显示当前页前后 3 个(比如第 5 页显示 2-8)。

五、进阶优化(实际项目必加功能)

上面的基础版满足核心需求,实际项目中还需添加以下优化,提升用户体验:

优化 1:支持切换每页条数

让用户选择 “10 条 / 20 条 / 50 条 / 页”,只需新增下拉框并绑定 pageSize

vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!-- 在分页控件区域添加下拉框 -->
<div class="page-size-select">
<select v-model="pageSize" @change="handlePageSizeChange">
<option value="10">10条/页</option>
<option value="20">20条/页</option>
<option value="50">50条/页</option>
</select>
</div>

<!-- 新增方法:切换每页条数后重置到第1页 -->
<script setup>
// 切换每页条数时,重置到第1页(避免当前页超出新的总页数)
const handlePageSizeChange = () => {
currentPage.value = 1;
};
</script>

<!-- 新增样式 -->
<style scoped>
.page-size-select {
margin-left: 20px;
}
.page-size-select select {
padding: 6px 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
</style>

优化 2:支持手动输入页码跳转

添加输入框,让用户直接输入页码跳转,需做边界校验:

vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<!-- 在分页控件区域添加跳转输入框 -->
<div class="jump-page">
跳转到:
<input
type="number"
v-model.number="jumpPage"
min="1"
:max="totalPages"
style="width: 60px; padding: 6px; margin: 0 8px; border: 1px solid #ddd; border-radius: 4px;"
>
<button @click="handleJumpPage" class="page-btn">确定</button>
</div>

<!-- 新增响应式变量和方法 -->
<script setup>
// 跳转的页码(响应式)
const jumpPage = ref(1);

// 处理跳转页码
const handleJumpPage = () => {
// 边界校验:小于1则设为1,大于总页数则设为总页数
let targetPage = jumpPage.value;
if (targetPage < 1) targetPage = 1;
if (targetPage > totalPages.value) targetPage = totalPages.value;
// 更新当前页
currentPage.value = targetPage;
// 清空输入框(可选)
jumpPage.value = targetPage;
};
</script>

优化 3:对接真实接口(替换模拟数据)

实际项目中,listData 需从接口获取,只需修改 onMounted 中的逻辑:

vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script setup>
import { ref, computed, onMounted } from 'vue';
import axios from 'axios'; // 需安装axios:npm install axios

const listData = ref([]);

// 从接口获取全量数据
onMounted(async () => {
try {
// 真实接口地址(示例)
const res = await axios.get('/api/getAllData');
// 赋值给全量数据
listData.value = res.data;
} catch (err) {
console.error('获取数据失败:', err);
alert('数据加载失败,请重试');
}
});
// 其余逻辑不变...
</script>

六、Vue2 适配版(兼容老项目)

如果你的项目是 Vue2(选项式 API),核心逻辑不变,仅语法调整,完整代码如下:

vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
<template>
<!-- 模板和Vue3完全一致,无需修改 -->
<div class="pagination-demo">
<div class="data-list">
<div v-if="paginatedData.length === 0" class="empty-tip">暂无数据</div>
<div v-else class="list-item" v-for="(item, index) in paginatedData" :key="item.id">
{{ item.id }} - {{ item.content }}
</div>
</div>
<div class="pagination-controls" v-if="totalDataLength > 0">
<button class="page-btn" @click="handlePrevPage" :disabled="currentPage === 1">上一页</button>
<button
class="page-num"
v-for="page in showPageNumbers"
:key="page"
@click="handlePageClick(page)"
:class="{ active: currentPage === page }"
>
{{ page }}
</button>
<button class="page-btn" @click="handleNextPage" :disabled="currentPage === totalPages">下一页</button>
<div class="page-info">共 {{ totalDataLength }} 条 / 每页 {{ pageSize }} 条 / 共 {{ totalPages }} 页</div>
<div class="page-size-select">
<select v-model="pageSize" @change="handlePageSizeChange">
<option value="10">10条/页</option>
<option value="20">20条/页</option>
<option value="50">50条/页</option>
</select>
</div>
</div>
</div>
</template>

<script>
export default {
name: 'PaginationDemo',
data() {
return {
listData: [], // 全量数据
currentPage: 1, // 当前页码
pageSize: 10 // 每页条数
};
},
mounted() {
// 模拟后端返回100条数据
for (let i = 1; i <= 100; i++) {
this.listData.push({ id: i, content: `这是第${i}条数据` });
}
},
computed: {
// 总数据长度
totalDataLength() {
return this.listData.length;
},
// 总页数
totalPages() {
if (this.pageSize === 0) return 0;
return Math.ceil(this.totalDataLength / this.pageSize);
},
// 当前页数据
paginatedData() {
const startIndex = (this.currentPage - 1) * this.pageSize;
const endIndex = this.currentPage * this.pageSize;
return this.listData.slice(startIndex, endIndex);
},
// 优化页码显示
showPageNumbers() {
const pages = [];
const start = Math.max(1, this.currentPage - 3);
const end = Math.min(this.totalPages, this.currentPage + 3);
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
}
},
methods: {
// 上一页
handlePrevPage() {
if (this.currentPage > 1) {
this.currentPage--;
}
},
// 下一页
handleNextPage() {
if (this.currentPage < this.totalPages) {
this.currentPage++;
}
},
// 点击页码
handlePageClick(page) {
if (page === this.currentPage) return;
this.currentPage = page;
},
// 切换每页条数
handlePageSizeChange() {
this.currentPage = 1;
}
}
};
</script>

<style scoped>
/* 样式和Vue3完全一致,无需修改 */
.pagination-demo {
width: 800px;
margin: 50px auto;
font-family: "Microsoft Yahei";
}
.data-list {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
min-height: 300px;
}
.empty-tip {
text-align: center;
color: #999;
line-height: 300px;
}
.list-item {
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
}
.list-item:last-child {
border-bottom: none;
}
.pagination-controls {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.page-btn {
padding: 8px 16px;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
cursor: pointer;
transition: all 0.2s;
}
.page-btn:hover:not(:disabled) {
border-color: #409eff;
color: #409eff;
}
.page-btn:disabled {
cursor: not-allowed;
color: #999;
background: #f5f5f5;
border-color: #eee;
}
.page-num {
padding: 8px 16px;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
cursor: pointer;
transition: all 0.2s;
}
.page-num.active {
background: #409eff;
color: #fff;
border-color: #409eff;
}
.page-num:hover:not(.active) {
border-color: #409eff;
color: #409eff;
}
.page-info {
margin-left: 20px;
color: #666;
font-size: 14px;
}
.page-size-select {
margin-left: 20px;
}
.page-size-select select {
padding: 6px 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
</style>

七、常见问题 & 避坑指南(新手必看)

问题 1:paginatedData 不更新?

  • 原因 1:listData 不是响应式数据(比如用普通变量而非 ref/data);
  • 原因 2:修改 listData 时没有触发响应式(比如直接赋值 listData = [],Vue3 需用 listData.value = []);
  • 解决:确保所有依赖都是响应式,修改数据时遵循 Vue 响应式规则。

问题 2:页码点击后超出范围(比如点到 0 或 11)?

  • 原因:没有做边界校验;
  • 解决:在 handlePageClick/handleJumpPage 中添加校验,确保页码≥1 且≤总页数。

问题 3:slice 截取的数据少一条?

  • 原因:误解 slice 的「左闭右开」规则(比如想取 1-10 条,却写 slice(1, 10));
  • 解决:记住公式:startIndex = (currentPage-1)*pageSizeendIndex = currentPage*pageSize

问题 4:切换每页条数后,当前页数据为空?

  • 原因:比如当前页是 10 页(每页 10 条),切换到 20 条 / 页后,总页数变成 5,第 10 页超出范围;
  • 解决:切换 pageSize 后,强制将 currentPage 重置为 1。

问题 5:数据量太大(比如 1 万条),前端分页卡顿?

  • 原因:前端分页仍需渲染当前页的 DOM,1 万条数据虽截取但全量存在内存,且当前页若显示 50 条以上也会卡顿;
  • 解决:改用「后端分页 + 前端虚拟列表」:
    1. 后端分页:每次请求只返回当前页数据(如 /api/data?page=1&pageSize=10);
    2. 虚拟列表:只渲染可视区域的 DOM(比如屏幕显示 10 条,只渲染 10 个 DOM,而非 50 条)。

八、前端分页 vs 后端分页(适用场景)

类型 核心逻辑 适用场景 优点 缺点
前端分页 前端截取全量数据 数据量小(≤500 条)、接口只返全量 无需频繁请求接口 首次加载全量数据,耗内存
后端分页 后端返回当前页数据 数据量大(≥1000 条)、需筛选排序 内存占用小、支持复杂筛选 切换页码需请求接口

九、总结

前端分页的核心是「参数计算 + 数组截取 + 响应式更新」:

  1. ref/data 声明响应式参数(当前页、每页条数、全量数据);
  2. computed 推导当前页数据、总页数,利用缓存提升性能;
  3. 用交互方法控制页码,做好边界校验;
  4. 实际项目中根据数据量选择「前端分页」或「后端分页」。

本教程的代码可直接复用,只需替换 listData 的初始化逻辑(对接真实接口),即可适配绝大多数前端分页场景。如果需要封装成通用分页组件,可将分页控件抽离为独立组件,通过 props 传递参数、emit 触发页码变化,进一步提升复用性。