博客项目(前端小项目练习)

前言

利用 express 框架实现多人博客管理系统的用户管理功能和博客管理功能后,个人觉得有必要对其中涉及的以前没有接触或者虽然接触过但是没有应用的做一下记录。纯粹是针对个人学习而写的笔记,没有去分功能模块去分析各部分代码(虽然那样会很清晰和全面,但作为个人笔记而已,我认为没有必要)。所以如果你碰巧看到这篇笔记,觉得乱的话请见谅。主要涉及的第三方模块有 bcrypt、Joi、mongoose-sex-page、formidable 和 config 等。还涉及一个重要的知识点 cookie 和 session 。

1. 用户管理功能

1.1 相关页面截图

1、登录界面(取消提交按钮默认的点击提交行为进行前端验证,后端也需进行验证后再比对信息判断是否登录通过)。

在这里插入图片描述

2、用户管理
在这里插入图片描述
3、添加用户和信息填写不符合要求时界面
在这里插入图片描述
在这里插入图片描述

4、修改用户(如果输入密码不正确修改失败)

在这里插入图片描述

5、删除用户

在这里插入图片描述

1.2 涉及知识

1、jQuery 提供的 serializeArray() 方法

serializeArray() 方法通过序列化表单值来创建对象(name 和 value)的数组。表单元素或者表单本身调用这个方法时返回的是一个包含多个表单名值属性的对象。例如 [{name: ‘emial’, value: ‘xx@xx.com’}, {name: ‘uname’, value: ‘TKOP_’}],可以封装如下函数得到我们常用的表示形式。

function serializeToJson(form) {
    let result = {};
    let arr = $(form).serializeArray();
    arr.forEach(element => {
        result[element.name] = element.value;
    });
    return result;
};

2、外链文件要使用绝对路径

3、虽然前端存在某些验证操作,但是在后端也需要进行二次验证。防止客户端禁用 js 脚本等情况。

4、密码加密模块 bcrypt。以明文的方式保存密码很不安全。在数据库被入侵的情况下,明文密码会随同用户的其他信息一起暴露给别人。在这里使用的加密模块使用的 bcrypt 模块哈希加密是单程加密方式:123 => xxxx ,只能进行加密不能解密。但是仍可以进行暴力解码(不断进行加密操作并比对明文加密后和旧密码的数据),因此可以在加密的密码中加入随机字符串增加密码被破解的难度。

// 导入bcrypt模块
const bcrypt = require('bcrypt');
// 生成随机字符串gent(generate)生成salt盐参数越大越复杂
let salt = await bcrypt.genSalt(10);
// 对密码进行加密
let psw = await bcrypt.hash(password, salt);

如下图为数据库中保存的加密后的密码:

在这里插入图片描述

// 密码比对compare方法内部进行的操作:
// 对明文密码进行同样的加密步骤后将其与数据库中的加密密码比较
let isEqual = await bcrypt.compare('明文密码', '数据库中的加密密码');

这个模块在使用前依赖大量的其他模块,必须同时下载后才可以使用。有 python 2.x 解释器、node-gyp、windows-build-tools。

5、了解 http 协议的无状态性。理论上,一个用户的所有请求操作都应该属于同一个会话,而另一个用户的所有请求操作则应该属于另一个会话,二者不能混淆。而 Web 应用程序是使用 http 协议传输数据的,http 协议具有无状态性。一旦客户端与服务器数据交换完毕,客户端与服务器的连接就会关闭,再次交换数据则需要建立新的连接,这意味着服务器无法从连接上跟踪会话。

6、cookie 与 session 的知识。为了解决 http 协议的无状态性使用的一种会话跟踪机制。
cookie : 浏览器在电脑硬盘中开辟的一块内存空间,主要供服务器存储数据。

  • cookie 中的数据是以域名的形式进行区分的。
  • cookie 中的数据具有有效时间,超过该时间数据会被浏览器自动删除。
  • cookie 中的数据会随着请求被自动发送到服务器。

session : 存储在服务器端内存中的一个对象,在 seesion 对象中可以存储多条数据,每条数据都具有一个 seesionid 属性作为唯一标识。

node.js 中利用 express 框架第三方模块 express-session 实现 cookie 和 session 。

const session = require('express-session');
app.use(session({ secret: 'scret key'}));


// 在用户登录路由处理函数中在登录成功后设置cookie信息
req.session.userName = user.userName

引入 express-session 模块后返回的是一个请求处理函数,调用返回的方法后则在服务器端创建了一个 session 对象。使用 use 中间件拦截所有请求并交由中间件函数 session() 进行处理。session 函数内部会为 req 对象添加一个 session 属性,值是一个在用户登录成功后保存用户信息的对象。在向 session 对象中保存数据时,会自动生成 sessionid 属性。sessionid 是当前会话(存储数据)的唯一标识。然后将 sessionid 存储在客户端的 cookie 当中,当客户端再次访问服务器端的时候。方法会拿到客户端传来的 cookie 并根据其中的 sessionid 属性从 cookie 对象中找到用户信息(识别会话),这样就建立了客户端和服务器的联系。

其中 session() 方法中传递的参数表示一个用于加密 cookie 信息的密钥,secret 属性的值是可以自定义的。密钥是用来加密 cookie 信息的,当向客户端存储数据时,需要使用密钥对数据进行加密,而服务器接收到 cookie 时,需要使用密钥进行解密。客户端是无法获取密钥所以保存于浏览器中的 cookie 是加密后的信息。如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KO7X6RRv-1617912966314)(https://imgtu.com/i/ctZW59)]

https://z3.ax1x.com/2021/04/08/ctZRUJ.png

注意:在服务器重启后,session 对象数据丢失(因为是保存在服务器内存中的)。

需要了解更加详细有关知识可以看看这里:cookie 与 session 的详解与区别

7、登录拦截(用户还未登录时,在其意图通过地址输入的方式访问只有登录用户才能访问的页面时,将页面重定向至登录页面)。

// 请求处理函数/middleware/loginGuard
module.exports = (req, res, next) => {
	// 判断(用户访问的是其他页面并且没有登录)
    if (req.url != '/login' && !req.session.userName) {
    	// 重定向回到登录页面
        res.redirect('/admin/login');
    } else {
        next();
    }
}

// app.js中使用use中间件拦截请求
app.use('/admin', require('./middleware/loginGuard'))

8、将前端传递的数据保存至数据库前(用户注册)需要使用第三方库 Joi 对其验证是否符合特定规则(进行请求参数格式验证)后才能将其增加至数据库,它充当 js 对象的规则描述语言和验证器(验证数据规则)。

const Joi = require('joi');
const schema = {
	userName: Joi.string().alphanum().min(3).max(30).required().error(new Error('错误信息')),
	password: Joi.string().regex(/^[a-zA-Z0-9]{3,30}$/),
	access_token: [Joi.string(), Joi.number()],
	email: Joi.string().email()
};
// 对前端过来的数据进行验证通过(当然还要判断邮箱是否占用)后方可将其添加进数据库
Joi.validate(formData, schema);

无论是请求数据格式不符合要求还是邮箱被占用发生的错误均交由错误处理中间件进行处理。将错误对象通过 next() 函数传递给错处处理中间件时必须使用 JSON.stringify() 将其转换为 json 字符串,在错误处理中间件中再使用 JSON.parse() 将其解析为 json 对象。

9、数据分页功能在不使用第三方模块 mongoose-sex-page 的情况下的实现步骤:

  • 使用集合的 countDocuments() 方法统计符合某些条件的文档的总数。
  • 规定每页展示的文档数目和计算总页数。
  • 将相关数据与模板进行拼接渲染,在渲染时结合总页数设置不同的页码按钮。
  • 不同的页码按钮对应不同的请求(传递的参数不同),服务器根据参数借助集合的 skip() 和 limit() 方法查询数据和进行模板拼接。
  • 下一页和上一页按钮功能的实现则是将请求的参数加 1 或者减 1 即可。但是需要注意的是在模板渲染前需要判断参数的值是否是第一页或者最后一页,根据判断结果决定是否显示按钮。而且在进行加或者减时需要注意数据类型的隐式转换。
	<!-- 分页 -->
    <ul class="pagination">
        <li style=" display:<%= page == 1 ? 'none' : 'inline' %>">
            <a href="/admin/user?page={{page - 1}}">
				<span>&laquo;</span>
			</a>
        </li>
        <% for(var i = 1; i <= pages; i++){ %>
        <li><a href="/admin/user?page={{i}}">{{i}}</a></li>
        <% } %>
        <li style=" display:<%= page == pages ? 'none' : 'inline' %>">
             <a href="/admin/user?page={{page - 0 + 1}}">
			     <span>&raquo;</span>
			 </a>
        </li>
    </ul>

10、处理添加和修改请求使用的是同一个路由(user-edit)。响应时渲染的是同一个模板(user-edit)。但是模板中的表单提交地址需要动态设置。并且如果是修改操作还需将当前用户的信息拼接到模板中。因此在发送添加或者修改的请求时虽然路径相同但是需要使用参数(用户 id)将它们进行区分。如果是添加操作,在渲染表单页面时将按钮文字改为“提交”,还需将表单提交地址设置为添加用户的处理路由对应的地址。如果请求中带有用户 id 参数则表示进行用户修改操作,渲染表单页面时将按钮文字改为“修改”。渲染时根据用户 id 查询相关信息(除了密码)拼接在模板中,同时表单提交地址也需要动态进行设定(请求路径改为修改功能路由对应的地址)。如下为用户编辑功能对应的路由(user-edit):

const { User } = require('../../model/user')
// 处理修改按钮和添加按钮发送请求的路由
// 修改按钮的请求localhost/admin/user-edit?id=xxxxxx
// 添加按钮的请求localhost/admin/user-edit
module.exports = async(req, res) => {
    const { message, id } = req.query;
    if (id) {
        // 修改功能
        let user = await User.findOne({ _id: id });
        res.render('admin/user-edit', {
        	// 获取某次体提交表单后的错误信息
            message: message,
            // 用户信息
            user: user,
            // 执行的操作
            operation: 'modify', 
            // 用于渲染时指定表单提交请求
            link: '/admin/user-modify?id=' + id 
        });
    } else {
        // 添加功能
        res.render('admin/user-edit', {
            message: message,
            operation: 'add',
            link: '/admin/user-edit'
        });
    }
};

在回顾代码时存在一个疑惑,其实如果不只是回顾代码(我是写完后才看代码写的笔记)而是顺着代码编写逻辑看下去就不会出现这个疑惑。再次看时想到的是无论添加或者修改用户信息都是先渲染表单不会出现错误(不涉及错误处理),提交表单后进入下个请求再进行错误处理,这样就不需要(也不会存在)传递错误信息了呀,怎么以前自己写的时候传递了错误信息呢?讲到底还是自己对路由的理解不够透彻。因为当时是先实现了用户添加功能,在实现添加功能时使用错误处理中间件重定向的路径是 user-edit 并将错误信息作为 get 参数传递了过去。然后在处理重定向请求时渲染了 user-edit.art 模板,拼接数据时需要用到 message 。其实无论编写的顺序如何,主要原因还是错误处理中间件重定向回了这个路由

涉及的路由结构如下:
在这里插入图片描述
错误处理中间件:

app.use((err, req, res, next) => {
    const result = JSON.parse(err);
    let getArg = [];
    // 处理传递过来的错误err中的参数
    for (let k in result) {
        if (k != 'path') {
            getArg.push(k + '=' + result[k]);
        }
    };
    // 因为错误信息中不一定只包含 message和path,还有id属性
    res.redirect(`${result.path}?` + getArg.join('&'));
})

11、用户的删除功能的实现

  • 在确认删除框中添加隐藏域用于存储需要删除用户的 ID 值。
  • 为删除按钮添加自定义属性用于存储要删除用户的 ID 值。
  • 为删除按钮添加点击事件,点击后将 ID 属性值存储在表单的隐藏域中。
  • 为删除表单添加提交地址以及提交方式。
  • 在服务器端建立删除功能路由。
  • 接收客户端传递过来的 id 参数。
  • 根据 id 删除用户。

隐藏域表单的 type 属性为 hidden 。在这个模块添加了一个自己想到的功能。删除掉一个用户时并不是简单重定向回到用户管理页面的第一页,而是根据删掉用户后该页是否还有用户,如果有则重定向回到当前页,如果没有则重定向回到上一页。当然页数不会是负数,如果第一页的用户都删完了也是回到第一页。相应的在添加用户时添加成功后直接重定向至显示有该用户的最后一页,而不是第一页。

2. 文章管理功能

2.1 相关页面截图

1、文章管理(第一页和最后一页时左右按钮分别隐藏)

在这里插入图片描述
在这里插入图片描述

2、添加文章
在这里插入图片描述3、修改文章

在这里插入图片描述

4、删除文章


在这里插入图片描述

2.2 涉及知识

1、前端如果没有实现用户管理和文章管理点击选中的样式,在后端可以在 locals 对象下添加一个属性用以标识当前使用功能并在模板中判断实现选中样式。

2、添加和编辑文章功能表单提交是涉及文件上传功能(文章封面的上传)。涉及文件上传的表单在提交数据时必须以二进制的形式提交,所以必须将表单的 enctype(表单数据的编码类型)属性由 application/x-www-form-urlencoded(默认)更改为 multipart/form-data(将表单数据编码为二进制类型)值

3、在解析前端表单提交传递过来的二进制数据时使用到的是第三方模块 formidable 。它可以解析表单(无论是 get 请求还是 post 请求参数),用于解决二进制数据的解析。

    // 创建表单解析对象
    const form = new formidable.IncomingForm();
    // 设置文件上传的路径
    form.uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads');
    // 配置是否保留文件后缀(默认是false)
    form.keepExtensions = true;
    // 对表单进行解析,回调函数err参数表示表单解析异常对象(没有则为null)
    form.parse(req, async(err, fields, files) => {
        await Article.create({
            title: fields.title,
            author: fields.author,
            publishDate: files.publishDate,
            // 数据库中存储的只是文件的保存路径,cover为表单中文件数据的name属性
            cover: files.cover.path.split('public')[1],
            content: fields.content
        })
        res.redirect('/admin/article');
    });

注意:form.parse() 方法中的回调函数的三个参数分别表示的内容

  • err : 表单解析异常时自动传递的错误对象,如果正常则为 null 。
  • fields :保存普通表单数据的对象
  • files :保存和上传有关的二进制文件数据,其中包含有文件的保存路径(path)、文件大小(size)、文件名称(name,上传后防止命名冲突系统会自动命名而不是使用这个命名)、文件类型(type)和文件最后修改时间(mtime)

4、客户端用户文章封面图片选择后预览功能的实现需要使用到 js 内置的 FileReader() 构造函数。

    // 选择文件上传控件
    var file = document.getElementById('file');
    var preview = document.querySelector('#preview');
    // 当用户选择完文件以后
    file.onchange = function () {
        // 1 创建文件读取对象
        var reader = new FileReader();
        // 用户选择的文件列表(如果设置文件上传控件multiple属性可进行多选)
        // console.log(this.files[0])
        // 2 读取文件
        reader.readAsDataURL(this.files[0]);
        // 3 监听onload事件
        reader.onload = function () {
            // console.log(reader.result)
            // 将文件读取的结果显示在页面中
            preview.src = reader.result;
        }
    }

5、在添加文章的表单中作者我直接使用的是用户名,所以在添加文章时用户字段不能直接使用表单该数据而是使用后端全局共享的 app.locals.userInfo._id 属性,不然会导致插入数据库失败。

上面展示文件添加功能的截图提交后数据库新添加的文章数据如下图:

在这里插入图片描述

6、使用第三方模块 mongoose-sex-page 实现分页展示功能。

const pagination = require('mongoose-sex-page');
pagination(Article).page(2).size(2).dispay(3).populate('author', {_id: 0, userName: 1}).exec();

各个方法中参数的含义以及得到的查询数据如下:

  • page : 当前页码
  • size : 一页显示几条数据
  • display : 显示几页数据
    在这里插入图片描述

查询时你会发现当你的数据不够一页时,会自动将最后不够一页的数据忽略掉。所以我自己做了一些小处理,也不知道是这个模块的 bug 还是自己对它的了解不够深入。

其他有关文章管理的功能的实现和用户管理功能的实现差不多,在此没有涉及其他知识,略过。

3. 首页

3.1 相关功能截图

1、文章展示功能

在这里插入图片描述

2、文章详情

在这里插入图片描述

3、登录后评论功能

在这里插入图片描述

3.2 涉及知识

这部分也没有涉及很多,只是将文章进行展示。添加的主要功能是退出登录的功能和评论功能。

1、在登录时判断用户权限(是管理员还是普通用户),根据判断结果跳转至首页或者管理页面。退出登录功能实现时需要删除 session 和 cookie 外主要时删除用于判断用户是否登录的模板共享数据。退出登录后重定向回首页。

2、在退出登录时遇到的一个逻辑问题:退出登录请求被登录拦截路由拦截,由于时登录状态所以会直接重定向回首页。所以在登录拦截模块不应在判断是否登录,登录账号是否是普通用户后直接就进行重定向。而是再判断是否是退出登录请求,如果是则不进行重定向,并将请求交由下个路由处理。

    // 删除session
    req.session.destroy(function() {
        // 删除模板中的共享信息
        req.app.locals.userInfo = null;
        // 删除cookie
        res.clearCookie('connet.sid');
        res.redirect('/home/');
    });

4. 其他

1、新建并配置系统环境变量 NODE_ENV 用于区分开发环境和生产环境。在程序中使用全局对象下的 process 对象属性的 env 属性可以获取全部系统环境变量,获取用于区分运行环境的 NODE_ENV 系统环境变量并判断其值。最后实现在不同运行环境下运行不同的代码(进行不同的项目配置),例如在开发环境连接开发人员自己创建的数据库,而生产环境上连接的是团队数据库负责开发的数据库等。在开发环境和生产环境中配置系统环境变量分别如下图所示:

在这里插入图片描述
在这里插入图片描述根据执行环境的不同进行不同的配置,如下在开发环境下运行时可以使用 morgan 第三方模块自动控制台输出浏览器请求信息帮助开发。

// 引入第三方模块morgan
const morgan = ruquire('morgan');
// 获取系统环境变量 NODE_ENV
const env = process.env.NODE_ENV;
if (env == 'development') {
	// 开发环境
	app.use(morgan('dev'));
} else {
	// 生产环境
}

开发环境下控制台自动输出浏览器请求信息如下如所示:
在这里插入图片描述
2、第三方模块 config

作用:允许开发人员将不同运行环境下的应用配置信息抽离到单独的文件夹中,模块内部自动判断当前应用的运行环境,并读取对应的配置信息,极大提高应用配置信息的维护成本,避免了当前运行环境重复多次切换时,手动到项目代码中修改配置信息。

使用步骤:

  1. 下载 config 第三方模块。
  2. 项目根目录下新建 config 文件夹。
  3. config 文件夹下新建 default.json、development.json、production.json 等文件。
  4. 项目中导入模块。
  5. 使用模块内部提供的 get 方法获取配置信息。

获取相应的配置信息时,首先会根据当前运行环境到对应的文件中查找配置项,如果没有该配置项则会到 default.json 文件中查找。

// development.json文件
{
    "db": {
        "user": "TKOP_",
        "ip": "localhost",
        "port": 27017,
        "database": "blog"
    }
}
// 数据库连接模块connet.js文件
const mongoose = require('mongoose');
const config = require('config');

const { user, pwd, ip, port, database } = config.get('db');
mongoose.connect(`mongodb://${user}:${pwd}@${ip}:${port}/${database}`, { useNewUrlParser: true }, { useUnifiedTopology: true })
    .then(() => {
        console.log('数据库连接成功');
    })
    .catch(() => {
        console.log('数据库连接失败');
    })

3、可以将某些敏感配置信息存储在环境变量中

  • config 文件中新建 custom-environment-variables.json 文件。
  • 配置项属性的值填写系统环境变量的名字。
  • 项目运行时 config 模块查找系统环境变量,并读取其值作为当前配置项属性的值。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eoYuVvt5-1618238928951)(https://z3.ax1x.com/2021/04/12/cDOK1K.png)]

{
	"db": {
		"pwd": "DATA_PWD"
	}
}

总结

由于时间、能力和精力的原因,个人无法将所有的功能实现进行详细准确地描述。只是进行零碎的记录,望见谅。个人觉得这个项目虽然不是很复杂,没有使用到后面的 ajax 等技术。但是我在实现这个项目的过程中,加深了对路由、session 和 cookie 等知识的理解,并且使我能更加熟练地使用 express 框架和模板引擎。在此过程也遇到了许许多多地困难和犯过种种或低级或平常的错误,但是解决问题后实现某个功能带来的愉悦感又是那么的真实。

  • 0
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值