一个 Node.js+mongoDB+Vue.js 的博客内容管理系统

2016.09.03

在用过臃肿的 WordPress 后,一直想自己写一个轻便简约的博客内容管理器(CMS)。

一拖再拖,在暑假开学前,终是完成了这么个玩意儿。

嗯,我想完成的功能:

  • 一个基本的博客内容管理器功能,如后台登陆,发布并管理文章等
  • 支持 markdown 语法实时编辑
  • 支持代码高亮
  • 管理博客页面的链接
  • 博客页面对移动端适配优化
  • 账户管理(修改密码)

Demo

登陆后台按钮在页面最下方“站长登陆”,可以以游客身份登入后台系统。

源码

用到的技术和实现思路:

前端:Vue 全家桶

  • Vue.js
  • Vue-Cli
  • Vue-Resource
  • Vue-Router
  • Vuex

后端:Node

  • Node.js
  • mongoDB (mongoose)
  • Express

工具和语言

  • Webpack
  • ES6
  • SASS

整体思路:

  • Node服务端不做路由切换,这部分交给 Vue-Router 完成
  • Node服务端只用来接收请求,查询数据库并用来返回值

所以这样做前后端几乎完全解耦,只要约定好 restful 数据接口,和数据存取格式就 OK 啦。

后端我用了 mongoDB 做数据库,并在 Express 中通过 mongoose 操作 mongoDB,省去了复杂的命令行,通过 Javascript 操作无疑方便了很多。

Vue 的各个插件:

  • vue-cli: 官方的脚手架,用来初始化项目
  • vue-resource:可以看作一个 Ajax 库,通过在跟组件引入,可以方便的注入子组件。子组件以 this.$http 调用
  • vue-router:官方的路由工具,用来切换子组件,是用来做 SPA 应用的关键
  • vuex:规范组件中数据流动,主要用于异步的 http 请求后数据的刷新。通过官方的 vue-devtools 可以无缝对接

文件目录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
│ .babelrc babel 配置
│ .editorconfig
│ .eslintignore
│ .eslintrc.js eslintrc 配置
│ .gitignore
│ index.html 入口页面
│ package.json
│ README.md
│ setup.html 初始化账户页面
│ webpack.config.js webpack 配置
├─dist 打包生成
├─server 服务端
│ api.js Restful 接口
│ db.js 数据库
│ index.js
│ init.json 初始数据
└─src
│ main.js 项目入口
│ setup.js 初始化账户
├─assets 外部引用文件
│ ├─css
│ ├─fonts
│ ├─img
│ └─js
├─components vue 组件
│ ├─back 博客控制台组件
│ ├─front 博客页面组件
│ └─share 公共组件
├─router 路由
├─store vuex 文件
└─style 全局样式

前端的文件统一放到了 src 目录下,有两个入口文件,分别是 main.jssetup.js,有过 WordPress 经验应该知道,第一次进入博客是需要设置用户名密码和数据库的,这里的 setup.js 就是第一次登入时的页面脚本,而 main.js 则是剩余所有文件的入口

main.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import Vue from 'vue'
import VueResource from 'vue-resource'
import {mapState} from 'vuex'
// 三个顶级组件,博客主页和控制台共享
import Spinner from './components/share/Spinner.vue'
import Toast from './components/share/Toast.vue'
import MyCanvas from './components/share/MyCanvas.vue'
import store from './store'
import router from './router'
import './style/index.scss'
Vue.use(VueResource)
new Vue({
router,
store,
components: {Spinner, Toast, MyCanvas},
computed: mapState(['isLoading', 'isToasting'])
}).$mount('#CMS2')

而后所有页面分割成一个单一的 vue 组件,放在 components 中,通过入口文件 main.js,由webpack 打包生成,生成的文件放在 dist 文件夹下。

后端文件放在 server 文件夹内,这就是基于 Expressnode服务器,在 server 文件夹内执行

1
node index

就可以启动 Node 服务器,默认侦听 3000 端口。

关于 Webpack

Webpack 的配置文件主体是有 vue-cli 生成的,但为了配合后端自动刷新、支持 Sass 和生成独立的 css 文件,稍微修改了一下:

webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
const path = require('path')
const webpack = require('webpack')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const CopyWebpackPlugin = require('copy-webpack-plugin')
// 萃取 css 文件,在此命名
const extractCSSFromVue = new ExtractTextPlugin('styles.css')
const extractCSSFromSASS = new ExtractTextPlugin('index.css')
module.exports = {
entry: {
main: './src/main.js',
setup: './src/setup.js'
},
output: {
path: path.resolve(__dirname, './dist'),
publicPath: '/dist/',
filename: '[name].js'
},
resolveLoader: {
moduleExtensions: ['-loader']
},
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue',
// 使用 postcss 处理加工后的 scss 文件
options: {
preserveWhitespace: false,
postcss: [
require('autoprefixer')({
browsers: ['last 3 versions']
})
],
loaders: {
sass: extractCSSFromVue.extract({
loader: 'css!sass!',
fallbackLoader: 'vue-style-loader'
})
}
}
},
{
test: /\.scss$/,
loader: extractCSSFromSASS.extract(['css', 'sass'])
},
{
test: /\.js$/,
loader: 'babel',
exclude: /node_modules/
},
{
test: /\.(png|jpg|gif|svg)$/,
loader: 'file',
options: {
name: '[name].[ext]?[hash]'
}
},
// 字体文件
{
test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/,
loader: 'url-loader?limit=10000&mimetype=application/font-woff'
},
{
test: /\.(ttf|eot|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/,
loader: 'file-loader'
}
]
},
plugins: [
// 取出 css 生成独立文件
extractCSSFromVue,
extractCSSFromSASS,
new CopyWebpackPlugin([
{from: './src/assets/img', to: './'}
])
],
resolve: {
alias: {
'vue$': 'vue/dist/vue'
}
},
// 服务器代理,便于开发时所有 http 请求转到 node 的 3000 端口,而不是前端的 8080 端口
devServer: {
historyApiFallback: true,
noInfo: true,
proxy: {
'/': {
target: 'http://localhost:3000/'
}
}
},
devtool: '#eval-source-map'
}
if (process.env.NODE_ENV === 'production') {
module.exports.devtool = '#source-map'
module.exports.plugins = (module.exports.plugins || []).concat([
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: '"production"'
}
}),
new webpack.optimize.UglifyJsPlugin({
compress: {
warnings: false
}
}),
new webpack.LoaderOptionsPlugin({
minimize: true
})
])
}

运行

1
npm start

后,node 端开启了 3000 端口,接着运行

1
npm run dev

打开 webpack 在 8080 端口服务器,具有动态加载的功能,并且所有的 http 请求会代理到 3000 端口

关于 Vue-Router

因为写的是但也应用(SPA),服务器不负责路由,所以路由方面交给 Vue-Router 来控制。

router.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import Vue from 'vue'
import Router from 'vue-router'
// 博客页面
import Archive from '../components/front/Archive.vue'
import Article from '../components/front/Article.vue'
// 控制台页面
import Console from '../components/back/Console.vue'
import Login from '../components/back/Login.vue'
import Articles from '../components/back/Articles.vue'
import Editor from '../components/back/Editor.vue'
import Links from '../components/back/Links.vue'
import Account from '../components/back/Account.vue'
Vue.use(Router)
export default new Router({
mode: 'history',
routes: [
{path: '/archive', name: 'archive', component: Archive},
{path: '/article', name: 'article', component: Article},
{path: '/', component: Login},
{
path: '/console',
component: Console,
children: [
{path: '', component: Articles},
{path: 'articles', name: 'articles', component: Articles},
{path: 'editor', name: 'editor', component: Editor},
{path: 'links', name: 'links', component: Links},
{path: 'account', name: 'account', component: Account}
]
}
]
})

文档首页

index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>cms2simple</title>
<link rel="stylesheet" href="dist/index.css">
<link rel="stylesheet" href="dist/styles.css">
</head>
<body>
<div id="CMS2" style="height: 100%">
<my-canvas></my-canvas>
<spinner v-show="isLoading"></spinner>
<Toast v-show="isToasting"></Toast>
<router-view ></router-view>
</div>
<script src="/dist/main.js"></script>
</body>
</html>

可以看到路由控制在 body 元素下的 router-view 中。前面的 spinnertoast 元素分别是等待效果(转圈圈)的弹出层和信息的弹出层,和背景样式的切换。

关于后端

后端是用 node.js 作为服务器的,使用了 express 框架。

其中代码非常简单:

index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const fs = require('fs')
const path = require('path')
const express = require('express')
const favicon = require('serve-favicon')
const bodyParser = require('body-parser')
const cookieParser = require('cookie-parser')
const db = require('./db')
const resolve = file => path.resolve(__dirname, file)
const api = require('./api')
const app = express()
// const createBundleRenderer = require('vue-server-renderer').createBundleRenderer
app.set('port', (process.env.port || 3000))
app.use(favicon(resolve('../dist/favicon.ico')))
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({extended: false}))
app.use(cookieParser())
app.use('/dist', express.static(resolve('../dist')))
app.use(api)
app.post('/api/setup', function (req, res) {
new db.User(req.body)
.save()
.then(() => {
res.status(200).end()
db.initialized = true
})
.catch(() => res.status(500).end())
})
app.get('*', function (req, res) {
const fileName = db.initialized ? 'index.html' : 'setup.html'
const html = fs.readFileSync(resolve('../' + fileName), 'utf-8')
res.send(html)
})
app.listen(app.get('port'), function () {
console.log('Visit http://localhost:' + app.get('port'))
})

服务器做的事情很简单,毕竟路由在前端。在接受请求的时候判断一下数据库是否初始化,如果初始化就转向主页,否则转向 setup.html,之所以没有直接sendfile 是因为考虑到之后添加服务端渲染(虽然主页并没有啥值得渲染的,因为很简单)

express框架中使用了 mongoose 来连接 mongoDB 数据库,在接收请求时做对应的 curd 操作,比如这就是在接收保存文章时对应的操作:

api.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
router.post('/api/saveArticle', (req, res) => {
const id = req.body._id
const article = {
title: req.body.title,
date: req.body.date,
content: req.body.content
}
if (id) {
db.Article.findByIdAndUpdate(id, article, fn)
} else {
new db.Article(article).save()
}
res.status(200).end()
})

后记

当然还有很多没提及的地方,最早写这个博客管理器的时候用的还是 vue 1.x,后来用2.0 改写后文档一直没改,所以最近更新了一下,避免误解。

其实整个管理器最复杂的地方时 vuex 异步数据视图的部分,不过这一部能讲的太多,就不在这里展开了,可以看官方文档后,参考源代码的注释。