webpack+vue+koa开发博客指南

1,907 阅读4分钟

项目简介

此项目步骤很详细,任何人只要按着步骤来百分百能复现。

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

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

博客演示地址

GitHub地址

目录结构

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
├─client
|   ├─App.vue
|   ├─index.ts
|   ├─router.ts
|   ├─types
|   |   └vue.d.ts
|   ├─pages
|   |   ├─Author.vue
|   |   ├─Backend.vue
|   |   ├─Blog.vue
|   |   ├─Career.vue
|   |   ├─Focus.vue
|   |   ├─Freebie.vue
|   |   ├─Frontend.vue
|   |   ├─Home.vue
|   |   ├─Login.vue
|   |   ├─Register.vue
|   |   ├─Search.vue
|   |   ├─UserCenter.vue
|   |   └Write.vue
|   ├─lib
|   |  ├─axiosInterceptor.ts
|   |  ├─dateFormat.ts
|   |  └textFilter.ts
|   ├─config
|   |   ├─aliOss.ts
|   |   └store.ts
|   ├─components
|   |     ├─AuthorCard.vue
|   |     ├─Card.vue
|   |     ├─CommentCard.vue
|   |     ├─Header.vue
|   |     └SearchCard.vue
├─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. 初始化项目

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

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

  • 2.1 实现每次编译前自动清空dist目录,安装clean-webpack-plugin
npm i clean-webpack-plugin --save-dev
  • 2.2 实现从HTML模板自动生成最终HTML,安装html-webpack-plugin
npm i html-webpack-plugin --save-dev
  • 2.3 配置typescript环境,安装ts-loader、typescript
npm i ts-loader typescript --save-dev
  • 2.4 搭建开发环境的热监测服务器,安装webpack-dev-server
npm i webpack-dev-server --save-dev
  • 2.5 构建项目
| - 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
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
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
const merge = require('webpack-merge');
const common = require('./webpack.common');

module.exports = merge(common, {
    // 方便追踪源代码错误
    devtool: '#source-map'
});
  • 3.4修改package.json
  "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系列依赖
npm install babel-loader @babel/core @babel/preset-env --save-dev
npm install @babel/plugin-transform-runtime @babel/plugin-transform-modules-commonjs --save-dev
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:
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
npm i vue-loader vue vue-template-compiler css-loader -S
  • 5.2配置webpack.common.js
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:

declare module "*.vue" {
    import Vue from "vue";
    export default Vue;
}

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

{
    "compilerOptions": {
        "target": "es5",
        "module": "commonjs",
        "sourceMap": true
    },
    "include": ["client", "server"],
    "exclude": ["node_modules"]
}
  • 5.3记一次坑

启动webpack报如下错误:

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安装依赖包
npm install style-loader --save-dev
npm install stylus-loader stylus --save-dev
  • 6.2在webpack.common.js里配置
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:

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

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

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转码等)

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

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
npm i compression-webpack-plugin -D
  • 8.2在webpack.common.js里配置
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 安装
npm i at-ui -S

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

  • 9.2打包运行后报错
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

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

11.登录token校验

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

配置tsconfig.json

{
    "compilerOptions": {
        "target": "es5",
        "module": "commonjs",
        "sourceMap": true,
        "lib": ["DOM", "ES2016", "ES2015"]
    },
    "include": ["client", "server"],
    "exclude": ["node_modules"]
}
  • 11.3鉴权中间件一定放在路由的前面。
// 错误处理
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怎么都不会解析成功!切记!切记!这里我找了一下午的坑~~

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

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

  • 12.1vuex结合localStorage
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登录组件登录成功后调用
// 存储用户信息
this.$store.commit('setUser', { user: res.data.user });
this.$store.commit('setToken', { token: res.data.token });
  • 12.2登出时调用
logout() {
    this.$store.commit('logout');
    window.location.reload();
}

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

  • 12.3鉴权失败调用(比如token过期了浏览器清除登录信息)
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多种方法,才找到是这个原因~~贴个链接blog.csdn.net/wangshang13…

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

  • 13.1
<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>
            .img-modify
                flex 9;
                label 
                    position absolute;
                input
                    opacity 0;
                    width 82px;
                    height 31.6px;
  • 13.2获取file对象
 fileHandler(e) {
    let file = e.target.files[0];
}

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

  • 14.1问题描述

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

  • 14.2解决

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

// 获取关注者的所有博文
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
npm install --save js-base64
  • 16.2在ES6+中使用,这里将挂载到vue实例上,以供全局使用
// 引入js-base64对url加密
import { Base64 } from 'js-base64';
Vue.prototype.$Base64 = Base64;

对参数加密:

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

对参数解密:

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

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

  • 17.1get请求时的写法
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获取参数
let pageSize = Number(ctx.request.query.pageSize),
 currentPage = Number(ctx.request.query.currentPage);

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

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

  • 监听最后一个输入框回车事件
<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都已经被删除,所以使用头像等时格外注意判断。

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获取博客评论需要获取用户名、头像,因此用户表和评论表需要关联
// 用户与评论是一对多关系
UserModel2.hasMany(CommentModel);
CommentModel.belongsTo(UserModel2, { foreignKry: 'email' }); 
// 获取博客评论(要返回用户头像和用户名,需关联表,建立一对多关系)
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'

注释掉关联声明:

// 用户与评论是一对多关系
// 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的使用。