本项目代码已开源,具体见:
前端工程:vue3-ts-blog-frontend
后端工程:express-blog-backend
数据库初始化脚本:关注公众号bin不懂二进制,回复关键字“博客数据库脚本”,即可获取。
前言
这是博客系列中一篇讲具体业务的,话题是分页模型和滚动加载。
分页和滚动加载,各位前端大佬们没做一千次也做了一百次了吧。所以光说前端没多大意义,这里是准备结合前后端的视角看看分页和滚动加载的实现,本质上也不难,高手直接略过。如果您对后端或数据库还比较陌生,相信读完本文您会有所收获!
为什么要分页?
为什么要做分页,想必大家都很清楚。假设数据库某个表的数据记录很多(成千上万甚至更多),那么在业务设计上不可能一次性把表的数据全部查出来返回给前端展示,这不仅对数据库来说是一种巨大负担,对网络传输、客户端渲染也有较大压力。
所以我们需要用到分页,把数据一页一页地返给前端,像翻书一样,一次只看一页,实现一种按需取用的效果。
瀑布流滚动加载也是同理,只不过是把第一页和后续页的数据拼起来展示。
数据分页
那么怎么实现分页呢?源头还是数据库,首先要探究数据库的分页能力。如果数据库层面不能实现分页,而是把数据全部查出返给前端,那么即便前端实现一种视觉上的分页效果,其本质上也是掩耳盗铃,没有太多实际意义。
回到数据库角度,以 MySQL 为例,其分页查询的标准语法为:
SELECT * FROM `table_name` LIMIT offset, row_count
通过关键词LIMIT
来限制查询的偏移量offset
和记录数量row_count
。
举例如下:
- 查询第一页文章,指定一页查10篇文章。
SELECT * FROM `article` LIMIT 0, 10
0 代表没有任何偏移,所以从第一条开始,一共查询 10 条数据。
由于我删除了部分测试数据,所以 id 不是从 1 开始,不必感到疑惑,实际上 id=147 是表里的第一条记录。
- 查询第二页文章,指定一页查10篇文章。
当我们查第二页文章时,offset 应该怎么给出呢?我们可以抽象一下,偏移量其实就是第二页之前的文章数量(此例中就是第一页的数量)。以页码为 pageNo,页大小为 pageSize,则偏移量可以这样算出:
const offset = (pageNo - 1) * pageSize
当 pageNo 为 2,pageSize 为 10 时,计算出来的 offset 也就是 10,所以我们实际得到的 sql 语句是:
// 偏移10,查10条记录
SELECT * FROM `article` LIMIT 10, 10
假设不传 offset,LIMIT 后代表的就是 row_count,而 offset 也就自然等价于 0,即从第一条记录开始查询。
基于此,我们还可以通过左连接关联作者、分类、标签等信息,结合时间排序、WHERE判断等,给出一个业务上实际需要的文章分页功能。
案例分析
确定数据结构
我们先看下博客首页的效果,文章列表就是一个分页模型。
我们先观察 UI 上的整体效果,再分析后端需要提供什么数据,以及数据以什么样的结构返回。
- 首先,分页每页的数据都是一个数组,这个没有太多的疑问。
- 前端需要知道一共有多少页,或者一共有多少篇文章,才能知道如何展示总页数。
- 除文章基础信息外,分类/标签/作者等信息需要从其他表关联得来。
根据本项目实现的效果,我们会提供下面这样的数据结构:
{
"code": "0",
"data": [
{
"id": 文章id,
"article_name": "标题",
"poster": "封面图",
"read_num": 阅读量,
"summary": "摘要信息",
"create_time": "创建时间",
"update_time": "修改时间",
"author": "作者名",
"categories": [
{
"id": 分类id,
"categoryName": "pnpm"
},
{
"id": 分类id,
"categoryName": "TypeScript"
}
],
"tags": [
{
"id": tag id,
"tagName": "pnpm"
},
]
},
// ...其他文章
],
"total": 文章总数
}
查询主表基本信息
其中data
就是文章数组,其中的文章基本信息都来源于article
表,这个可以通过SELECT
语句查询得来。
SELECT id,
article_name,
poster,
read_num,
summary,
create_time,
update_time
FROM article
WHERE private = 0
AND deleted = 0
ORDER BY create_time DESC
LIMIT 0, 10;
通过WHERE
来加上一些限定条件,避免私密文章或者已逻辑删除的文章被查出。
第一页通常是看最新发布的文章,所以我们使用ORDER BY
和DESC
实现一个按创建时间降序查询。
最后是使用LIMIT
做一个偏移和数量限制,本质上也就是分页查询。
分页总数怎么查?
有了列表,就可以在 nodejs 响应中返回 data
数组了,但是文章总数total
怎么来呢?这里提供两种方式,但是性能的对比我就不擅长了,请自行查阅相关资料,毕竟咱不是专业后端开发。
第一种,我们知道 MySQL 提供了 COUNT
函数,它是可以提供总数统计的。
SELECT COUNT(*) FROM article;
第二种,利用SQL_CALC_FOUND_ROWS
和FOUND_ROWS()
也可以做到同样效果。
SELECT SQL_CALC_FOUND_ROWS
id,
article_name,
poster,
read_num,
summary,
create_time,
update_time
FROM article
WHERE private = 0
AND deleted = 0
ORDER BY create_time DESC
LIMIT 0, 10;
SELECT FOUND_ROWS() as total;
那么到底用哪种方式性能更好呢?其实我心里也没底,之前也没有过多关注这个问题,因为脱离实际情况的性能优化都是扯淡。今天写到这里时,顺手查询了一下 MySQL 官方手册,发现 MySQL 推荐我们使用 COUNT(*)
。
这,,,我好像第一版实现就是用的 COUNT(*)
,后面看了一些相关博客,才改成了FOUND_ROWS
,这就有点尴尬了,哈哈哈。此问题具体见The SQL_CALC_FOUND_ROWS query modifier and accompanying FOUND_ROWS() function are deprecated。
但是我仔细想了一下,COUNT(*)
有一点不好的在于,当查询语句带了 WHERE
限定条件时,前后语句的条件必须得一致,如果漏了条件就容易出事!
举例,当我们只查询 id 大于 200 的分页数据时,使用 COUNT(*)
很容易忘记写条件,而使用 FOUND_ROWS()
就不用太过于担心,因为它与 SQL_CALC_FOUND_ROWS
修饰符一起保证了前后是一致的。
针对 COUNT(*)
的这种问题,可能就需要对 SQL 语句的调用做封装了,避免人为出错,或者是不是通过 ORM 等工具解决这个问题。我目前还是裸写 SQL 比较多,后续再考虑上 ORM。
分页过程的关联表信息
拿到了文章主表的基本信息后,我们还需要展示分类、标签、作者等信息,而这些信息是存储在其他表中,关联关系是靠外键或者关系表维护起来的。
我们先看作者信息,在设计数据库时,我考虑的是一篇文章只有一个作者,所以文章和作者的关系是一对一,而一个作者可以有多篇文章。针对这种关系,我们使用外键约束即可,在文章表中使用外键author_id
去引用用户表的主键id
。
在查询作者信息时,通过LEFT JOIN
就能带出作者名。
SELECT SQL_CALC_FOUND_ROWS
a.id,
// ......省略部分 article 表字段
a.update_time,
u.nick_name AS author
FROM article a
LEFT JOIN user u ON a.author_id = u.id
WHERE a.private = 0
AND a.deleted = 0
ORDER BY a.create_time DESC
LIMIT 0, 10;
SELECT FOUND_ROWS() as total;
针对文章分类信息,因为一篇文章可能属于多个分类,而一个分类下也能有多篇文章,这是一种多对多关系。这里采用的是关系表作为中间表来维护关系。我们继续用LEFT JOIN
来查出分类名称。
SELECT SQL_CALC_FOUND_ROWS
a.id,
// ......省略部分 article 表字段
a.update_time,
u.nick_name AS author,
c.category_name
FROM article a
LEFT JOIN user u ON a.author_id = u.id
LEFT JOIN article_category a_c ON a.id = a_c.article_id
LEFT JOIN category c ON a_c.category_id = c.id
WHERE a.private = 0
AND a.deleted = 0
ORDER BY a.create_time DESC
LIMIT 0, 10;
SELECT FOUND_ROWS() as total;
分类数据是关联出来了,但同时我们也发现了一个问题,部分同一个id
值的文章(也就是同一篇文章)出现了两次以上。
这是因为有的文章关联了2个以上的分类,通过左连接查询自然就会出现多条记录。此时我们要用到分组,也就是 GROUP BY
;同时为了将合并后的分类信息作为一列展示,我们还需要用到 GROUP_CONCAT()
。
SELECT SQL_CALC_FOUND_ROWS
a.id,
// ......省略部分 article 表字段
a.update_time,
u.nick_name AS author,
GROUP_CONCAT(DISTINCT c.category_name SEPARATOR ",") AS categoryNames
FROM article a
LEFT JOIN user u ON a.author_id = u.id
LEFT JOIN article_category a_c ON a.id = a_c.article_id
LEFT JOIN category c ON a_c.category_id = c.id
WHERE a.private = 0
AND a.deleted = 0
GROUP BY a.id
ORDER BY a.create_time DESC
LIMIT 0, 10;
SELECT FOUND_ROWS() as total;
这样我们就离想要的结果越来越近了。
类似地,我们可以把分类 id,标签 id,标签 name 等信息也关联出来。用到分类 id,主要是为了方便提供分类页面的链接,这样就可以实现点击分类名称跳转到分类的详情页面,标签也是同理。
数据库部分设计大概就讲到这里了,后端 nodejs 代码主要就是对以上逻辑的封装,不再展开叙述,具体可以 clone 源码查看。
分页的前端呈现
前端部分大家都比较熟悉了,不太需要深入分析。分页模型中,前端列表永远只展示当前页的数据,也就是 data 返回什么,就展示什么,不存在拼接数据问题。
滚动加载的前端呈现
滚动加载与分页模型最大的不同在于,数据是需要拼接起来的,每查到一页新数据,都需要通过concat
等手段将数组拼接起来。
随着不断滚动呢,数据会越来越多,如果为了性能考虑,可能还会出现虚拟滚动等需求;而为了视觉美观效果,则会出现不定高自适应瀑布流的需求。不过这些,都不在本文研究范围之内,仅引出一些拓展的话题!
小结
本文主要分享了我在设计分页和瀑布流业务时的一些思考,主要讲的也是核心的数据设计思路,而业务代码部分则没有选择重点叙述,感兴趣的朋友可以简单看看源码,链接都附在文章开头了。
- 专栏导航:Vue3+TS+Node打造个人博客(总览篇)