登录后台

页面导航

本文编写于 273 天前,最后修改于 272 天前,其中某些信息可能已经过时。

前言(假装有人看)

距离上一期,已经过去了差不多三周时间了。说好的周报变成了「三周报」, 主要是有一周没有开发进度,就开始鸽了。近两周还是有一些进展的,「文章管理」和「分类管理」这两个模块大体功能已经开发完成了,开发和完善了两个公用组件等等。踩了不少坑,学到了不少东西,有些需要注意的点,觉得有必要写一下。在下面内容之前,强烈推荐先去 github 里把项目的代码拉下来。

后端项目:KiteBlog

前端项目:KiteBlog_Front_Admin

Node (KiteBlog)

经过这两周的摸索,项目结构的基本定下来了。目录主要借鉴了之前学习 node 时候的项目,觉得结构蛮清晰的,就沿用到现在的项目了。

Node (后端)项目目录结构:

KiteBlog
├─ .gitignore
├─ app.js
├─ bin
│ └─ www //express-generator生成的启动项目文件
├─ common
│ ├─ common.js // 一些公用的方法
│ ├─ enumerate.js // 枚举,用于状态码转中文、校验前端传进来的状态码是否正确等等
│ └─ sqlFields.js // 枚举,数据库字段和前端对应(暂时没有用到,准备去掉)
├─ config
│ └─ db.js //配置数据库的基本信息
├─ dao
│ ├─ articleDao.js //DAO,业务逻辑主要写在这里
│ ├─ articleSqlMapping.js //Mapping,手写的SQL语句
│ ├─ categoryDao.js
│ ├─ categoryMapping.js
│ ├─ commentDao.js
│ ├─ commentSqlMapping.js
│ ├─ tokenDao.js
│ ├─ tokenSqlMapping.js
│ ├─ userDao.js
│ └─ userSqlMapping.js
├─ mysql_model.sql // 生成数据库表的文件,运行项目前,请先运行此文件,生成数据库表
├─ package-lock.json
├─ package.json
├─ public
│ ├─ css
│ │ └─ style.css
│ ├─ img
│ └─ js
├─ README.md
├─ routes //每个模块对应的 router, 指定 API路径以及请求方式(GET、POST等)
│ ├─ article.js
│ ├─ category.js
│ ├─ index.js
│ ├─ login.js
│ └─ users.js
└─ views //express-generator 生成的模板页面放在这里,
├─ error.ejs
└─ index.ejs

分类模块(文章分类)

前面讲过,自己在做整个项目之前,是完全没有后端的经验的。在开发分类模块的时候,花了不少时间。从数据库的表设计、字段类型的选用、token验证等等,甚至是 SQL 语法都要一步步学。文章有什么不对的地方,请大佬们赐教,谢谢。

分类表设计

首先是分类表的设计,分类表和文章表是多对多的关系,所以还需要建一张「文章和分类的关系表」

分类表:k_category
文章表:k_article
文章和分类的关系表:k_article_category_relationship

多对多的关系比较简单,主要就是建一张关系表来保存「文章ID」和「分类ID」的对应关系,进行多表查询。

分类表设计就比较麻烦,我在网上看了一些文章,发现主要有两种方案,也叫分层模型:邻接表模型、嵌套集合模型
关于这两种模型可以看这里,还是不懂的话,多搜索几篇看看,看多了自然就有头绪了:MySQL的分层数据管理与无限级分类

KiteBlog 采用了「邻接表模型」,开源博客最常用的模型。它最大的特点就是,给每个分类记录添加一个「parentId」,用于记录它的父级分类。一级分类,也就是最外层分类,我用 parentId 为 0 表示,不传 parentId 默认为 0

我们从表里数据可以看出,当需要获取某个分类下的所有子分类时,需要一层一层的往下找,如果层级非常多的时候,会影响性能。所以一般都会对层级进行限制,一般为3~4级。目前我并没有对层级进行限制。

邻接表模型,还有一个缺点,就是如果删除某个分类的时候,后面的节点就变成孤立的分类。目前我想到了两种解决方法:

  1. 在删除节点的时候,需要先找到分类的所有下级分类,并把下级分类的「parentId」改成当前分类的 「parentId」,这样才能确保分类的上下级关系。
  2. 还有一种做法,比较简单除暴,如果改节点存在下级分类时就不给删除,需要从最小的一层开始删除。孤立的问题就不会出现了

无论是哪种做法,删除分类都要面临一个文章迁移的问题,是直接迁移到它们的上一级分类,还是最高那一级的分类,又或者是默认分类。这个主要是看项目的需求来定。现在项目采用的就是最简单除暴的方法、有下一级分类则不能删除。分类下有文章也不能删除。需要先迁移才能删除。目前迁移功能还没有加个。后续会加上。

前端

分类界面用了 elementUI 的Tree组件,这种结构看层级的时候非常直观清晰。

Tree组件需要使用下方这种数据结构(我叫它树形数据结构),才能渲染出来。

 categoryData:[
  {
    label: "",
    children: [
        {
         label: "",
         children: []
        },
    ],
  },
 ] 

树形数据结构的处理,那到底放在前端来处理还是后端处理比较好呢?从性能方面考虑哪个更靠谱,答案我也不清楚。这里我是在后端处理的。先查出所有分类的数据,根据通过 分类等级(cat_level)和 (cat_parentId)来处理数据,下方为处理数据的代码。 要是有更好的办法或者建议,也请各位大佬多多提点赐教,谢谢。

//区分不同等级的分类
let levelObj = {}
//result 为数据库查询的所有分类记录,根据分类等级 进行分类归类
result.forEach(x => {
    x.children = []
    if (!levelObj[x.categoryLevel]) {
        levelObj[x.categoryLevel] = []
    }
    levelObj[x.categoryLevel].push(x)

})
//保证等级分类从高到低进行处理,先排序。
let levelList = Object.keys(levelObj).sort((a, b) => a - b)
//储存每个分类对应的索引,方便往 children 里面追加数据(还可以优化,最后一级的分类索引其实不用记录,因为是最后一级,不会再往里面追加子分类,所以没必要。)
let categoryIdIndex = {}
levelList.forEach(x => {
    //一级分类
    if (x == 1) {
        dataList.push(...levelObj[x].map((y, i) => {
            categoryIdIndex[y.categoryId] = [i]
            y.categoryStatus = Boolean(y.categoryStatus)
            return y
        }))
    } else {
        //其他级别分类
        levelObj[x].forEach((y, index) => {
            let parentIndex = categoryIdIndex[y.categoryParentId]
            let parent = dataList
            for (let i = 0; i < parentIndex.length; i++) {
                if (i === 0) {
                    parent = parent[parentIndex[i]]
                } else {
                    parent = parent.children[parentIndex[i]]
                }
            }
            if (!parent.children) {
                parent.children = []
            }
            //把子类的文章数加到父类,为了避免混淆,暂时不启用
            // parent.articleCount += y.articleCount
            
            // categoryStatus 为 0 或者 1,转成Boolean型
            y.categoryStatus = Boolean(y.categoryStatus)
            parent.children.push(y)
            categoryIdIndex[y.categoryId] = [...parentIndex, parent.children.length - 1]
        })
    }
})

视图更新

由于是树形数据结构,项目使用的是vue2.x,数据在嵌套层级比较多的时候,会监听不到对象属性的变化,视图(界面)没法进行实时更新。 在点击编辑修改分类名称的时候需要显示输入框,要更新视图,所以需要使用 this.$set() 来给属性赋值,vue3.0 使用 Proxy 解决了这个问题。有时间得深入了解一下。

解决新增分类没有ID问题

我们都知道在界面上添加一个分类时,如果是一级分类,就直接在 categoryData 里添加一项数据,要是添加的是子级分类,则往 父级分类的 children 里面添加追加一项。而每追加一项,我们就要调用一次新增分类的接口。给后端传输的数据中也一定会包含一个父级分类的ID(除最外层的分类外)。

那假设我们现在添加了个子级,子级里再添加一个子级,这时候第一个添加的子级是没有ID的,那我们怎么办?

有朋友肯定会想到,那再请求一次获取分类的接口不就行了吗?这样新添加数据就有ID了。确实是可行,但是绝对不是一个好办法。如果每添加一项就重新请求获取分类的接口,这样会十分影响性能,特别是分类数据特别多的时候。不仅是要调用一个数据量非常大的接口,而且分类的树形组件也会重新渲染。 还有一点就是,如果你有多个分类同时在编辑,你重新请求接口,那些还没保存到数据库的分类怎么办?保存起来再拼接上去?可以做,但显然不现实。

其实有一个十分简单且有效的方法,就是每次请求添加分类接口,数据库里添加之后,就返回当新加分类ID,然后往当前分类对象添加一个 分类ID就解决了。

Tree组件展示数据优化

目前分类采用方案是 ,一次性取出所有分类数据,且树形结构默认是展开的。可以预测到当分类的数据量很大时,渲染的时候会变得越来越长。由于每一条分类记录需要渲染的组件又比较多,开销自然也比较大。

我试了一下七百多条数据,渲染时间大概为 2~3秒,2~3秒看上去勉强还能接受,但滚动、展开子级、switch切换时,有动画产生的时候能明显感觉到不是很流畅。收起子级的时候(特别是子级分成多的时候),界面又变流畅了。

其实分类数能上到 200个已经是蛮多了,但我们做项目的时候,肯定考虑到未来的,万一以后分类扩展到几千个呢?

我脑子里梳理了一下大概想到了几个优化的点:

在优化之前我们还要把搜索功能考虑进去,现在分类模块使用的搜索,是 elementUI Tree组件自带的搜索,支持搜索全部子分类。但有个前提,这个搜索只能在已经存在的分类(已经从后端请求到的数据)里搜索的。下文称「本地搜索」

1、默认不展开子级,效果是立竿见影的,首次加载时间也明显缩短,特别是子级特别多的情况。还有就是「本地搜索」可以直接使用,所有的分类都可以搜索到。这个方法也是改动成本最低的,只需要去掉「default-expand-all」属性就可以了。我觉得杠个2000个分类还是能接受的。

2、懒加载,tree组件是支持懒加载的,我们可以很方便使用。这样就避免一次性渲染全部分类。这样后端就要设计一个查询当前分类下一级分类的接口。如果下级分类,还存在下级分类,就需要提供一个标识,让前端知道是否还能再展开下级,可以结合tree组件的「isLeaf」属性来实现这个功能

后端最起码要递归两层,才能知道子级下面是否还有子级。懒加载方式有个缺点,「本地搜索」是没法搜索到还没加载出来的子分类的。相对于第一种方式,它舍去了搜索的部分功能,换来了加载速度。但要是子级不是特别多或者子级分布比较零散(就是不在一个下拉展示里),我觉得第一种方式可能比它好使。

3、翻页 + 默认不展开子级,如果是一级分类,非常多的时候,就有必要考虑分页了。后端这个翻页是根据一级分页来翻页的(根据子级来分类还没想比较好的实现方法)。这时候搜索就要考虑两种情况了。

使用「本地搜索」,那么他只能搜索当前页的分类。

使用远程搜索,也只能搜索一级分类。

4、懒加载+翻页。

如果一级分类和子级分类都暴多,这个方法是最好使的,这里我觉得实在没必要使用「本地搜索」了,「本地搜索」的优势已经荡然无存。使用远程搜索,也只能搜索一级分类。但从性能上考虑,这个是最有效的方法。

如果你有更好的方法,可以的话,麻烦在下方留言,谢谢。

已有 3 条评论