现在开始正式在前端展示各个用户的文章。
分页查询
什么是“分页查询”?
想象一下你正在看一本非常厚非常厚的书,比如一本字典。
- 没有分页查询: 就像你一次性要把整本字典从头到尾都读完,或者一次性把整本字典都搬到桌子上。这显然非常慢,非常累,而且你的桌子(电脑内存)也可能放不下。
- 有了分页查询: 就像你只看字典的“第 1 页到第 10 页”,或者“第 101 页到第 110 页”。你每次只看一小部分内容,看完这部分,觉得不够再翻到下一页。
在计算机和网络的世界里,这个“书”就是数据库里的大量数据(比如几万、几十万、几百万篇文章),而“看书”就是你的程序去数据库里拿这些数据。
它的核心思想就是:
- 按需加载: 我不需要一次性把所有数据都拿出来,我只想要当前用户在屏幕上能看到的那一小部分数据。
- 分批展示: 我把所有数据分成一页一页的,用户想看哪一页,我就去拿哪一页的数据。
为什么需要分页查询?
想象一下你的网站上有 100 万篇文章:
- 慢! 如果你一次性把这 100 万篇文章全部从数据库拿出来,再全部传到你的网页上,再全部显示出来,你的网站会慢到爆炸,用户等得花都谢了。
- 卡! 就算传到了网页,你的电脑内存也可能瞬间爆满,网页直接卡死或崩溃。
- 浪费! 用户通常只关心当前屏幕上显示的那几篇文章,后面几十万篇他可能根本没看,白白传输了那么多数据,浪费了网络流量和服务器资源。
有了分页查询,这些问题就迎刃而解了:
- 用户访问网站,网站只去数据库拿**“第 1 页”**的 10 篇文章。
- 用户点击“下一页”,网站再去数据库拿**“第 2 页”**的 10 篇文章。
- 这样既快又省,用户体验也很好。
“分页查询”的实现原理
当你的网页(前端)告诉服务器(后端)说:“我想要第 3 页的文章,每页显示 10 篇”,那么后端会:
- 计算跳过多少条: 要拿第 3 页,每页 10 条,那前面第 1 页和第 2 页加起来就是 20 条,所以要跳过 20 条数据。
- 公式:
(页码 - 1) * 每页大小
- 公式:
- 从数据库查询: 向数据库发送一个特殊的命令,告诉它:“跳过前面 20 条数据,然后给我取接下来的 10 条数据”。
- 这个命令在 SQL 语言中通常是
LIMIT 10 OFFSET 20(或者LIMIT 20, 10)。
- 这个命令在 SQL 语言中通常是
- 计算总数: 同时,后端还会问数据库:“满足我条件的文章总共有多少篇?” 这样前端就知道总共有多少页可以翻。
- 这个命令通常是
SELECT COUNT(*) FROM your_table WHERE ...
- 这个命令通常是
- 返回数据: 把当前页的 10 篇文章和总文章数一起返回给前端。
安装依赖
在**MyBatis Plus3.5.9及以上版本的分页功能独立了出来,所以我们需要额外安装一个依赖mybatis-plus-jsqlparser** 如果你的版本是3.5.9以下,则无需安装。
我们来到Maven依赖网站
https://mvnrepository.com/artifact/com.baomidou/mybatis-plus-jsqlparser
选择3.5.12版本,请根据自己的**mybatis-plus** 版本自行选择对应的版本安装,我用的是3.5.12版本。
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-jsqlparser</artifactId> <version>3.5.12</version></dependency>将依赖写入pom.xml后记得更新一下依赖,前文说过便不再赘述。
在config包下创建一个MybatisPlusConfig,代码如下:
package com.example.demo.config;
import com.baomidou.mybatisplus.annotation.DbType;import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;
@Configurationpublic class MybatisPlusConfig {
@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // **核心:添加分页拦截器,指定数据库类型** interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); // 假设你使用的是 MySQL return interceptor; }}前端部分
一个主页通常包含顶部导航,主要内容区,页脚。 大部分网站都是这样的,你可以打开各类网站观察一下他们的布局,在此我不赘述。 一个顶部导航可能会在很多地方都能用到,所以它最好是个组件,以便我们复用。 在src下的components下创建一个TheHeader.vue

<template> <el-menu default-active="" class="el-menu-demo" mode="horizontal" :ellipsis="false" @select="" style="align-items: center;"> <h1 class="menu-item">博客系统</h1> <el-menu-item class="menu-item" index="0" key="">首页</el-menu-item> <el-menu-item class="menu-item" index="1" key="">创建文章</el-menu-item> <el-menu-item class="menu-item" index="2" key="">管理文章</el-menu-item> <el-link v-if="!isLogin" class="menu-item" href="login" type="primary" underline="always">请登录</el-link> <div v-else class="menu-item" style="display: flex;"> <p class="menu-item">{{ username }},欢迎您</p> <el-button type="primary" class="menu-item" round @click="logout">注销</el-button> </div> </el-menu></template>
<script lang="ts" setup>import { onMounted, ref } from 'vue';import type { User } from '@/interface/User';
const username = ref('')const isLogin = ref(false)
const logout = () => { localStorage.removeItem('curUser') localStorage.removeItem('token') updataInfo()}
const updataInfo = () => { const curUser: User | null = JSON.parse(localStorage.getItem('curUser') || 'null'); if (curUser && curUser.username) { username.value = curUser.username; isLogin.value = true; } else { username.value = ""; isLogin.value = false; }}
onMounted(() => { updataInfo()})</script>
<style scoped>.menu-item { margin: 0 6px; align-items: center;}
.el-menu--horizontal>.el-menu-item:nth-child(4) { margin-right: auto;}</style>在视图下创建一个Home.vue
并导入TheHeader组件

<template> <TheHeader /> <div>
</div></template>
<script setup lang="ts">import TheHeader from '@/components/TheHeader.vue'
</script>
<style scoped>
</style>按照惯例在router里加上Home,这样我们就能正确路由到Home了

{ path: '/', name: 'Home', component: () => import('@/views/Home.vue')}数据库部分
我们需要一个article表,来存放文章内容 点击左上角的创建sql,写入sql代码,最后点击上方的闪电按钮执行。 这里的user_id和用户表的id关联了起来,当用户表的用户被删除时,与之关联的文章也会被一并删除。

use blog;CREATE TABLE `article` ( `id` int NOT NULL AUTO_INCREMENT, `title` varchar(100) NOT NULL COMMENT '文章标题', `content` text NOT NULL COMMENT '文章内容', `user_id` int NOT NULL COMMENT '作者ID,关联user表', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:1-发布,0-草稿', `view_count` int NOT NULL DEFAULT '0' COMMENT '浏览次数', PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), CONSTRAINT `fk_article_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='文章表';我们再造一些模拟的假数据,你可以将上面的SQL代码复制下来,去问ai,让ai给你造一点数据
这里AI给我生成了20条假数据,文章作者分别对应user_id 1 到 5,请注意你有没有5个用户在用户表里,否则会出错。

use blog;INSERT INTO article (title, content, user_id, create_time, update_time, status, view_count) VALUES('Spring Boot入门指南', 'Spring Boot是一个用于简化Spring应用初始搭建和开发过程的框架...', 1, '2023-01-15 09:30:00', '2023-01-15 09:30:00', 1, 245),('MySQL优化技巧', '本文将介绍MySQL数据库性能优化的10个实用技巧...', 2, '2023-02-20 14:15:00', '2023-02-22 11:20:00', 1, 189),('RESTful API设计原则', '良好的API设计是构建可维护系统的关键...', 3, '2023-03-05 10:00:00', '2023-03-05 10:00:00', 1, 312),('Vue3新特性解析', 'Vue3带来了许多令人兴奋的新特性...', 1, '2023-03-18 16:45:00', '2023-03-20 08:30:00', 1, 178),('微服务架构实践', '微服务架构已成为现代应用开发的主流选择...', 4, '2023-04-10 13:20:00', '2023-04-12 09:15:00', 1, 267),('Docker容器化入门', 'Docker改变了我们部署和运行应用的方式...', 2, '2023-04-25 11:10:00', '2023-04-25 11:10:00', 1, 154),('未完成的草稿', '这是一篇尚未完成的文章...', 5, '2023-05-08 15:30:00', '2023-05-08 15:30:00', 0, 12),('JavaScript ES6新特性', 'ES6为JavaScript带来了许多强大的新特性...', 3, '2023-05-20 10:45:00', '2023-05-22 14:20:00', 1, 198),('Redis缓存策略', '合理使用Redis可以显著提升系统性能...', 4, '2023-06-05 09:15:00', '2023-06-07 16:30:00', 1, 223),('Git高级用法', '掌握这些Git高级技巧可以提升你的开发效率...', 5, '2023-06-18 14:00:00', '2023-06-18 14:00:00', 1, 176),('Kubernetes基础教程', 'Kubernetes是容器编排的事实标准...', 1, '2023-07-10 11:30:00', '2023-07-12 10:45:00', 1, 289),('设计模式实践', '设计模式是解决常见软件设计问题的可复用方案...', 2, '2023-07-25 16:20:00', '2023-07-25 16:20:00', 1, 134),('TypeScript类型系统', 'TypeScript的类型系统提供了强大的开发体验...', 3, '2023-08-05 10:10:00', '2023-08-07 09:30:00', 1, 167),('网络安全基础', '了解基本的网络安全知识对每个开发者都很重要...', 4, '2023-08-20 14:45:00', '2023-08-20 14:45:00', 1, 211),('性能优化方法论', '系统性能优化需要科学的方法和工具...', 5, '2023-09-05 09:30:00', '2023-09-07 11:20:00', 1, 189),('React Hooks详解', 'React Hooks彻底改变了我们编写React组件的方式...', 1, '2023-09-18 13:15:00', '2023-09-20 15:45:00', 1, 256),('数据库事务隔离级别', '理解事务隔离级别对开发可靠应用至关重要...', 2, '2023-10-10 10:00:00', '2023-10-10 10:00:00', 1, 178),('CI/CD实践指南', '持续集成和持续部署是现代DevOps的核心...', 3, '2023-10-25 15:30:00', '2023-10-27 09:15:00', 1, 234),('Python异步编程', 'Python的异步编程模型可以显著提高IO密集型应用性能...', 4, '2023-11-05 11:20:00', '2023-11-07 14:30:00', 1, 167),('数据结构与算法', '扎实的数据结构与算法基础是优秀程序员的必备技能...', 5, '2023-11-20 16:10:00', '2023-11-20 16:10:00', 1, 198);后端部分
Article 实体类
创建一个Article实体类

package com.example.demo.entity;
import com.baomidou.mybatisplus.annotation.IdType;import com.baomidou.mybatisplus.annotation.TableId;import lombok.Data;import java.time.LocalDateTime;
@Datapublic class Article { @TableId(type = IdType.AUTO) private Integer id; private String title; private String content; private Integer userId; private LocalDateTime createTime; // 创建时间 private LocalDateTime updateTime; // 更新时间 private Integer status; // 状态:1-发布,0-草稿 private Integer viewCount; //}ArticleMapper 接口
创建ArticleMapper接口

package com.example.demo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;import com.example.demo.entity.Article;
public interface ArticleMapper extends BaseMapper<Article> {
}服务层
ArticleService接口
在service包下创建一个ArticleService接口,代码如下:
package com.example.demo.service;
import com.baomidou.mybatisplus.extension.service.IService;import com.baomidou.mybatisplus.core.metadata.IPage; // 引入分页接口import com.example.demo.entity.Article;import com.example.demo.dto.ArticleDto; // 假设你有用于接收前端文章数据的DTOimport com.example.demo.dto.ArticleQueryParams; // 假设你有用于查询参数的DTO
import java.util.List;
/** * 文章业务接口 */public interface ArticleService extends IService<Article> {
/** * @param queryParams 包含分页信息和查询条件的DTO。 * @return 包含文章列表的分页结果。 */ IPage<Article> getArticlesByPage(ArticleQueryParams queryParams);}目前我们只有一个需求,实现分页查询,那就先设计一个分页查询接口。
ArticleServiceImpl类
接下来实现具体方法
在Impl包下创建ArticleServiceImpl(注意Impl开头是大写i,末尾是小写l),代码如下:
package com.example.demo.service.Impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;import com.baomidou.mybatisplus.core.metadata.IPage;import com.baomidou.mybatisplus.extension.plugins.pagination.Page;import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;import com.example.demo.entity.Article;import com.example.demo.mapper.ArticleMapper;import com.example.demo.service.ArticleService;import com.example.demo.dto.ArticleQueryParams; // 导入你的 DTOimport org.springframework.stereotype.Service;
@Servicepublic class ArticleServiceImpl extends ServiceImpl<ArticleMapper, Article> implements ArticleService {
@Override public IPage<Article> getArticlesByPage(ArticleQueryParams queryParams) { // 1. 参数校验与默认值设置 final ArticleQueryParams finalQueryParams; if (queryParams == null) { finalQueryParams = new ArticleQueryParams(); } else { finalQueryParams = queryParams; }
// 2. 创建 Mybatis-Plus 的 Page 对象 Page<Article> page = new Page<>(finalQueryParams.getCurrent(), finalQueryParams.getSize());
// 3. 构建查询条件 (QueryWrapper) QueryWrapper<Article> queryWrapper = new QueryWrapper<>();
// **核心简化:只查询已发布的文章 (status = 1)** queryWrapper.eq("status", 1);
// 4. 设置排序规则:按创建时间倒序(最新的文章在前) queryWrapper.orderByDesc("create_time");
// 5. 执行分页查询 return page(page, queryWrapper); }}ArticleController类
package com.example.demo.controller;
import cn.dev33.satoken.util.SaResult;import com.baomidou.mybatisplus.core.metadata.IPage;import com.example.demo.entity.Article;import com.example.demo.service.ArticleService;import com.example.demo.dto.ArticleQueryParams;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;
/** * 文章相关的API接口 */@RestController@RequestMapping("/article")public class ArticleController {
@Autowired private ArticleService articleService;
/** * 获取所有已发布的公共文章列表 (分页)。 * 所有人都可以访问此接口,无需登录校验。 * * @param queryParams 查询参数,只包含分页信息(页码和每页大小)。 * @return 统一响应体,包含文章列表的分页数据。 */ @GetMapping("/list") public SaResult getArticleList(ArticleQueryParams queryParams) { IPage<Article> pageResult = articleService.getArticlesByPage(queryParams); return SaResult.ok("获取文章列表成功").setData(pageResult); }}与之对应的前端
打开你的VSCode
Article接口
在interface文件夹下创建一个Article.ts 接口,
export interface Article { id: number title: string content: string userId: number author: string // 可选字段,因为可能是后查询的 createTime: string | Date updateTime?: string | Date // 可选 status?: 0 | 1 // 0: 草稿, 1: 已发布 viewCount?: number}
export interface IPage<T> { records: T[]; total: number; size: number; current: number; pages: number;}
export interface ArticleQueryParams { current?: number; // 当前页码 size?: number; // 每页大小 // **移除:不再包含 keyword, userId, status**}与后端的对应。
对接Api
来到前端,增加get方法getArticleList
/** * 获取文章列表(分页),只返回所有已发布的公共文章。 * @param params 查询参数 (页码, 每页大小) * @returns Promise<IPage<Article>> */export function getArticleList(params: ArticleQueryParams): Promise<IPage<Article>> { return api.get<Result<IPage<Article>>, IPage<Article>>('/article/list', { params });}别忘记export方法
文章列表卡片

<template> <div class="article-container"> <h1>文章列表</h1>
<el-pagination v-model:current-page="queryParams.current" v-model:page-size="queryParams.size" :page-sizes="[5, 10, 20]" :small="false" :background="true" layout="total, sizes, prev, pager, next, jumper" :total="totalArticles" @size-change="handleSizeChange" @current-change="handleCurrentChange" />
<div v-for="art in articles" :key="art.id"> <el-card class="art-card" @click="goToArticleDetail(art.id)"> <template #header> <div class="card-header"> <span>{{ art.title }}</span> </div> </template> <p class="text item">{{ truncateContent(art.content) }}</p> <template #footer>{{ formatTime(art.createTime) }}</template> </el-card> <el-divider /> </div>
<el-empty v-if="articles.length === 0 && !loading" description="暂无文章"></el-empty> <el-skeleton :rows="5" animated v-if="loading" />
<el-pagination v-model:current-page="queryParams.current" v-model:page-size="queryParams.size" :page-sizes="[5, 10, 20]" :small="false" :background="true" layout="total, sizes, prev, pager, next, jumper" :total="totalArticles" @size-change="handleSizeChange" @current-change="handleCurrentChange" /> </div></template>
<script lang="ts" setup>import { ref, onMounted } from 'vue'; // 移除 computed,因为不再需要前端切片import { useRouter } from 'vue-router'; // 用于跳转详情页import { ElMessage, ElSkeleton, ElEmpty } from 'element-plus'; // 导入 Element Plus 组件import { getArticleList } from '@/api/Api'; // **修改点:导入 getArticleList,而不是 getAllArticle**import type { Article, ArticleQueryParams, IPage } from '@/interface/Article'; // 导入接口import dayjs from 'dayjs'; // 用于格式化时间
const router = useRouter(); // 获取路由实例
const articles = ref<Article[]>([]); // **现在只存储当前页的文章**const totalArticles = ref(0); // **存储后端返回的总文章数**const loading = ref(false); // 加载状态
// **修改点:queryParams 包含分页参数**const queryParams = ref<ArticleQueryParams>({ current: 1, // 初始页码 size: 10, // 初始每页大小 // 后续若要添加搜索,可在 ArticleQueryParams 中添加 keyword 等字段});
// --- 核心方法:从后端获取文章列表 ---const fetchArticles = async () => { loading.value = true; // 开始加载 try { // 调用后端接口,传递当前页码和每页大小 const response: IPage<Article> = await getArticleList(queryParams.value);
// **修改点:直接将后端返回的当前页数据赋值给 articles** articles.value = response.records; // **修改点:将后端返回的总数赋值给 totalArticles** totalArticles.value = response.total;
// 确保分页器和 queryParams 状态同步(通常后端返回的 current 和 size 会和请求的一致) queryParams.value.current = response.current; queryParams.value.size = response.size;
} catch (error: any) { console.error('获取文章列表失败:', error); ElMessage.error(error.message || '获取文章列表失败!'); articles.value = []; // 清空列表 totalArticles.value = 0; // 总数清零 } finally { loading.value = false; // 结束加载 }};
// 截断内容,用于文章摘要显示const truncateContent = (content: string, maxLength = 40) => { if (!content) return ''; return content.length > maxLength ? content.slice(0, maxLength) + '...' : content;};
// 时间格式化函数const formatTime = (time: string | Date) => { if (!time) return '未知时间'; return dayjs(time).format('YYYY-MM-DD HH:mm'); // 推荐使用 dayjs 进行更专业的日期格式化};
// --- 分页事件处理函数 ---
// 当每页显示数量改变时const handleSizeChange = (newSize: number) => { if (newSize > 20) { // 限制最大每页 20 条 newSize = 20; } queryParams.value.size = newSize; queryParams.value.current = 1; // **每页大小改变后,重置到第一页** fetchArticles(); // 重新发起请求获取新数据};
// 当当前页码改变时const handleCurrentChange = (newPage: number) => { queryParams.value.current = newPage; fetchArticles(); // 重新发起请求获取新数据};
// 跳转到文章详情页(假设你将来的详情页路由是 /article/:id)const goToArticleDetail = (id: number) => { router.push(`/article/${id}`);};
// 组件挂载时,首次加载文章数据onMounted(() => { fetchArticles();});</script>
<style scoped>.article-container { padding: 20px; max-width: 800px; margin: 0 auto; text-align: center;}
h1 { margin-bottom: 20px; color: #333;}
.art-card { max-width: 800px; margin: 24px auto;}</style>记得把这个组件添加到Home.vue里,像TheHeader.vue一样
保存文件,热更新后可以看到你的主页有了一系列文章卡片展示
接下来实现点击文章卡片就能跳转到文章的详情页功能。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时









