项目简介 博客采用前后端分离,前端用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
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
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: { filename: '[name]-[chunkhash:6].js' , path: path.resolve(__dirname, './server/public/dist' ) }, plugins: [ new CleanWebpackPlugin(), 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' ] } }
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' , port: 8081 , hot: true } });
1 2 3 4 5 6 7 const merge = require ('webpack-merge' );const common = require ('./webpack.common' );module .exports = merge(common, { devtool: '#source-map' });
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
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: [ { 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
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: [ { test: /\.vue$/ , use: 'vue-loader' } ] }, plugins: [ 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" ] }
启动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
1 npm install style-loader --save-dev
1 npm install stylus-loader stylus --save-dev
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 module .exports = { module : { rules: [ { test: /\.css$/ , use: ['style-loader' , 'css-loader' ] }, { test: /\.styl(us)$/ , use: ['style-loader' , 'css-loader' , 'stylus-loader' ] } ] } }
注意,每次修改了webpack记得重启项目。
先安装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: [ ...... new MiniCssExtractPlugin({ filename: '[name]-[hash:6].css' }) ], module : { rules: [ ...... { test: /\.css$/ , use: [MiniCssExtractPlugin.loader, 'css-loader' ] }, { test: /\.styl(us)$/ , use: [MiniCssExtractPlugin.loader, 'css-loader' , 'stylus-loader' ] } ] } }
7.处理图片资源
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 , limit: 10240 , }, exclude: /node_modules/ } ] } }
8.前端开启GZIP压缩 gzip就是GNUzip的缩写,是一个文件压缩程序,可以将文件压缩进后缀为.gz的压缩包。而我们前端所讲的gzip压缩优化,就是通过gzip这个压缩程序,对资源进行压缩,从而降低请求资源的文件大小。**gzip压缩能力很强,压缩力度可达到70%。
8.1安装compression-webpack-plugin
1 npm i compression-webpack-plugin -D
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 }) ] }
注意事项:compression-webpack-plugin使用会受版本影响,版本过高会冲突报错。解决方案:重新安装较低版本的包
9.使用At-UI AT-UI
是一款基于 Vue.js 2.0
的前端 UI 组件库,主要用于快速开发 PC 网站中后台产品.
由于at-ui的样式已经独立成一个项目了,因此这里可以npm安装at-ui-style。本人这里直接使用的CDN方式引入以减小开销。
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:
解决:将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' }
10.制作导航栏
1 <header v-if ="$route.name !== 'register'" > <header-section > </header-section > </header >
10.2用bcrypt存储的密码,一定要设置足够的长度,否则会一直返回false。
11.登录token校验
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" ] }
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; } }) }); 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里导航栏用户信息不刷新
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;
1 2 3 this .$store.commit('setUser' , { user : res.data.user });this .$store.commit('setToken' , { token : res.data.token });
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 ) { this .$store.commit('logout' ); this .$router.push({ name : 'home' }); } })
综上,vuex结合localStorage能够实现用户登录时保存信息。vuex 中store的数据需要放到computed 里面才能同步更新视图,切记切记!找了一天的bug,试了n多种方法,才找到是这个原因~~ 贴个链接https://blog.csdn.net/wangshang1320/article/details/98871252
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;
1 2 3 fileHandler(e) { let file = e.target.files[0 ]; }
14.前端获取所有关注者的博客,批量处理异步操作
当我关注了几个博主时,点击导航栏的关注,要获取他们的所有文章。刚开始我是在for循环里操作,但是这样很明显有一个问题,因为for循环是同步代码,我永远只能拿到最后一个请求的结果,所以需要解决这个问题。
因为我使用的是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 ) { this .$Modal.info({ content: '登录过期,请重新登录!' }); this .$store.commit('logout' ); this .$router.push({ name: 'login' }); } }); }
15.博客待完善功能
首页分页✔
评论✔
删除文章
编辑文章必须修改内容才生效的问题✔
点赞
搜索页-对题目高亮✔
前端url加密。vue里用params传参呢,怕刷新页面参数丢失。用query呢,参数直接显示在地址栏。因此这里考虑对query加密处理。网上搜索到一种方法,用到的是base64加密。✔
反馈
登录注册及搜索支持按键enter✔
密码修改
16.前端对url进行base64加密
1 npm install --save js-base64
16.2在ES6+中使用,这里将挂载到vue实例上,以供全局使用
1 2 3 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那样传递参数
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); });
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.评论
记一次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 CommentModel.belongsTo(UserModel2, { foreignKey : 'email' , targetKey : 'email' });
一个模型要关联另一个模型时,加一句声明即可,否则会报错。
注意点 :
type 如果不存在则直接用字符串表示 如:’TIMESTAMP’;
如果需要在更新表字段时记录更新时间,可应使用 updateAt,并设置默认值和对应的字段名。
如果默认值不是具体的数值,可以用 literal 函数去表示。
tableName 表名,u 为别名。
建立关联关系时,如果外键关联的是主键则不用写 targetKey,否则需要。
20.总结 这个博客我当时花了4天时间搭建起来,主要是为了巩固webpack各种配置,以及学习typescript的使用(虽然并没有怎么用ts语法)。整个过程对于自己掌握项目快速搭建很有帮助,希望和我入门前端不久的小伙伴们能够通过这个过程学会webpack的使用。