简单做个博客。
功能点:注册、登录、cookie、权限控制、文章列表、文章详情、文章目录、点赞、评论、分页加载
整体架构:
前端
使用vue-cli3创建项目
npm install -g @vue/cli
vue create hello-world
为了避开烦人的 eslint,选择了手动选择特性:
同时没有选择 Linter/Formatter,使用 vscode 中的插件 prettier 和 vetur 配合格式化代码。
使用axios封装http请求方法
参考了vue中Axios的封装和API接口的管理,对网络请求进行了发封装。
网络请求统一放在/src/request
中,config.js
中是基本配置信息:http.js
中封装请求拦截、响应拦截、错误统一处理。api.js
中是网站所有接口,将其导出后,挂载到 vue.prototype.$api
上,这样全局可以使用 this.$api.xxx
使用接口:
然后在main.js
引入并挂载:
cookie/session
博客采用cookie/session的方式来记录会话状态,在app.vue
的created
生命周期函数中通过checkLogin()
接口查询用户登录状态,具体的逻辑为:
首页分页加载
let documentEle = document.documentElement
let needLoadMore =
documentEle.scrollTop + documentEle.clientHeight + 50 > documentEle.scrollHeight;
if (needLoadMore && !this.nomore) {
this.loadingMore = true;
//暂时不能再滚动加载数据
this.nomore = true;
this.loadMoreArticle();
}
document.documentElement.scrollTop
:文档滚动的距离document.documentElement.clientHeight
:文档在可视范围内的高度document.documentElement.scrollHeight
:文档总高度
判断思路是:视口的高度 + 文档的滚动距离 >= 文档总高度,可以预留50的距离做预加载。前端每一次判断触底后,会向后端请求下一页数据,并返回 nomore
字段,表示是否还有未加载文章。这里有个细节是:每一次触底后手动将 nomore
设为 true
,后端返回 nomore
后再修改其值,这样做的目的是防止在请求返回前再次发送请求。
参考:搞清clientHeight、offsetHeight、scrollHeight、offsetTop、scrollTop
如何展示博文
没有实际做博客之前,以为每一篇博文都是通过单独写html标签的方式排版的...然后通过别人的项目,发现了正确的做法是:
使用markdown/富文本编辑器编辑文章,生成html
片段,在vue中使用v-html
语法插入该html
片段,文章便以html
标签的形式渲染出来可。博客中使用的markdown编辑器是mavonEditor,另外使用highlightjs
高亮代码。
<div v-html="articleInfo.contentHtml" class="article-container" ref="content"></div>
提取目录
extractCatalog() {
let contentElementRef = Array.from(
this.$refs.content.querySelectorAll("h1,h2,h3,h4,h5,h6")
);
contentElementRef.forEach((item, index) => {
item.id = item.localName + "-" + index;
this.catalogList.push({
tagName: item.localName,
href: `#${item.localName}-${index}`,
text: item.innerText
});
});
}
上一小节中,将文章的html
片段渲染到了div
容器元素中,接下来可以使用querySelectorAll("h1,h2,h3,h4,h5,h6")
函数来找出文中所有的标题Dom节点,querySelectorAll()
的好处在于它会按照传入参数的顺序进行查找,所以不用担心乱序。获取到目录后对各级目录编号,以便生成锚点进行跳转。
判断对应节是否出现
watchPageScrollFunc() {
let contentElementRef = Array.from(
this.$refs.content.querySelectorAll("h1,h2,h3,h4,h5,h6")
);
for (let index = 0; index < contentElementRef.length; index++) {
const viewPortHeight =
window.innerHeight ||
document.documentElement.clientHeight ||
document.body.clientHeight;
const elementHeight = contentElementRef[index].clientHeight;
const el = contentElementRef[index];
const top =
el.getBoundingClientRect() &&
el.getBoundingClientRect().top + elementHeight;
if (top <= viewPortHeight && top > 0) {
this.firstVisibleElemetHref = this.catalogList[index].href;
break;
}
}
}
window.addEventListener("scroll", this.watchPageScrollFunc);
判断方法:元素上边到视口上边的距离 + 元素自身高度 <= 视口高度 && 元素上边到视口上边的距离 + 元素自身高度 > 0
其中,获取元素到视口上边的距离用到:getBoundingClientRect()
,某个元素相对于视窗的位置集合。集合中有top, right, bottom, left等属性:
rectObject = object.getBoundingClientRect();
rectObject.top // 元素上边到视窗上边的距离;
rectObject.right // 元素右边到视窗左边的距离;
rectObject.bottom // 元素下边到视窗上边的距离;
rectObject.left // 元素左边到视窗左边的距离;
在整个文章的Dom结构中,使用querySelectorAll("h1,h2,h3,h4,h5,h6")
去搜索所有标题类的Dom节点,每一次滚动都去依次动态检查这些标题元素距离视口上方的距离,找到第一个出现在视口中的Dom元素就返回。另外加上节流函数可优化性能。
评论的实现
评论数据采用的数据结构如下,这里只做到了二级评论,数据结构定下来,具体的实现还是比较简单的。
let comments = [
{
author: "admin",
content: "留言1",
articleId: "9",
time: "2020-04-12 10:59",
id: "0",
replyList: [
{
author: "ghm",
content: "回复留言1",
articleId: "9",
replyTo: "admin",
time: "2020-04-12 10:59",
id: "0-0"
}
]
},
{
author: "admin",
content: "留言2",
articleId: "9",
time: "2020-04-12 10:59",
id: "1",
replyList: [
{
author: "ghm",
content: "回复留言2",
articleId: "9",
replyTo: "admin",
time: "2020-04-12 11:00",
id: "1-0"
}
]
}
];
可添加表情的输入框
第一版的实现方式:在<input>
中直接插入emoji
的unicode
,展示效果如下所示:
这样做的问题在于:展示效果没有图片好,并且在不同的浏览器中会展现出不同的效果。果断换用图片形式展示表情,但是<input>
是不能插入<img>
标签的,于是参考了掘金的实现方式,使用contenteditable
这个css属性将普通Dom元素变为可编辑的Dom元素,这样就可以使用appendChild()
的方式将<img>
插入,最终的显示效果也是明显优于直接使用emoji
的。
<div
class="textarea"
ref="inputContent"
contenteditable="true"
autocomplete="off"
:placeholder="placeholder"
draggable="false"
spellcheck="false"
></div>
后端
数据库表的设计
表1:文章列表:
表2: 文章详情:
表3:用户详情
webServer
使用了 eggjs 作为 web服务器,eggjs 的文档很完善,照着文档撸代码就够了。eggjs奉行约定优于配置,有以下优点:
- 统一目录结构
- 统一分层设计:router-controller-service-model
- 全套安全、日志、测试方案
- 高扩展性的插件机制
初始化
npm init egg --type=simple
npm i
目录结构
路由
框架约定在app/router.js
文件中统一配置所有路由
// app/router.js
module.exports = app => {
const { router, controller } = app;
router.get('/user/:id', controller.user.info);
};
完整的理由定义如下:
router.verb('router-name', 'path-match', middleware1, ..., middlewareN, app.controller.action);
注意事项:
- 在 Router 定义中, 可以支持多个 Middleware 串联执行
- Controller 必须定义在
app/controller
目录中。 - 一个文件里面也可以包含多个 Controller 定义,在定义路由的时候,可以通过
${fileName}.${functionName}
的方式指定对应的 Controller。 - Controller 支持子目录,在定义路由的时候,可以通过
${directoryName}.${fileName}.${functionName}
的方式制定对应的 Controller。
框架中一些常用的路由用法:
获取查询参数
// curl http://127.0.0.1:7001/search?name=egg ctx.query.name
获取路径参数
// app/router.js module.exports = app => { app.router.get('/user/:id/:name', app.controller.user.info); }; // app/controller/user.js exports.info = async ctx => { ctx.body = `user: ${ctx.params.id}, ${ctx.params.name}`; }; // curl http://127.0.0.1:7001/user/123/xiaoming
post请求boby的获取
ctx.request.body
重定向
// 内部路由重定向 app.router.redirect('/', '/home/index', 302); // 外部路由重定向 ctx.redirect(\`http://cn.bing.com\`);
控制器(Controller)
简单的说 Controller 负责解析用户的输入,处理后返回相应的结果。框架推荐 Controller 层主要对用户的请求参数进行处理(校验、转换),然后调用对应的service方法处理业务,得到业务结果后封装并返回:
- 获取用户通过 HTTP 传递过来的请求参数。
- 校验、组装参数。
- 调用 Service 进行业务处理,必要时处理转换 Service 的返回结果,让它适应用户的需求。
- 通过 HTTP 将结果响应给用户。
定义:
所有的 Controller 文件都必须放在 app/controller 目录下,可以支持多级目录,访问的时候可以通过目录名级联访问。
// app/controller/post.js
const Controller = require('egg').Controller;
class PostController extends Controller {
async create() {
const { ctx, service } = this;
const createRule = {
title: { type: 'string' },
content: { type: 'string' },
};
// 校验参数
ctx.validate(createRule);
// 组装参数
const author = ctx.session.userId;
const req = Object.assign(ctx.request.body, { author });
// 调用 Service 进行业务处理
const res = await service.post.create(req);
// 设置响应内容和响应状态码
ctx.body = { id: res.id };
ctx.status = 201;
}
}
module.exports = PostController;
上面定义的PostController
的方法可以通过文件名和方法名的方式使用。
// app/router.js
module.exports = app => {
const { router, controller } = app;
router.post('createPost', '/api/posts', controller.post.create);
}
定义的 Controller 类,会在每一个请求访问到 server 时实例化一个全新的对象,而项目中的 Controller 类继承于 egg.Controller
,会有下面几个属性挂在 this
上:this.ctx
,this.app
,this.service
,this.config
,this.logger
服务(Service)
Service 就是在复杂业务场景下用于做业务逻辑封装的一个抽象层,提供这个抽象有以下几个好处:
- 保持 Controller 中的逻辑更加简洁。
- 保持业务逻辑的独立性,抽象出来的 Service 可以被多个 Controller 重复调用。
- 将逻辑和展现分离,更容易编写测试用例
使用场景:
- 复杂数据的处理,比如要展现的信息需要从数据库获取,还要经过一定的规则计算,才能返回用户显示。或者计算完成后,更新到数据库。
- 第三方服务的调用,比如 GitHub 信息获取等。
Service 文件必须放在 app/service 目录,可以支持多级目录,访问的时候可以通过目录名级联访
app/service/biz/user.js => ctx.service.biz.user
由于它继承于 egg.Service
,故拥有下列属性方便我们进行开发:this.ctx
,this.app
,this.service
,this.config
,this.logger
MySQL
egg提供了 egg-mysql
插件来访问 MySQL 数据库
安装插件:
$ npm i --save egg-mysql
开启插件:
// config/plugin.js
exports.mysql = {
enable: true,
package: 'egg-mysql',
};
最后在 config/config.${env}.js
配置各个环境的数据库连接信息
// config/config.${env}.js
exports.mysql = {
// 单数据库信息配置
client: {
// host
host: 'mysql.com',
// 端口号
port: '3306',
// 用户名
user: 'test_user',
// 密码
password: 'test_password',
// 数据库名
database: 'test',
},
// 是否加载到 app 上,默认开启
app: true,
// 是否加载到 agent 上,默认关闭
agent: false,
};
Sequelize
Sequelize 是 nodejs 社区广泛使用的 ORM 框架,将关系数据库的表结构映射为对象,让开发者使用 js 语法完成数据库操作。另外Sequelize 提供了 Migrations,帮助开发者管理数据库的每一次变更,因此每一次库表的变动都应通过 Migrations 来实现。
egg 提供了集成 Sequelize 的脚手架:
npm init egg --type=sequelize
上线
配置服务器环境
1.一台linux服务器
作者买的阿里云最便宜的ECS云服务器,配置为1核2G,1M带宽,作为学习使用完全够了,操作系统选择的CentOS。然后参照阿里云给出的教程在服务器上部署NodeJs环境,部署mysql。
2.购买域名
在阿里云上购买域名后,按照新手引导设置域名解析,同时设置 www 和 @,网站便可通过 www.xxx.com 和 xxx.com 访问。同时,想要使用域名访问网站,还需要对域名进行备案。
3.本地数据库迁移到云服务器
作者使用的数据库可视化工具是navicat,在navicat中对选中的数据库做转储SQL文件处理,再在云服务器的mysql中新建数据库,运行此SQL文件,便完成了数据库的迁移。
上传文件
1.下载文件传输工具 Xftp
2.部署前端代码
npm run build
打包好后,使用 Xftp 将 dist
文件夹上传到服务器中
3.部署后端代码
将后端代码 git clone
到服务器中
npm i
npm start
配置nginx
1.安装nginx
yum install nginx
安装好的 nginx 会在 /etc/nginx
中
2.使用 Xftp 修改 nginx 配置
找到/etc/nginx
目录下的 nginx.conf
文件,使用记事本打开编辑:
user root;
worker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 80;
server_name localhost;
root /root/project/dt_blog/frontend/dist/;
index index.html;
add_header Access-Control-Allow-Origin *;
location /api {
proxy_pass http://127.0.0.1:7001;
proxy_set_header HOST $host;
}
location / {
try_files $uri $uri/ @router;
index index.html;
}
location @router {
rewrite ^.*$ /index.html last;
}
}
}
其中这两个配置很重要:
location / {
try_files $uri $uri/ @router;
index index.html;
}
location @router {
rewrite ^.*$ /index.html last;
}
网站部署好后,可以正常访问,但是打开二级页面后再刷新,就会404,原因是:v-router设置的路径并不是真实存在的路由,工程中的路由跳转都是通过 js 实现的,而将前端打包好的 dist
目录部署在服务器后,在浏览器中访问根路径会默认打到 index.html 中,但是直接访问其他路径,就没有一个真实的路径与其对应。(参考:https://www.cnblogs.com/kevingrace/p/6126762.html)
做好以上配置后,就可以通过 服务器ip:80
来访问部署好的网站了,但是一直通过 ip 地址访问网站不太合理,那么就需要给网站配置域名。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。