Jacleklm's Blog

博客项目小结

2020/01/29

概述

来源

该项目是技术胖的一个项目,涉及了前台中台后台,觉得是个不错的练手项目.

概述

博客前台: React Hooks + Next.js + marked + highlight + Ant Design
数据中台: Egg.js(阿里的一个基于 Koa2 的框架) + MySQL
管理后台: React Hooks + Ant Design (不用 Next.js)

Next.js 是一个轻量级的 React 服务端渲染应用框架。有了它我们可以简单轻松的实现 React 的服务端渲染,从而加快首屏打开速度,也可以作 SEO(搜索引擎优化了)。并且路由,webpack 配置等也是框架配置好的。
个人看法:配置太全了,自己写着玩的项目还好,企业级的项目用起来可能不够灵活。

前台

布局

组件内的布局基本都是用 Antd 的 栅格-响应式布局 ,<Col> 的 xs,sm 等这些属性本质上是媒介查询;给 <Row> 添加属性实现 flex 布局 type="flex" justify="center"

组件一般都是用 Antd 的组件组合而成

请求数据

数据来源于请求中台接口

函数组件的 getInitialProps 方法写一个 promise 并返回 promise 的结果(Next.js 框架)

文档
该方法返回的应该是一个普通的 JS 对象;不能用于子组件中,只能用于 pages 页面组件中;该方法只会加载在服务端,所以在该方法中 console 是不会显示的;

该方法用于一开始就请求一次的数据(之后不用请求了),然后 promise 的 resolve 的结果这个对象,会变成函数组件的 props 对象。例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 函数组件
const Home = props => {
const [mylist, setMylist] = useState(props.data)
// ...组件逻辑
}

// 函数组件外
Home.getInitialProps = async () => {
const promise = new Promise(resolve => {
axios(servicePath.getArticleList).then(res => {
resolve(res.data) // resovle()的结果这个对象变成上面的props对象
})
})
return await promise
}

这种写法其实和写在最普通的 useEffect 的写法效果一样

Hook

跨域

中台安装 egg-cors ,并做相应配置。本质是设置 Access-Control-Allow-Origin

跨域白名单

Egg.js 的跨域不支持白名单,只支持单个域名,这样前后台项目不能一起跑。解决方案是需要自己写一个中间件实现白名单,这是别人写的中间件Github,这里直接用了这个中间件

最后线上版本舍弃了这个插件,见博客项目上线部署博文的 跨域

涉及 id 的页面

像不同种类的列表页,文章详情页这种涉及 id 的页面,路由设置 和 数据获取 稍微复杂一些

例如,列表页中每篇文章的标题,是一个带 id 的 <Link>

1
2
3
4
5
<div className="list-title">
<Link href={{ pathname: '/detailed', query: { id: item.id } }}>
<a>{item.title}</a>
</Link>
</div>

点击后跳转带详情页,所以详情页的 getInitialProps 获取数据的时候是根据路由的 id 去 post

1
2
3
4
5
6
7
8
9
Detailed.getInitialProps = async context => {
let id = context.query.id
const promise = new Promise(resolve => {
axios(servicePath.getArticleById + id).then(res => {
resolve(res.data.data[0])
})
})
return await promise
}

对应的接口则是由文章的 id 在数据库查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
async getArticleById() {
const id = this.ctx.params.id;
const sql = 'SELECT article.id as id,' +
'article.title as title,' +
'article.introduce as introduce,' +
'article.article_content as article_content,' +
'article.view_count as view_count ,' +
'type.typeName as typeName ,' +
'type.id as typeId ' +
'FROM article LEFT JOIN type ON article.type_id = type.Id ' +
'WHERE article.id=' + id;
const result = await this.app.mysql.query(sql);
this.ctx.body = { data: result };
}

PS:前端在路由跳转的时候(Router.push()<Link href=‘xx’>),路由地址最后得是 xx?id=1 这样,而不是 xx/?id=1。但是后端在配置接口的时候依旧是 xx/:id

详情页 中对 Markdown 语法的解析

把从数据库取出来的 markdown 语法的字符串,编译成正常的文章并显示样式,是用 marked + heighlight.js

详情页 中的 导航目录组件

效果如下所示:

把这个组件固定在页面右侧是用了 Antd 的 Affix 固钉 组件,Anchor 锚点

解析文章内容生成目录是用了阿里的一个插件 tocify.tsx

中台

概况 & RESTful 规范

配置了两套路由,/default/开头的用于前台,/admin/开头的用于后台
所有数据的获得和业务逻辑的操作都是通过中台实现的,也就是说中台只提供接口,这里的设计我们采用 RESTful 的规则,让 egg 为前端提供 Api 接口,实现中台主要的功能

RESTful 是目前最流行的网络应用程序设计风格和开发方式,大量使用在移动端 App 上和前后端分离的接口设计。这种形式更直观并且接口也有了一定的约束性。

约束的请求方式和对应的操作:

  • GET(SELECT) : 从服务端取出资源,可以同时取出一项或者多项。
  • POST(CREATE) :在服务器新建一个资源。
  • PUT(UPDATE) :在服务器更新资源(客户端提供改变后的完整资源)。
  • DELETE(DELETE) :从服务器删除资源。

数据库

数据库采用的是关系型数据库 MySQL,安装了官方带的 WorkBench 对数据库进行可视化管理。把 Egg.js 项目进行连接数据库的配置后即能使用

数据库设计

表 和 Cloum 如下:

  • type

    • id : 类型编号 int 类型
    • typeName: 文章类型名称 varchar 类型
    • orderNum: 类型排序编号 int 类型
  • article

    • id : 文章编号 int 类型
    • type_id : 文章类型编号 int 类型
    • title : 文章标题,varchar 类型
    • article_cointent : 文章主体内容,text 类型, TEXT(n),这里的 n 是文本长度,可以设置一个较大值
    • introduce: 文章简介,text 类型
    • addTime : 文章发布时间,int(11)类型
    • view_count :浏览次数, int 类型
  • admin_user

    • id
    • userName
    • password

数据库语法

参考
W3School
SQL 时间戳日期时间转换

常用例子

  • 增,直接对某个表进行 insert
  • 删,直接根据 id,对某个表进行 delete
  • 改,直接对某个表进行 udate
  • 查,eg:
1
2
'SELECT article.id as id, article.title as title, article.article_content as article_content, FROM_UNIXTIME(article.addTime,'%Y-%m-%d' ) as addTime, type.typeName as typeName, FROM article LEFT JOIN type ON article.type_id = type.Id ORDER BY article.addTime DESC'; // DESC是逆序的意思,ASC是正序`
也可以加`'WHERE type_id= 2` 这样的语法

后台

直接用 React + Antd 写的,不用 Next.js

React 项目中的根目录的 index.js 应该是渲染一个路由组件(自己配的),由这个路由组件再去加载其他页面组件。例如路由组件可以如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import React from 'react'
import { BrowserRouter as Router, Route } from 'react-router-dom'
import Login from './Login'
import AdminIndex from './AdminIndex'

function Main() {
return (
<Router>
<Route path="/login/" exact component={Login} />
<Route path="/index/" component={AdminIndex} />
</Router>
)
}
export default Main

登录页面

样式

样式上比较简单,是由 Antd 的 Card, Input, Icon, Button, Spin, message 组件写的

逻辑

目前没有 注册 这个操作,用户名密码及 id 是采用直接在 workbench 插入新数据的方式注册
点击登录按钮的时候,向接口 post 用户名和密码,向数据库查询这个用户名和密码。如果登录成功就返回登录成功的信息和一个 token,组件就把 token 存到 localstorage 中(localStorage.setItem('openId', res.data.openId)),并跳转到首页(props.history.push('/index')

附上接口代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 判断用户名密码是否正确
async checkLogin() {
const userName = this.ctx.request.body.userName;
const password = this.ctx.request.body.password; // 这里也可以进行加密和解密
const sql = " SELECT userName FROM admin_user WHERE userName = '" + userName + "' AND password = '" + password + "'";

const res = await this.app.mysql.query(sql);
if (res.length > 0) {
// 登录成功,进行session缓存
const openId = new Date().getTime(); // 传给前端的token,用当前时间的时间戳做token
this.ctx.session.openId = { openId }; // 并且把这个token放到session中
this.ctx.body = { data: '登录成功', openId };
} else {
this.ctx.body = { data: '登录失败' };
}
}

路由守卫

登录后,我们生成了 session,通过后台是不是存在对应的 session,作一个中台的路由守卫。如果没有登录,是不允许访问后台对应的接口,也没办法作对应的操作。这样就实现了接口的安全
其实就是路由守卫,没有 token 的时候不让访问接口(等同于不让访问某些页面)。在这里我们是通过 egg.js 的中间件实现的(详情版见教程)

  • 写一个中间件文件,并应用在中台路由中 (目前只有getTypeInfo这个接口有守卫
1
2
3
4
5
6
7
8
9
10
module.exports = options => {
return async function adminauth(ctx, next) {
console.log(ctx.session.openId)
if (ctx.session.openId) {
await next()
} else {
ctx.body = { data: '没有登录' }
}
}
}
1
2
// 中台路由:对某个接口进行路由守卫
router.get('/admin/getTypeInfo', adminauth, controller.admin.main.getTypeInfo)
  • 在正常情况下前后台是不能共享session的,需要要在 egg 端的/config/config.default.js里的config.cor配置项增加credentials:true;并且前台的请求中,需要带 withCredentials: true

管理页

布局上用 Antd 的 layout-侧边布局。再做相应的路由配置就能分页

管理页之添加文章

直接在 编辑文章 分页进行编辑并保存
前端:把文章的数据都打包好之后 POST 给后端接口
后端:数据库对表进行插入数据的操作。id 方面是先找到最大 id,然后让新文章的 id 等于最大值+1

管理页之修改文章

在 文章列表 分页,点击某篇文章的 修改 按钮,就会进行路由跳转到 编辑文章 分页并附上文章 id 保存在路由中

所以在 编辑文章 分页还有这样一个逻辑:有一个getArticleById方法,会根据 id 向接口请求对应的文章的数据并显示在页面上,这个方法会在 useEffect 中执行让它在页面刚刷新的时候就执行(如果路由有带 id 的话)。此外,改页面的 发布文章 这个按钮也会判断是否有 id,无的话就是请求发布文章的接口,有的话就是请求更新文章的接口

项目优化计划

前台

  • markdown 语法中图片的解析和存储
    markdown 语法中图片是直接写成 ![](/img/二叉树的遍历/二叉树的遍历_1.png) 这种形式,完全不做配置的话会出现下图这种 404 的情况
  • 文章简介的实现
  • 访问人数的实现。目前的访问人数是随机生成的
  • UI 美化

后台

  • 登录账号和密码加密
  • 实现手机号注册?或者说这种管理中台一般是管理者才能使用,管理者账号直接写在数据库就行了,不必要有注册功能
  • 页面 UI 优化,使该系统美观一些
  • 添加文章能插入图片

上线及部署

见另一篇文章

参考资料
技术胖博客

CATALOG
  1. 1. 概述
    1. 1.1. 来源
    2. 1.2. 概述
  2. 2. 前台
    1. 2.1. 布局
    2. 2.2. 请求数据
      1. 2.2.1. 函数组件的 getInitialProps 方法写一个 promise 并返回 promise 的结果(Next.js 框架)
      2. 2.2.2. Hook
    3. 2.3. 跨域
      1. 2.3.1. 跨域白名单
    4. 2.4. 涉及 id 的页面
    5. 2.5. 详情页 中对 Markdown 语法的解析
    6. 2.6. 详情页 中的 导航目录组件
  3. 3. 中台
    1. 3.1. 概况 & RESTful 规范
    2. 3.2. 数据库
      1. 3.2.1. 数据库设计
      2. 3.2.2. 数据库语法
  4. 4. 后台
    1. 4.1. 登录页面
      1. 4.1.1. 样式
      2. 4.1.2. 逻辑
    2. 4.2. 路由守卫
    3. 4.3. 管理页
    4. 4.4. 管理页之添加文章
    5. 4.5. 管理页之修改文章
  5. 5. 项目优化计划
    1. 5.1. 前台
    2. 5.2. 后台
  6. 6. 上线及部署