webpack+vue+ts+koa2前后端合体搭建博客

webpack+vue+ts+koa2前后端合体搭建博客

项目简介

博客采用前后端分离,前端用vue+ts+stylus开发,基于MVVM模式;后端用koa2+mysql+sequelize ORM开发,基于MVC模式。前后端由webpack进行合体,并且对webpack进行了生产模式、开发模式分离配置。最终将前端打包的dist和后端的server上传至服务器,前端代码看作是后端的静态资源。

由于之前时间有限,博客的功能只做了登录、注册、写文章、修改文章、修改昵称改头像、关注、评论,感兴趣的同学可以持续添加,比如回复、点赞、分享、分类、标签、推荐等功能。除了功能,样式也可以更改。本来我当初是想仿掘金的,当时时间有限,做了几天就没写了。

博客演示地址

GitHub地址

目录结构

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
blog
├─.babelrc
├─.dockerignore
├─.gitignore
├─Dockerfile
├─package-lock.json
├─package.json
├─README.md
├─tsconfig.json
├─webpack.common.js
├─webpack.dev.js
├─webpack.prod.js
├─static
| └defaultAvatar.png
├─server
| ├─app.ts
| ├─router.ts
| ├─views
| | └index.html
| ├─services
| | ├─BlogService.ts
| | ├─CommentService.ts
| | ├─FollowService.ts
| | ├─ReplyService.ts
| | ├─SortService.ts
| | └UserService.ts
| ├─public
| | ├─dist
| ├─models
| | ├─BlogModel.ts
| | ├─CommentModel.ts
| | ├─FollowModel.ts
| | ├─ReplyModel.ts
| | ├─SortModel.ts
| | └UserModel.ts
| ├─controllers
| | ├─BlogController.ts
| | ├─CommentController.ts
| | ├─FollowController.ts
| | ├─SortController.ts
| | └UserController.ts
| ├─config
| | ├─db.ts
| | └tools.ts
├─node_modules

1. 初始化项目

1
2
npm init -y
npm i webpack webpack-cli --save-dev

2.构建基础架构-安装插件

  • 2.1 实现每次编译前自动清空dist目录,安装clean-webpack-plugin
1
npm i clean-webpack-plugin --save-dev
  • 2.2 实现从HTML模板自动生成最终HTML,安装html-webpack-plugin
1
npm i html-webpack-plugin --save-dev
  • 2.3 配置typescript环境,安装ts-loader、typescript
1
npm i ts-loader typescript --save-dev
  • 2.4 搭建开发环境的热监测服务器,安装webpack-dev-server
1
npm i webpack-dev-server --save-dev
  • 2.5 构建项目
1
2
3
4
5
6
7
8
9
10
| - client
| - node_modules
| - server
| - public
| - views
| - index.html
.gitignore
| - package-lock.json
| - package.json
| - README.md

3.webpack配置生产环境和开发环境

新建三个配置文件,webpack.common.js、webpack.dev.js、webpack.prod.js

  • 3.1 webpack.common.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
const path = require('path');
const webpack = require('webpack');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
// 入口
entry: {
index: './client/index.ts'
},
// 编译输出配置
output: {
// js生成到dist/js,[name]表示保留原js文件名,并跟随生成的chunkhash
filename: '[name]-[chunkhash:6].js',
// 输出到server/public,输出路径为dist,一定要绝对路径
path: path.resolve(__dirname, './server/public/dist')
},
// 插件
plugins: [
new CleanWebpackPlugin(),
// 设置html模板生成路径
new HtmlWebpackPlugin({
filename: 'index.html',
template: './server/views/index.html',
chunks: ['index']
})
],
// 配置各个模块规则
module: {
rules: [
{
test: /\.ts$/,
use: 'ts-loader',
exclude: /node_modules/
}
]
},
// 配置文件扩展名
resolve: {
extensions: ['.ts', '.js', '.vue', '.json']
}
}
  • 3.2 webpack.dev.js
1
2
3
4
5
6
7
8
9
10
11
12
13
const merge = require('webpack-merge');
const common = require('./webpack.common');

module.exports = merge(common, {
// 热监测服务器,动态监测并实时更新页面
devServer: {
contentBase: './server/public/dist',
// 默认端口为8080
port: 8081,
// 开启热更新
hot: true
}
});
  • 3.3 webpack.prod.js
1
2
3
4
5
6
7
const merge = require('webpack-merge');
const common = require('./webpack.common');

module.exports = merge(common, {
// 方便追踪源代码错误
devtool: '#source-map'
});
  • 3.4修改package.json
1
2
3
4
5
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "webpack --config webpack.prod.js --mode production",
"dev": "webpack-dev-server --open chrome --config webpack.dev.js --mode development"
}

4.解决ES6转ES5

  • 4.1 安装babel系列依赖
1
npm install babel-loader @babel/core @babel/preset-env --save-dev
1
npm install @babel/plugin-transform-runtime @babel/plugin-transform-modules-commonjs --save-dev
1
npm install @babel/runtime --save

注意版本兼容:babel-loader8.x对应babel-core7.X,babel-loader7.x对应babel-core6.X

  • 4.2 修改webpack.common.js,这里代码的作用是,在编译时把js文件中ES6转成ES5:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
module.exports = {
module: {
rules: [
// 处理ES6转ES5
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
plugins: [
'@babel/plugin-transform-runtime',
'@babel/plugin-transform-modules-commonjs'
]
}
},
exclude: /node_modules/
}
]
}
}

5.配置vue开发环境

  • 5.1安装vue-loader、vue、vue-template-compiler、css-loader
1
npm i vue-loader vue vue-template-compiler css-loader -S
  • 5.2配置webpack.common.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const VueLoaderPlugin = require('vue-loader/lib/plugin');

module.exports = {
module: {
rules: [
// 处理vue
{
test: /\.vue$/,
use: 'vue-loader'
}
]
},
plugins: [
// vue-loader必须和VueLoaderPlugin一起使用,否则报错
new VueLoaderPlugin()
]
}

除此之外,在入口文件里引入.vue文件,会出现红色下划线,这是因为没有声明。因此新建types文件夹,在里面新建vue.d.ts:

1
2
3
4
declare module "*.vue" {
import Vue from "vue";
export default Vue;
}

因为本项目用typescript开发,即使做出了vue的导入导出声明,也还是会提示找不到App.vue文件。因此在项目根目录下新建tsconfig.json文件:

1
2
3
4
5
6
7
8
9
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"sourceMap": true
},
"include": ["client", "server"],
"exclude": ["node_modules"]
}
  • 5.3记一次坑

启动webpack报如下错误:

1
2
3
ERROR in chunk index [entry]
[name]-[chunkhash:6].js
Cannot use [chunkhash] or [contenthash] for chunk in '[name]-[chunkhash:6].js' (use [hash] instead)

这是因为在配置webpack输出filename时这么写的,因此直接使用hash即可。

6.在vue里使用stylus

  • 6.1安装依赖包
1
npm install style-loader --save-dev
1
npm install stylus-loader stylus --save-dev
  • 6.2在webpack.common.js里配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
module.exports = {
module: {
rules: [
// 处理CSS(类似管道,优先使用css-loader处理,最后是style-loader)
{
test: /\.css$/,
use: ['style-loader', 'css-loader']
},
// 处理stylus
{
test: /\.styl(us)$/,
use: ['style-loader', 'css-loader', 'stylus-loader']
}
]
}
}

注意,每次修改了webpack记得重启项目。

  • 6.3现在我们想把样式通过link方式引入

先安装MiniCssExtractPlugin:

1
npm i mini-css-extract-plugin --save-dev

再修改webpack.common.js,将style-loader替换掉:

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
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
plugins: [
......
// 将样式抽离使用link方式引入
new MiniCssExtractPlugin({
filename: '[name]-[hash:6].css'
})
],
// 配置各个模块规则
module: {
rules: [
......
// 处理CSS(类似管道,优先使用css-loader处理,最后是style-loader)
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader']
},
// 处理stylus
{
test: /\.styl(us)$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'stylus-loader']
}
]
}
}

7.处理图片资源

  • 7.1安装插件file-loader和url-loader,url-loader基于file-loader,所以两个都要安装。 (也可以只使用file-loader,url-loader在file-loader的基础上扩展了功能,比如能设置小于多少KB的图片进行base64转码等)

    1
    npm install file-loader url-loader --save-dev
  • 7.2配置webpack.common.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
module.exports = {
module: {
rules: [
// 处理图片
{
test: /\.(png|jpg|gif|eot|woff|ttf|svg|webp|PNG)$/,
loader: 'url-loader',
options: {
name: '[name]-[hash:6].[ext]',
esModule: false, // 否则图片加载src显示为object module
limit: 10240, // 小于10kb的特殊处理,转成base64
},
exclude: /node_modules/
}
]
}
}

8.前端开启GZIP压缩

gzip就是GNUzip的缩写,是一个文件压缩程序,可以将文件压缩进后缀为.gz的压缩包。而我们前端所讲的gzip压缩优化,就是通过gzip这个压缩程序,对资源进行压缩,从而降低请求资源的文件大小。**gzip压缩能力很强,压缩力度可达到70%。

  • 8.1安装compression-webpack-plugin
1
npm i compression-webpack-plugin -D
  • 8.2在webpack.common.js里配置
1
2
3
4
5
6
7
8
9
10
11
const CompressionWebpackPlugin = require('compression-webpack-plugin');

module.exports = {
plugins: [
......
new CompressionWebpackPlugin({
test: /\.(js|css)$/,
threshold: 10240 // 这里对大于10k的js和css文件进行压缩
})
]
}

注意事项:compression-webpack-plugin使用会受版本影响,版本过高会冲突报错。解决方案:重新安装较低版本的包

9.使用At-UI

AT-UI 是一款基于 Vue.js 2.0 的前端 UI 组件库,主要用于快速开发 PC 网站中后台产品.

  • 9.1 安装
1
npm i at-ui -S

由于at-ui的样式已经独立成一个项目了,因此这里可以npm安装at-ui-style。本人这里直接使用的CDN方式引入以减小开销。

  • 9.2打包运行后报错
1
2
3
4
ERROR in ./node_modules/element-ui/lib/theme-chalk/index.css
Module build failed (from ./node_modules/mini-css-extract-plugin/dist/loader.js):
ModuleParseError: Module parse failed: Unexpected character ' ' (1:0)
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders

解决:将url-loader替换为file-loader

1
2
3
4
5
6
7
8
9
10
11
12
// 处理图片
{
test: /\.(png|jpg|gif|eot|woff|ttf|svg|webp|PNG)(\?\S*)?$/,
loader: 'file-loader'
// options: {
// name: '[name]-[hash:6].[ext]',
// esModule: false, // 否则图片加载src显示为object module
// limit: 10240, // 小于10kb的特殊处理,转成base64
// puplicPath: './server/public'
// },
// exclude: /node_modules/
}

10.制作导航栏

  • 10.1登录或者注册时隐藏导航栏
1
<header v-if="$route.name !== 'register'"><header-section></header-section></header>
  • 10.2用bcrypt存储的密码,一定要设置足够的长度,否则会一直返回false。

11.登录token校验

  • 11.1安装依赖
1
2
npm i jsonwebtoken --save
npm i koa-jwt --save
  • 11.2TS2304: Cannot find name ‘localStorage’

配置tsconfig.json

1
2
3
4
5
6
7
8
9
10
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"sourceMap": true,
"lib": ["DOM", "ES2016", "ES2015"]
},
"include": ["client", "server"],
"exclude": ["node_modules"]
}
  • 11.3鉴权中间件一定放在路由的前面。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 错误处理
app.use(async (ctx, next) => {
return next().catch(err => {
if(err.status === 401) {
ctx.status = 401;
ctx.body = 'Protected resource, use Authorization header to get access\n';
} else {
throw err;
}
})
});
// unless表示不对登录注册做token校验(颁发token时密钥是secret)
app.use(koajwt({ secret: 'secret' }).unless({ path: [/^\/login/, /^\/register/] }));
app.use(bodyParser());
router(app);

前端axios拦截器添加token一定要这样写,否则koa-jwt怎么都不会解析成功!切记!切记!这里我找了一下午的坑~~

1
2
3
4
let token = JSON.parse(localStorage.getItem('token'));
if(token) {
config.headers.common['Authorization'] = 'Bearer ' + token;
}

12.main组件里登录操作,成功后header里导航栏用户信息不刷新

  • 12.1vuex结合localStorage
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
import Vuex from 'vuex';
import Vue from 'vue';
Vue.use(Vuex);

const store = new Vuex.Store({
state: {
user: JSON.parse(localStorage.getItem('user')) || null,
token: JSON.parse(localStorage.getItem('token')) || ''
},
getters: {
getUser: state => state.user,
getToken: state => state.token
},
mutations: {
setUser(state, payload) {
state.user = payload.user;
// 数据持久化
localStorage.setItem('user', JSON.stringify(payload.user));
},
setToken(state, payload) {
state.token = payload.token;
localStorage.setItem('token', JSON.stringify(payload.token));
},
logout(state) {
localStorage.removeItem('user');
localStorage.removeItem('token');
state.user = null;
state.token = '';
}
}
});

export default store;
  • 12.2登录组件登录成功后调用
1
2
3
// 存储用户信息
this.$store.commit('setUser', { user: res.data.user });
this.$store.commit('setToken', { token: res.data.token });
  • 12.2登出时调用
1
2
3
4
logout() {
this.$store.commit('logout');
window.location.reload();
}

在这里vuex更新导航栏没刷新,我就加了reload手动刷新,由于时间有限具体原因留到后面再分析。

  • 12.3鉴权失败调用(比如token过期了浏览器清除登录信息)
1
2
3
4
5
6
7
8
this.axios.get('/sort').then(res => {
this.sorts = res.data;
}, err => {
if(err.code === -1) { // token鉴权失败
this.$store.commit('logout');
this.$router.push({ name: 'home' });
}
})

综上,vuex结合localStorage能够实现用户登录时保存信息。vuex 中store的数据需要放到computed 里面才能同步更新视图,切记切记!找了一天的bug,试了n多种方法,才找到是这个原因~~贴个链接https://blog.csdn.net/wangshang1320/article/details/98871252

13.vue中使用input和label实现上传按钮美化

  • 13.1
1
2
3
4
5
6
<div class="img-modify">
<label for="input-img">
<at-button type="primary">点击上传</at-button>
</label>
<input type="file" name="input-img" @change="fileHandler($event)" accept="image/*">
</div>
1
2
3
4
5
6
7
8
.img-modify
flex 9;
label
position absolute;
input
opacity 0;
width 82px;
height 31.6px;
  • 13.2获取file对象
1
2
3
 fileHandler(e) {
let file = e.target.files[0];
}

14.前端获取所有关注者的博客,批量处理异步操作

  • 14.1问题描述

当我关注了几个博主时,点击导航栏的关注,要获取他们的所有文章。刚开始我是在for循环里操作,但是这样很明显有一个问题,因为for循环是同步代码,我永远只能拿到最后一个请求的结果,所以需要解决这个问题。

  • 14.2解决

因为我使用的是axios,axios本身封装了promise,而且axios提供了一个all方法批量处理异步请求结果,非常方便。首先定义一个返回的promise数组,暂且命名为promiseAll。然后拿到所有·异步结果后,通过调用axios提供的all方法批量处理回调函数里的结果。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 获取关注者的所有博文
getFollowersBlogs() {
// 先返回所有异步请求结果
let promiseAll = this.followerList.map((item) => {
return this.axios.get('/blog/email/' + item.follow_email);
});
// 再处理所有回调结果
this.axios.all(promiseAll).then(resArr => {
resArr.forEach(res => {
this.blogList = this.blogList.concat(res.data);
});
}, err => {
if(err.code === -1) { // token鉴权失败
this.$Modal.info({
content: '登录过期,请重新登录!'
});
this.$store.commit('logout');
this.$router.push({ name: 'login' });
}
});
}

15.博客待完善功能

  1. 首页分页✔
  2. 评论✔
  3. 删除文章
  4. 编辑文章必须修改内容才生效的问题✔
  5. 点赞
  6. 搜索页-对题目高亮✔
  7. 前端url加密。vue里用params传参呢,怕刷新页面参数丢失。用query呢,参数直接显示在地址栏。因此这里考虑对query加密处理。网上搜索到一种方法,用到的是base64加密。✔
  8. 反馈
  9. 登录注册及搜索支持按键enter✔
  10. 密码修改

16.前端对url进行base64加密

  • 16.1安装js-base64
1
npm install --save js-base64
  • 16.2在ES6+中使用,这里将挂载到vue实例上,以供全局使用
1
2
3
// 引入js-base64对url加密
import { Base64 } from 'js-base64';
Vue.prototype.$Base64 = Base64;

对参数加密:

1
2
3
4
this.$router.push({ 
name: 'search',
query: { keyword: this.$Base64.encode(this.searchValue) }
});

对参数解密:

1
this.keyword = this.$Base64.decode(this.$route.query.keyword);

17.axios的get请求像post那样传递参数

  • 17.1get请求时的写法
1
2
3
4
5
6
7
8
9
10
this.axios.get('/blogs/list', {
params: {
pageSize: this.page.pageSize,
currentPage: this.page.currentPage
}
}).then(res => {
this.blogList = res.data;
}, err => {
console.error(err);
});
  • 17.2获取参数
1
2
let pageSize = Number(ctx.request.query.pageSize),
currentPage = Number(ctx.request.query.currentPage);

注意数据库查询前参数转为整型,否则会报错。

18.监听登录和注册密码框enter事件实现登录注册

  • 监听最后一个输入框回车事件
1
2
<at-input v-model="checkPass" type="password" placeholder="请确认密码" size="large" 
:maxlength="12" :minlength="6" @keyup.enter.native="register"></at-input>

19.评论

  • 19.1页面点击文本域显示评论按钮

记一次vuex获取用户信息的坑。

因为vuex存储的是user和token,在vue中使用的时候,必须使用计算属性,否则会报错。另外,当退出登录时,因为user和token都已经被删除,所以使用头像等时格外注意判断。

1
2
3
4
5
6
7
8
9
10
11
12
avatar: function() {
if(this.$store.getters.getUser !== null) {
return this.$store.getters.getUser.avatar;
}
return null;
},
user: function() {
if(this.$store.getters.getUser !== null) {
return this.$store.getters.getUser;
}
return null;
}
  • 19.2获取博客评论需要获取用户名、头像,因此用户表和评论表需要关联
1
2
3
// 用户与评论是一对多关系
UserModel2.hasMany(CommentModel);
CommentModel.belongsTo(UserModel2, { foreignKry: 'email' });
1
2
3
4
5
6
7
8
9
10
11
12
// 获取博客评论(要返回用户头像和用户名,需关联表,建立一对多关系)
findBlogComments: async (blog_id) => {
return await CommentModel.findAll({
where: {
blog_id
},
include: [{
model: UserModel2,
attributes: ['username', 'avatar']
}]
})
}

上述查询语句会报错:

SequelizeDatabaseError: Unknown column ‘comment.userEmail’ in ‘field list’

注释掉关联声明:

1
2
3
// 用户与评论是一对多关系
// UserModel2.hasMany(CommentModel);
CommentModel.belongsTo(UserModel2, { foreignKey: 'email', targetKey: 'email' });

一个模型要关联另一个模型时,加一句声明即可,否则会报错。

注意点

  1. type 如果不存在则直接用字符串表示 如:’TIMESTAMP’;
  2. 如果需要在更新表字段时记录更新时间,可应使用 updateAt,并设置默认值和对应的字段名。
  3. 如果默认值不是具体的数值,可以用 literal 函数去表示。
  4. tableName 表名,u 为别名。
  5. 建立关联关系时,如果外键关联的是主键则不用写 targetKey,否则需要。

20.总结

这个博客我当时花了4天时间搭建起来,主要是为了巩固webpack各种配置,以及学习typescript的使用(虽然并没有怎么用ts语法)。整个过程对于自己掌握项目快速搭建很有帮助,希望和我入门前端不久的小伙伴们能够通过这个过程学会webpack的使用。