全栈开发实战:用 Vue2+Koa1 开发完整的前后端项目

7,280 阅读38分钟

简介

本文从一名新手的角度(默认对Vue有了解,对Koa或者Express有了解)出发,从0开始构建一个数据通过Koa提供API的形式获取,页面通过Vue渲染的完整的前端项目。可以了解到Vue构建单页面的一些知识以及前端路由的使用、Koa如何提供API接口,如何进行访问过滤(路由)、验证(JSON-WEB-TOKEN)以及Sequelize操作MySQL数据库的一些知识和技巧,希望能够作为一篇入门全栈开发的文章吧。

写在前面

我曾经写过一篇文章,是用express和mongodb入门Nodejs的前后端开发,这篇文章里简单的做了一个小demo,能够让你读写mongodb数据库,并且从数据库里将数据读取出来显示到页面上。算是一个简单的读写小demo吧,也算是服务端渲染的一次初尝试。并且我还写过用nodejs写简单小爬虫的 文章,用爬虫来获取数据写入数据库。通过以上的的方法我用express写了一个小网站,记录并显示北邮人论坛每天的十大的 内容。挺好玩的对吧,可以把想要做的事用代码来实现。

后来我接触到了Koa,并开始了学习,从express迁移到Koa其实曲线还算是比较平滑的。不过用Koa的方式也还是采用服务端渲染页面的方式。而且我发现目前网络上少有写过用Koa构建的前后端分离的应用、网站文章,我最近做的一个项目里需要用到的方式就是用Vue构建页面,数据的获取全部走后端API的形式,也就是所谓的前后端分离吧。正好在这过程中走了不少的坑,包括数据库的使用上也算是个新手,所以写篇文章记录一下,用同样的思路和方法构建一个简单的Todolist,欢迎讨论,轻拍~

项目架构

.├── LICENSE├── README.md├── .env  // 环境变量配置文件├── app.js  // Koa入口文件├── build // vue-cli 生成,用于webpack监听、构建│   ├── build.js│   ├── check-versions.js│   ├── dev-client.js│   ├── dev-server.js│   ├── utils.js│   ├── webpack.base.conf.js│   ├── webpack.dev.conf.js│   └── webpack.prod.conf.js├── config // vue-cli 生成&自己加的一些配置文件│   ├── default.conf│   ├── dev.env.js│   ├── index.js│   └── prod.env.js├── dist // Vue build 后的文件夹│   ├── index.html // 入口文件│   └── static // 静态资源├── index.html // vue-cli生成,用于容纳Vue组件的主html文件。单页应用就只有一个html├── package.json // npm的依赖、项目信息文件├── server // Koa后端,用于提供Api│   ├── config // 配置文件夹│   ├── controllers // controller-控制器│   ├── models // model-模型│   ├── routes // route-路由│   └── schema // schema-数据库表结构├── src // vue-cli 生成&自己添加的utils工具类│   ├── App.vue // 主文件│   ├── assets // 相关静态资源存放│   ├── components // 单文件组件│   ├── main.js // 引入Vue等资源、挂载Vue的入口js│   └── utils // 工具文件夹-封装的可复用的方法、功能└── yarn.lock // 用yarn自动生成的lock文件

看起来好像很复杂的样子,其实很大一部分文件夹的结构是vue-cli这个工具帮我们生成的。而我们需要额外添加的主要是Koa的入口文件以及一个server文件夹用于Koa提供API。这样的话,在获取数据的方面就可以走Koa所提供的API,而Vue只需关心怎么把这些数据渲染到页面上就好了。

项目用到的一些关键依赖

以下依赖的版本都是本文所写的时候的版本,或者更旧一些

  • Vue.js(v2.1.8)
  • Vue-Router(v2.1.1)
  • Axios(v0.15.3)
  • Element(v1.1.2)
  • Koa.js(v1.2.4) // 没采用Koa2
  • Koa-Router@5.4\Koa-jwt\Koa-static等一系列Koa中间件
  • Mysql(v2.12.0) // nodejs的mysql驱动,并不是mysql本身版本(项目采用mysql5.6)
  • Sequelize(v3.28.0) // 操作数据库的ORM
  • Yarn(v0.18.1) // 比起npm更快一些

剩下依赖可以参考本文最后给出的项目demo仓库。

项目启动

Nodejs与npm的安装不再叙述(希望大家装上的node版本大于等于6.x,不然还需要加上–harmony标志才可以开启es6),默认读者已经掌握npm安装依赖的方法。首先全局安装npm i vue-cli -g,当然本项目基本上是采用yarn,所以也可以yarn global add vue-cli

Tips: 可以给yarn换上淘宝源,速度更快: yarn config set registry "https://registry.npm.taobao.org"

然后我们初始化一个Vue2的webpack的模板:

vue init webpack demo

Tips: 上面的demo可以填写你自己的项目名称

然后进行一些基本配置选择之后,你就可以得到一个基本的vue-cli生成的项目结构。

接着我们进入vue-cli生成的目录,安装Vue的项目依赖并安装Koa的项目依赖:yarn && yarn add koa koa-router@5.4 koa-logger koa-json koa-bodyparser,(注意是安装koa-router的5.4版,因为7.X版本是支持Koa2的)然后进行一些基本目录建立:

vue-cli生成的demo目录下,建立server文件夹以及子文件夹:

├── server // Koa后端,用于提供Api
    ├── config // 配置文件夹
    ├── controllers // controller-控制器
        ├── models // model-模型
    ├── routes // route-路由
    └── schema // schema-数据库表结构

然后在demo文件夹下我们创建一个app.js的文件,作为Koa的启动文件。

写入如下基本的内容就可以启动Koa啦:

const app = require('koa')()
  , koa = require('koa-router')()
  , json = require('koa-json')
  , logger = require('koa-logger'); // 引入各种依赖
    app.use(require('koa-bodyparser')());
app.use(json());
app.use(logger());
app.use(function* (next){
  let start = new Date;
  yield next;
  let ms = new Date - start;
  console.log('%s %s - %s', this.method, this.url, ms); // 显示执行的时间
    });
app.on('error', function(err, ctx){
  console.log('server error', err);
    });
app.listen(8889,() => {
  console.log('Koa is listening in 8889');
    });
module.exports = app;

然后在控制台输入node app.js,能看到输出Koa is listening in 8889,则说明我们的Koa已经启动成功了,并在8889端口监听。

前端页面构建

这个DEMO是做一个Todo-List,我们首先来做一个登录页面。

Tips: 为了方便构建页面和美观,本文采用的Vue2的前端UI框架是element-ui。安装:yarn add element-ui

模板引擎我习惯用pug,CSS预处理我习惯用stylus,当然每个人自己的习惯和喜好是不一样的,所以大家根据自己平时的喜好来就行了。

为了方便大家查看代码,就不用pug了,学习成本相对高一些。不过CSS用stylus写起来简便,看起来也不会难懂,是我自己的习惯,所以还需要安装一下yarn add stylus stylus-loader

Tips: 安装stylus-loader是为了让webpack能够渲染stylus

然后要把element-ui引入项目中。打开src/main.js,将文件改写如下:

import Vue from 'vue'
import App from './App'
import ElementUI from 'element-ui' // 引入element-ui
import 'element-ui/lib/theme-default/index.css'
Vue.use(ElementUI) // Vue全局使用
new Vue({
  el: '#app',
  template: '<App/>',
  components: { App }
    })

然后我们在项目根目录下输入npm run dev,启动开发模式,这个模式有webpack的热加载,也就是你写完代码,浏览器立即就能响应变化。

为了实现响应式页面,我们要在项目目录下的index.htmlhead标签内加入以下meta

<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">

登录界面

进入src/components目录,新建一个Login.vue的文件。然后我们来写第一个页面:


<template>
  <el-row class="content">
    <el-col :xs="24" :sm="{span: 6,offset: 9}">
      <span class="title">
       欢迎登录 
      </span>
      <el-row>
        <el-input 
          v-model="account" 
          placeholder="账号"
          type="text">
        </el-input>
        <el-input 
          v-model="password" 
          placeholder="密码"
          type="password">
        </el-input>
        <el-button type="primary">登录</el-button>
      </el-row>
    </el-col>
  </el-row>
    </template>
<script>
export default {
  data () {
    return {
      account: '',
      password: ''
    };
  }
    };
</script>
<style lang="stylus" scoped>
  .el-row.content
    padding 16px
  .title
    font-size 28px
  .el-input
    margin 12px 0
  .el-button
    width 100%
    margin-top 12px    
    </style>

在这里就有一些值得注意的地方。首先是template标签内的直接子元素最多只能挂载一个。也就是你不能这么写:


<template>
  <el-row></el-row>
  <el-row></el-row>
    </template>

否则会报错:template syntax error Component template should contain exactly one root element,template下只能有一个根元素。不过为了写多个元素,你可以这样:


<template>
  <div>
    <el-row></el-row>
    <el-row></el-row>
  </div>
    </template>

同时注意到,在Login.vuestyle标签内有个scoped属性,这个属性能够使这些样式只在这个组件内生效(因为Webpack在渲染的时候会将这个组件内的元素自动打上一串形如data-v-62a7f97e这样的属性,对于这些样式也会变成形如.title[data-v-62a7f97e]{ font-size: 28px;}的样子,保证了不会和其他组件的样式冲突。

页面写完之后,如果不把组件注册到Vue之下那么页面是不会显示的。因此这个时候需要把APP.vue这个文件改写一下:

<template>
  <div id="app">
    <img src="./assets/logo.png">
    <Login></Login> <!--使用Login组件-->
  </div>
    </template>
<script>
import Login from './components/Login' // 引入Login组件
export default {
  name: 'app',
  components: {
    Login // 注册组件
  }
    }
</script>
<style>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
    }
</style>

也就是把Login这个组件注册到Vue下,同时你再看浏览器,已经不再是vue-cli默认生成的Hello欢迎界面了。

Login

Login

接着我们写一下登录成功后的界面。

TodoList页面

还是在src/components目录下,写一个叫做TodoList.vue的文件。

接着我们开始写一个TodoList:

<template>
  <el-row class="content">
    <el-col :xs="{span:20,offset:2}" :sm="{span:8,offset:8}">
      <span>
        欢迎:{{name}}!你的待办事项是:
      </span>
      <el-input placeholder="请输入待办事项" v-model="todos" @keyup.enter.native="addTodos"></el-input>
      <el-tabs v-model="activeName">
        <el-tab-pane label="待办事项" name="first">
          <el-col :xs="24">
            <template v-if="!Done"> <!--v-if和v-for不能同时在一个元素内使用,因为Vue总会先执行v-for-->
              <template v-for="(item, index) in list">
                <div class="todo-list" v-if="item.status == false">
                  <span class="item">
                    {{ index + 1 }}. {{ item.content }}
                  </span>
                  <span class="pull-right">
                    <el-button size="small" type="primary" @click="finished(index)">完成</el-button>
                    <el-button size="small" :plain="true" type="danger" @click="remove(index)">删除</el-button>
                  </span>
                </div>
              </template> 
            </template>
            <div v-else-if="Done">
              暂无待办事项
            </div>
          </el-col>
        </el-tab-pane>
        <el-tab-pane label="已完成事项" name="second">
          <template v-if="count > 0">
            <template v-for="(item, index) in list">
              <div class="todo-list" v-if="item.status == true">
                <span class="item finished">
                  {{ index + 1 }}. {{ item.content }}
                </span>
                <span class="pull-right">
                  <el-button size="small" type="primary" @click="restore(index)">还原</el-button>
                </span>
              </div>
            </template> 
          </template>
          <div v-else>
            暂无已完成事项
          </div>
        </el-tab-pane>
      </el-tabs>
    </el-col>
  </el-row>
    </template>
<script>
export default {
  data () {
    return {
      name: 'Molunerfinn',
      todos: '',
      activeName: 'first',
      list:[],
      count: 0
    };
  },
  computed: { // 计算属性用于计算是否已经完成了所有任务
    Done(){
      let count = 0;
      let length = this.list.length;
      for(let i in this.list){
        this.list[i].status == true ? count += 1 : '';
      }
      this.count = count;
      if(count == length || length == 0){
        return true
      }else{
        return false
      }
    }
  },
  methods: {
    addTodos() {
      if(this.todos == '')
        return
      let obj = {
        status: false,
        content: this.todos
      }
      this.list.push(obj);
      this.todos = '';
    },
    finished(index) {
      this.$set(this.list[index],'status',true) // 通过set的方法让数组的变动能够让Vue检测到
      this.$message({
        type: 'success',
        message: '任务完成'
      })
    },
    remove(index) {
      this.list.splice(index,1);
      this.$message({
        type: 'info',
        message: '任务删除'
      })
    },
    restore(index) {
      this.$set(this.list[index],'status',false)
      this.$message({
        type: 'info',
        message: '任务还原'
      })
    }
  }
    };
</script>
<style lang="stylus" scoped>
  .el-input
    margin 20px auto
  .todo-list
    width 100%
    margin-top 8px
    padding-bottom 8px
    border-bottom 1px solid #eee
    overflow hidden
    text-align left
    .item
      font-size 20px
      &.finished
        text-decoration line-through
        color #ddd
  .pull-right
    float right
    </style>

页面构建其实没有什么特别好说的,但是因为我自己有踩点坑,所以还是专门讲一下:

  1. v-ifv-for放在一个元素内同时使用,因为Vue总会先执行v-for,所以导致v-if不会被执行。替代地,你可以使用一个额外的template元素用来放置v-if或者v-for从而达到同样的目的。这是相关的issue

  2. 计算属性对于直接的数据比如a: 2 -> a: 3这样的数据变动可以直接检测到。但是如果是本例中的list的某一项的status这个属性变化了,如果我们直接使用list[index].status = true这样的写法的话,Vue将无法检测到数据变动。替代地,可以使用set方法(全局是Vue.set(),实例中是this.$set()),通过set方法可以让数据的变动变得可以被检测到。从而让计算属性能够捕捉到变化。可以参考官方文档对于响应式原理的 描述

Todolist

Todolist

写完TodoList之后,我们需要将它和vue-router配合起来,从而使这个单页应用能够进行页面跳转。

页面路由

由于不采用服务端渲染,所以页面路由走的是前端路由。安装一下vue-routeryarn add vue-router

安装好后,我们挂载一下路由。打开main.js文件改写如下:

// src/main.js
import Vue from 'vue'
import App from './App'
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-default/index.css'
import VueRouter from 'vue-router'
Vue.use(ElementUI);
Vue.use(VueRouter);
import Login from `./components/Login`
import TodoList from `./components/TodoList`
const router =  new VueRouter({
  mode: 'history', // 开启HTML5的history模式,可以让地址栏的url长得跟正常页面跳转的url一样。(不过还需要后端配合,讲Koa的时候会说)
  base: __dirname, 
  routes: [
    {
      path: '/',  // 默认首页打开是登录页
      component: Login
    },
    {
      path: '/todolist',
      component: TodoList
    },
    {
      path: '*',
      redirect: '/' // 输入其他不存在的地址自动跳回首页
    }
  ]
    })
const app = new Vue({
  router: router, // 启用router
  render: h => h(App) 
    }).$mount('#app') //挂载到id为app的元素上

这样就把路由挂载好了,但是你打开页面发现好像还是没有什么变化。这是因为我们没有把路由视图放到页面上。现在我们改写一下APP.vue

<!-- APP.vue -->
<template>
  <div id="app">
    <img src="./assets/logo.png">
    <router-view></router-view> <!-- 原本的Login换成了router-view 这就是路由视图渲染的目标元素-->
  </div>
    </template>
<script>
export default {
  name: 'app' // 不需要再引入`Login`\`TodoList`组件了,因为在路由里已经注册了
    }
</script>
<style>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
    }
</style>

然后再看一下你的页面,这个时候你如果在地址栏后加上/todolist那么就会跳转到TodoList页面啦。

不过我们如何通过点击登录按钮跳转到TodoList呢?改写一下Login.vue,就可以跳转了。

只需要给登录的button加一个方法即可:

<!-- Login.vue -->
······
<!-- 给input增加键盘事件,当输入完密码回车也执行loginToDo方法 -->
<el-input 
  v-model="password" 
  placeholder="密码"
  type="password"
  @keyup.enter.native="loginToDo">
    </el-input>
<!-- 增加一个click方法 loginToDo -->
<el-button type="primary" @click="loginToDo">登录</el-button>
······
<script>
export default {
  data () {
    return {
      account: '',
      password: ''
    };
  },
  methods: {
    loginToDo() {
      this.$router.push('/todolist') // 编程式路由,通过push方法,改变路由。
    }
  }
    };
</script>

然后你就可以通过点击登录按钮进行页面跳转了。并且你可以发现,页面地址从localhost:8080变成了localhost:8080/todolist,长得跟正常的url跳转一样。(但是实际上我们是单页应用,只是在应用内进行页面跳转而已,没有向后端额外请求)

login2todolist

login2todolist

至此,我们已经完成了一个纯前端的单页应用,能够进行页面跳转,能够做简单的ToDoList的添加和删除和还原。当然这个东西只能算是个能看不能用的东西——因为登录系统有名无实、ToDoList只要页面刷新一下就没了。

于是我们可以先把前端放一放。开启我们的后端之旅。

后端环境搭建

MySQL

之所以没有用Node界大家普遍喜爱的Mongodb主要是因为之前我用过它,而没有用过MySQL,本着学习的态度,我决定用用MySQL。还有就是Express + Mongodb的教程其实很早之前就已经满大街都是了。所以如果你觉得Mongodb更合你的胃口,看完本文你完全可以用Mongodb构建一个类似的应用。

MySQL官网下载安装对应平台MySQLCommunity Server

通常来说安装的步骤都是比较简单的。对于MySQL的基本安装、开启步骤可以参考这篇文章,这篇是windows的。当然其他平台的安装也是很方便的,都有相应的包管理工具可以获取。值得注意的就是,安装完MySQL之后你需要设定一下root账户的密码。保证安全性。如果你漏了设定,或者你不知道怎么设定,可以参考这篇 文章

因为我对MySQL的SQL语句不是很熟悉,所以我需要一个可视化的工具来操作MySQL。Windows上我用的是HediSQL,macOS上我用的是Sequel Pro。它们都是免费的。

然后我们可以用这些可视化工具连上MySQL的server(默认端口是3306)之后,创建一个新的数据库,叫做todolist。(当然你也可以用SQL语句:CREATE DATABASE todolist,之后不再赘述)。

接着我们可以来开始创建数据表了。

我们需要创建两张表,一张是用户表,一张是待办事项表。用户表用于登录、验证,待办事项表用于展示我们的待办事项。

创建一张user表,其中password我们稍后会进行md5加密(取32位)。

字段 类型 说明
id int(自增) 用户的id
user_name CHAR(50) 用户的名字
password CHAR(32) 用户的密码

创建一张list表,所需的字段是iduser_idcontentstatus即可。

字段 类型 说明
id int(自增) list的id
user_id int(11) 用户的id
content CHAR(255) list的内容
status tinyint(1) list的状态

直接跟数据库打交道的部分基本就是这样了。

Sequelize

跟数据库打交道的时候我们都需要一个好的操作数据库的工具,能够让我们用比较简单的方法来对数据库进行增删改查。对于Mongodb来说大家熟悉的是Mongoose以及我用过一个相对更简单点的Monk。对于MySQL,我选用的是 Sequelize,它支持多种关系型数据库(SqliteMySQLPostgres等),它的操作基本都能返回一个Promise对象,这样在Koa里面我们能够很方便地进行”同步”操作。

更多关于Sequelize的用法,可以参考官方文档,以及这两篇文章——Sequelize中文API文档Sequelize和MySQL对照

在用Sequelize连接数据库之前我们需要把数据库的表结构用sequelize-auto导出来。

更多关于sequelize-auto的使用可以参考官方介绍或者这篇文章

由此我们需要分别安装这几个依赖:yarn global add sequelize-auto && yarn add sequelize mysql

注:上面用yarn安装的mysql是nodejs环境下的mysql驱动。

进入server的目录,执行如下语句sequelize-auto -o "./schema" -d todolist -h 127.0.0.1 -u root -p 3306 -x XXXXX -e mysql,(其中 -o 参数后面的是输出的文件夹目录, -d 参数后面的是数据库名, -h 参数后面是数据库地址, -u 参数后面是数据库用户名, -p 参数后面是端口号, -x 参数后面是数据库密码,这个要根据自己的数据库密码来! -e 参数后面指定数据库为mysql)

然后就会在schema文件夹下自动生成两个文件:

// user.js
module.exports = function(sequelize, DataTypes) {
  return sequelize.define('user', {
    id: {
      type: DataTypes.INTEGER(11), // 字段类型
      allowNull: false, // 是否允许为NULL
      primaryKey: true, // 主键
      autoIncrement: true // 是否自增
    },
    user_name: {
      type: DataTypes.CHAR(50), // 最大长度为50的字符串
      allowNull: false
    },
    password: {
      type: DataTypes.CHAR(32),
      allowNull: false
    }
  }, {
    tableName: 'user' // 表名
  });
    };
// list.js
module.exports = function(sequelize, DataTypes) {
  return sequelize.define('list', {
    id: {
      type: DataTypes.INTEGER(11),
      allowNull: false,
      primaryKey: true,
      autoIncrement: true
    },
    user_id: {
      type: DataTypes.INTEGER(11),
      allowNull: false
    },
    content: {
      type: DataTypes.CHAR(255),
      allowNull: false
    },
    status: {
      type: DataTypes.INTEGER(1),
      allowNull: false
    }
  }, {
    tableName: 'list'
  });
    };

自动化工具省去了很多我们手动定义表结构的时间。同时注意到生成的数据库表结构文件都自动帮我们module.exports出来了,所以很方便我们之后的引入。

server目录下的config目录下我们新建一个db.js,用于初始化Sequelize和数据库的连接。

// db.js
const Sequelize = require('sequelize'); // 引入sequelize
// 使用url连接的形式进行连接,注意将root: 后面的XXXX改成自己数据库的密码
const Todolist = new Sequelize('mysql://root:XXXX@localhost/todolist',{
  define: {
    timestamps: false // 取消Sequelzie自动给数据表加入时间戳(createdAt以及updatedAt)
  }
    }) 
module.exports = {
  Todolist // 将Todolist暴露出接口方便Model调用
    }

接着我们去models文件夹里将数据库和表结构文件连接起来。在这个文件夹下新建一个user.js的文件。我们先来写一个查询用户id的东西。

为此我们可以先在数据库里随意加一条数据:

test

test

通常我们要查询一个用户id为1的数据,会很自然的想到类似如下的写法:


const userInfo = User.findOne({ where: { id: 1} }); // 查询
console.log(userInfo); // 输出结果

但是上面的写法实际上是行不通的。因为JS的特性让它的IO操作是异步的。而上面的写法,userInfo将是返回的一个Promise对象,而不是最终的userInfo。如果又想用同步的写法获取异步IO操作得到的数据的话,通常情况下是不能直接得到的。但是在Koa里,由于有co的存在,让这一切变得十分简单。改写如下:

// models/user.js
const db = require('../config/db.js'), 
      userModel = '../schema/user.js'; // 引入user的表结构
        const TodolistDb = db.Todolist; // 引入数据库
const User = TodolistDb.import(userModel); // 用sequelize的import方法引入表结构,实例化了User。
const getUserById = function* (id){ // 注意是function* 而不是function 对于需要yield操作的函数都需要这种generator函数。
  const userInfo = yield User.findOne({ // 用yield控制异步操作,将返回的Promise对象里的数据返回出来。也就实现了“同步”的写法获取异步IO操作的数据
    where: {
      id: id
    }
  });
  return userInfo // 返回数据
    }
module.exports = {
  getUserById  // 导出getUserById的方法,将会在controller里调用
    }

接着我们在controllers写一个user的controller,来执行这个方法,并返回结果。

// controllers/user.js 
const user = require('../models/user.js');
const getUserInfo = function* (){
  const id = this.params.id; // 获取url里传过来的参数里的id
  const result = yield user.getUserById(id);  // 通过yield “同步”地返回查询结果
  this.body = result // 将请求的结果放到response的body里返回
    }
module.exports = {
  auth: (router) => {
    router.get('/user/:id', getUserInfo); // 定义url的参数是id
  }
    }

写完这个还不能直接请求,因为我们还没有定义路由,请求经过Koa找不到这个路径是没有反应的。

routes文件夹下写一个auth.js的文件。(其实user表是用于登录的,所以走auth

// routes/auth.js
const user = require('../controllers/user.js'); 
const router = require('koa-router')();
user.auth(router); // 用user的auth方法引入router
module.exports = router; // 把router规则暴露出去

至此我们已经接近完成我们的第一个API了,还缺最后一步,将这个路由规则“挂载”到Koa上去。

回到根目录的app.js,改写如下:

const app = require('koa')()
  , koa = require('koa-router')()
  , json = require('koa-json')
  , logger = require('koa-logger')
  , auth = require('./server/routes/auth.js'); // 引入auth
    app.use(require('koa-bodyparser')());
app.use(json());
app.use(logger());
app.use(function* (next){
  let start = new Date;
  yield next;
  let ms = new Date - start;
  console.log('%s %s - %s', this.method, this.url, ms);
    });
app.on('error', function(err, ctx){
  console.log('server error', err);
    });
koa.use('/auth', auth.routes()); // 挂载到koa-router上,同时会让所有的auth的请求路径前面加上'/auth'的请求路径。
app.use(koa.routes()); // 将路由规则挂载到Koa上。
app.listen(8889,() => {
  console.log('Koa is listening in 8889');
    });
module.exports = app;

打开你的控制台,输入node app.js,一切运行正常没有报错的话,大功告成,我们的第一个API已经构建完成!

如何测试呢?

API Test

接口在跟跟前端对接之前,我们应该先进行一遍测试,防止出现问题。在测试接口的工具上,我推荐Postman,这个工具能够很好的模拟发送的各种请求,方便的查看响应结果,用来进行测试是最好不过了。

Postman

测试成功,我发送了正确的url请求,返回的结果也是我想看到的。我们看到返回的结果实际上是个JSON,这对于我们前后端来说都是十分方便处理的数据格式。

但是如果我们代码出了问题,返回error了我们该怎么测试呢?如果说控制台能够反馈一定的信息,但是绝对不充分,并且我们很可能不知道哪步出错了导致最终结果出问题。

所以我推荐用VSCode这个工具来帮我们调试nodejs后端的代码。它能够添加断点,能够很方便地查看请求的信息。并且配合上nodemon这类的工具,调试简直不要更舒服。

关于VSCode的nodejs调试,可以参考官方的这篇文章

我自己是用Sublime写代码,用VSCode调试,哈哈。

登录系统的实现

刚才实现的不过是一个简单的用户信息查询的接口,但是我们要实现的是一个登录系统,所以还需要做一些工作。

JSON-WEB-TOKEN

基于cookie或者session的登录验证已经屡见不鲜,前段时间JSON-WEB-TOKEN出来后很是风光了一把。引入了它之后,能够实现真正无状态的请求,而不是基于session和cookie的存储式的有状态验证。

关于JSON-WEB-TOKEN的描述可以参考这篇文章比较简单,我还推荐一篇文章,将如何使用JSON-WEB-TOKEN写得很清楚。

另外可以在JSON-WEB-TOKEN的官网上感受一下。

Tips:JSON-WEB-TOKEN分三部分,头部信息+主体信息+密钥信息,其中主体传递的信息(是我们存放我们需要的信息的部分)是用BASE64编码的,所以很容易被解码,一定不能存放明文密码这种关键信息!替代地可以存放一些不是特别关键的信息,比如用户名这样能够做区分的信息。

简单来说,运用了JSON-WEB-TOKEN的登录系统应该是这样的:

  1. 用户在登录页输入账号密码,将账号密码(密码进行md5加密)发送请求给后端
  2. 后端验证一下用户的账号和密码的信息,如果符合,就下发一个TOKEN返回给客户端。如果不符合就不发送TOKEN回去,返回验证错误信息。
  3. 如果登录成功,客户端将TOKEN用某种方式存下来(SessionStorage、LocalStorage),之后要请求其他资源的时候,在请求头(Header)里带上这个TOKEN进行请求。
  4. 后端收到请求信息,先验证一下TOKEN是否有效,有效则下发请求的资源,无效则返回验证错误。

通过这个TOKEN的方式,客户端和服务端之间的访问,是无状态的:也就是服务端不知道你这个用户到底还在不在线,只要你发送的请求头里的TOKEN是正确的我就给你返回你想要的资源。这样能够不占用服务端宝贵的空间资源,而且如果涉及到服务器集群,如果服务器进行维护或者迁移或者需要CDN节点的分配的话,无状态的设计显然维护成本更低。

话不多说,我们来把JSON-WEB-TOKEN用到我们的项目中。

yarn add koa-jwt,安装KoaJSON-WEB-TOKEN库。

我们需要在models里的user.js加一个方法,通过用户名查找用户:

// models/user.js
// ......
// 前面的省略了
// 新增一个方法,通过用户名查找
const getUserByName = function* (name){
  const userInfo = yield User.findOne({
    where: {
      user_name: name
    }
  })
  return userInfo
    }
module.exports = {
  getUserById, // 导出getUserById的方法,将会在controller里调用
  getUserByName
    }

然后我们写一下controllers里的user.js

// controllers/user.js
const user = require('../models/user.js');
const jwt = require('koa-jwt'); // 引入koa-jwt
const getUserInfo = function* (){
  const id = this.params.id; // 获取url里传过来的参数里的id
  const result = yield user.getUserById(id);  // 通过yield “同步”地返回查询结果
  this.body = result // 将请求的结果放到response的body里返回
    }
const postUserAuth = function* (){
  const data = this.request.body; // post过来的数据存在request.body里
  const userInfo = yield user.getUserByName(data.name);
  if(userInfo != null){ // 如果查无此用户会返回null
    if(userInfo.password != data.password){
      this.body = {
        success: false, // success标志位是方便前端判断返回是正确与否
        info: '密码错误!'
      }
    }else{ // 如果密码正确
      const userToken = {
        name: userInfo.user_name,
        id: userInfo.id
      }
      const secret = 'vue-koa-demo'; // 指定密钥,这是之后用来判断token合法性的标志
      const token = jwt.sign(userToken,secret); // 签发token
      this.body = {
        success: true,
        token: token, // 返回token
      }
    }
  }else{
    this.body = {
      success: false,
      info: '用户不存在!' // 如果用户不存在返回用户不存在
    }
  }
    }
module.exports = {
  auth: (router) => {
    router.get('/user/:id', getUserInfo); // 定义url的参数是id
    router.post('/user', postUserAuth);
  }
    }

由此我们写完了用户认证的部分。接下去我们要改写一下前端登录的方法。

引入Axios

之前在学Vue的时候一直用的是vue-resource,不过后来Vue2出来之后,Vue官方不再默认推荐它为官方的ajax网络请求库了。替代地推荐了一些其他的库,比如就有我们今天要用的 axios。我之前也没有用过它,不过看完它的star和简要介绍Promise based HTTP client for the browser and node.js,能够同时支持node和浏览器端的ajax请求工具(还是基于Promised的!),我想就有必要用一用啦。

yarn add axios,安装axios。然后我们在src/main.js里面引入axios


// scr/main.js
// ...
import Axios from 'axios'
Vue.prototype.$http = Axios // 类似于vue-resource的调用方法,之后可以在实例里直接用this.$http.get()等
// ...
// Login.vue
// 省略前面的部分
 methods: {
    loginToDo() {
      let obj = {
        name: this.account,
        password: this.password
      } 
      this.$http.post('/auth/user', obj) // 将信息发送给后端
        .then((res) => { // axios返回的数据都在res.data里
          if(res.data.success){ // 如果成功
            sessionStorage.setItem('demo-token',res.data.token); // 用sessionStorage把token存下来
            this.$message({ // 登录成功,显示提示语
              type: 'success',
              message: '登录成功!'
            }); 
            this.$router.push('/todolist') // 进入todolist页面,登录成功
          }else{
            this.$message.error(res.data.info); // 登录失败,显示提示语
            sessionStorage.setItem('demo-token',null); // 将token清空
          }
        }, (err) => {
            this.$message.error('请求错误!')
            sessionStorage.setItem('demo-token',null); // 将token清空
        })
    }
  }
    

密码md5加密

同时,前端向后端发送的密码应当进行md5加密。

所以我们需要安装一下md5的库: yarn add md5

然后在Login.vue下把loginToDo的方法修改一下:

import md5 from 'md5'
export default {
  data () {
    return {
      account: '',
      password: ''
    };
  },
  methods: {
    loginToDo() {
      let obj = {
        name: this.account,
        password: md5(this.password) // md5加密
      } 
      this.$http.post('/auth/user', obj) // 将信息发送给后端
        .then((res) => {
          console.log(res);
          if(res.data.success){ // 如果成功
            sessionStorage.setItem('demo-token',res.data.token); // 用sessionStorage把token存下来
            this.$message({ // 登录成功,显示提示语
              type: 'success',
              message: '登录成功!'
            }); 
            this.$router.push('/todolist') // 进入todolist页面,登录成功
          }else{
            this.$message.error(res.data.info); // 登录失败,显示提示语
            sessionStorage.setItem('demo-token',null); // 将token清空
          }
        }, (err) => {
            this.$message.error('请求错误!')
            sessionStorage.setItem('demo-token',null); // 将token清空
        })
    }
  }
    };

因为我们数据库里还是存着明文的123作为密码,现在要先将它md5化,32位md5加密后变为:202cb962ac59075b964b07152d234b70,将其替换掉数据库里的123。不做这步我们将无法登录。

还没有大功告成,因为我们的界面跑在8080端口,但是Koa提供的API跑在8889端口,所以如果直接通过/auth/user这个url去post是请求不到的。就算写成localhost:8889/auth/user也会因为跨域问题导致请求失败。

这个时候有两种最方便的解决办法:

  1. 如果是跨域,服务端只要在请求头上加上CORS,客户端即可跨域发送请求。
  2. 变成同域,即可解决跨域请求问题。

第一种也很方便,采用kcors即可解决。 不过为了之后部署方便,我们采用第二种,变成同域请求。

打开根目录下的config/index.js,找到dev下的proxyTable,利用这个proxyTable我们能够将外部的请求通过webpack转发给本地,也就能够将跨域请求变成同域请求了。

proxyTable改写如下:

 proxyTable: {
  '/auth':{
    target: 'http://localhost:8889',
    changeOrigin: true
  },
  '/api':{
    target: 'http://localhost:8889',
    changeOrigin: true
  }
    }

上面的意思是,我们在组件里请求的地址如果是/api/xxxx实际上请求的是http://localhost:8889/api/xxxx,但是由于webpack帮我们代理了localhost的8889端口的服务,所以我们可以把实际是跨域的请求当做是同域下的接口来调用。

此时重新启动一下webpack:先ctrl+c退出当前进程,然后npm run dev

一切都万事了之后,我们可以看到如下激动人心的画面:

login2todolist

login2todolist

跳转拦截

虽然我们现在能够成功登录系统了,但是还是存在一个问题:我在地址栏手动将地址改为localhost:8080/todolist我还是能够成功跳转到登录后的界面啊。于是这就需要一个跳转拦截,当没有登录的时候,不管地址栏输入什么地址,最终都重新定向回登录页。

这个时候,从后端给我们传回来的token就派上大用处。有token就说明我们的身份是经过验证的,否则就是非法的。

vue-router提供了页面跳转的钩子,我们可以在router跳转前进行验证,如果token存在就跳转,如果不存在就返回登录页。

参考路由的导航钩子

打开src/main.js,修改如下:

// src/main.js
// ...
const router = new VueRouter({....}) // 省略
router.beforeEach((to,from,next) =>{
  const token = sessionStorage.getItem('demo-token');
  if(to.path == '/'){ // 如果是跳转到登录页的
    if(token != 'null' && token != null){
      next('/todolist') // 如果有token就转向todolist不返回登录页
    }
    next(); // 否则跳转回登录页
  }else{
    if(token != 'null' && token != null){
      next() // 如果有token就正常转向
    }else{
      next('/') // 否则跳转回登录页
    }
  }
    })
const app = new Vue({...}) // 省略

注意:一定要确保要调用 next() 方法,否则钩子就不会被 resolved。如果纯粹调用next(path)这样的方法最终还是会回到.beforeEach()这个钩子里面来,如果没有写对条件就有可能出现死循环,栈溢出的情况。

然后我们就可以看到如下效果:

login2todolist

login2todolist

Tips:这种只判断token存不存在就通过的验证是很不安全的,此例只是做了一个演示,实际上还应该进行更深一层的判断,比如从token解包出来的信息里包含我们想要的信息才可以作为有效token,才可以登录。等等。本文只是做一个简要介绍。

解析token

注意到我们在签发token的时候,写过这样几句话:


// server/controllers/user.js
// ...
const userToken = {
  name: userInfo.user_name,
  id: userInfo.id
    }
const secret = 'vue-koa-demo'; // 指定密钥,这是之后用来判断token合法性的标志
const token = jwt.sign(userInfo,secret); // 签发token
// ...

我们将用户名和id打包进JWT的主体部分,同时我们解密的密钥是vue-koa-demo。所以我们可以通过这个信息,来进行登录后的用户名显示,以及用来区别这个用户是谁,这个用户有哪些Todolist

接下来在Todolist页面进行token解析,从而让用户名显示为登录用户名。


// src/components/TodoList.vue
// ...
import jwt from 'jsonwebtoken' // 我们安装koa-jwt的时候会自动下载这个依赖
export default {
  created(){ // 组件创建时调用
    const userInfo = this.getUserInfo(); // 新增一个获取用户信息的方法
    if(userInfo != null){
      this.id = userInfo.id;
      this.name = userInfo.name;
    }else{
      this.id = '';
      this.name = ''
    }
  },
  data () {
    return {
      name: '', // 用户名改为空
      todos: '',
      activeName: 'first',
      list:[],
      count: 0,
      id: '' // 新增用户id属性,用于区别用户
    };
  },
  computed: {...}, //省略
  methods: {
    addTodos() {...}, // 省略
    finished(index) {...},
    remove(index) {...},
    restore(index) {...},
    getUserInfo(){ // 获取用户信息
      const token = sessionStorage.getItem('demo-token');
      if(token != null && token != 'null'){
        let decode = jwt.verify(token,'vue-koa-demo'); // 解析token
        return decode // decode解析出来实际上就是{name: XXX,id: XXX}
      }else {
        return null
      }
    }
  }
    };
// ...

于是你就可以看到:

todolist

todolist

用户名已经不是我们之前默认的Molunerfinn而是登录名molunerfinn了。

Todolist 增删改查的实现

这个部分就是前后端协作了。我们要实现之前在纯前端部分实现的内容。我以最基本的两个方法来举例子:获取Todolist以及增加Todolist,剩下其实思路大同小异,我就提供代码和注释了,我相信也很容易懂。

Token的发送

之前说了,用JSON-WEB-TOKEN之后,这个系统的验证就完全依靠token了。如果token正确就下发资源,如果资源不正确,就返回错误信息。

因为我们用了koa-jwt,所以只需要在每条请求头上加上Authorization属性,值是Bearer {token值},然后让Koa在接收请求之前验证一下token即可。但是如果每发一条请求就要手动写一句这个,太累了。于是我们可以做到全局Header设定。

打开src/main.js,在路由跳转的钩子里加上这句:


// scr/main.json
router.beforeEach((to,from,next) =>{
  const token = sessionStorage.getItem('demo-token');
  if(to.path == '/'){ 
    if(token != 'null' && token != null){
      next('/todolist') 
    }
    next(); 
  }else{
    if(token != 'null' && token != null){
      Vue.prototype.$http.defaults.headers.common['Authorization'] = 'Bearer ' + token; // 全局设定header的token验证,注意Bearer后有个空格
      next() 
    }else{
      next('/') 
    }
  }
    })

这样就完成了token的客户端发送设定。

Koa端对Token的验证

接着我们实现两个简单的api,这两个api请求的路径就不是/auth/xxx而是/api/xxx了。我们还需要实现,访问/api/*路径的请求都需要经过koa-jwt的验证,而/auth/*的请求不需要。

首先去models目录下新建一个todolist.js的文件:


// server/models/todolist.js
const db = require('../config/db.js'), 
      todoModel = '../schema/list.js'; // 引入todolist的表结构
        const TodolistDb = db.Todolist; // 引入数据库
const Todolist = TodolistDb.import(todoModel); 
const getTodolistById = function* (id){  // 获取某个用户的全部todolist
  const todolist = yield Todolist.findAll({ // 查找全部的todolist
    where: {
      user_id: id
    },
    attributes: ['id','content','status'] // 只需返回这三个字段的结果即可
  });
  return todolist // 返回数据
    }
const createTodolist = function* (data){ // 给某个用户创建一条todolist
  yield Todolist.create({
    user_id: data.id, // 用户的id,用来确定给哪个用户创建
    content: data.content,
    status: data.status 
  })
  return true
    }
module.exports = {
  getTodolistById,
  createTodolist
    }

接着去controllers目录下新建一个todolist.js的文件:

// server/controllers/todolist
const todolist = require('../models/todolist.js');
const getTodolist = function* (){ // 获取某个用户的所有todolist
  const id = this.params.id; // 获取url里传过来的参数里的id
  const result = yield todolist.getTodolistById(id);  // 通过yield “同步”地返回查询结果
  this.body = result // 将请求的结果放到response的body里返回
    }
const createTodolist = function* (){ // 给某个用户创建一条todolist
  const data = this.request.body; // post请求,数据是在request.body里的
  const result = yield todolist.createTodolist(data);
  this.body = {
    success: true
  }
    }
module.exports = (router) => {
  router.get('/todolist/:id', getTodolist),
  router.post('/todolist', createTodolist)
    }

然后去routes文件夹里新建一个api.js文件:


// server/routes/api.js
const todolist = require('../controllers/todolist.js');
const router = require('koa-router')();
todolist(router); // 引入koa-router
module.exports = router; // 导出router规则

最后,去根目录下的app.js,给koa加上新的路由规则:


// app.js
const app = require('koa')()
  , koa = require('koa-router')()
  , json = require('koa-json')
  , logger = require('koa-logger')
  , auth = require('./server/routes/auth.js')
  , api = require('./server/routes/api.js')
  , jwt = require('koa-jwt');
    // ..... 省略
app.use(function* (next){
  let start = new Date;
  yield next;
  let ms = new Date - start;
  console.log('%s %s - %s', this.method, this.url, ms);
    });
app.use(function *(next){  //  如果JWT验证失败,返回验证失败信息
  try {
    yield next;
  } catch (err) {
    if (401 == err.status) {
      this.status = 401;
      this.body = {
        success: false,
        token: null,
        info: 'Protected resource, use Authorization header to get access'
      };
    } else {
      throw err;
    }
  }
    });
app.on('error', function(err, ctx){
  console.log('server error', err);
    });
koa.use('/auth', auth.routes()); // 挂载到koa-router上,同时会让所有的auth的请求路径前面加上'/auth'的请求路径。
koa.use("/api",jwt({secret: 'vue-koa-demo'}),api.routes()) // 所有走/api/打头的请求都需要经过jwt中间件的验证。secret密钥必须跟我们当初签发的secret一致
app.use(koa.routes()); // 将路由规则挂载到Koa上。
// ...省略

至此,后端的两个api已经构建完成。

初始化配置相对复杂一些,涉及到modelcontrollersroutesapp.js,可能会让人望而却步。实际上第一次构建完成之后,后续要添加api,基本上只需要在modelcontrollers写好方法,定好接口即可,十分方便。

前端对接接口

后端接口已经开放,接下来要把前端和后端进行对接。主要有两个对接接口:

  1. 获取某个用户的所有todolist
  2. 创建某个用户的一条todolist

接下来就是改写Todolist.vue里的方法了:


// todolist.js
// ... 省略
created(){
  const userInfo = this.getUserInfo();
  if(userInfo != null){
    this.id = userInfo.id;
    this.name = userInfo.name;
  }else{
    this.id = '';
    this.name = ''
  }
  this.getTodolist(); // 新增:在组件创建时获取todolist
    },
// ... 省略
methods: {
  addTodos() {
    if(this.todos == '')
      return
    let obj = {
      status: false,
      content: this.todos,
      id: this.id
    }
    this.$http.post('/api/todolist', obj) // 新增创建请求
      .then((res) => {
        if(res.status == 200){ // 当返回的状态为200成功时
          this.$message({
            type: 'success',
            message: '创建成功!' 
          })
          this.getTodolist(); // 获得最新的todolist
        }else{
          this.$message.error('创建失败!') // 当返回不是200说明处理出问题
        }
      }, (err) => {
        this.$message.error('创建失败!') // 当没有返回值说明服务端错误或者请求没发送出去
        console.log(err)
      })
    this.todos = ''; // 将当前todos清空
  },
  // ... 省略一些方法
  getTodolist(){
    this.$http.get('/api/todolist/' + this.id) // 向后端发送获取todolist的请求
      .then((res) => {
        if(res.status == 200){
          this.list = res.data // 将获取的信息塞入实例里的list
        }else{
          this.$message.error('获取列表失败!')
        }
      }, (err) => {
        this.$message.error('获取列表失败!')
        console.log(err)
      })
  }
    }

至此,前后端的部分已经完整构建。让我们来看看效果:

todolist

todolist

做到这一步的时候其实我们的应用已经基本完成了。最后的收尾工作,让我们来收一下。

原本的前端版本还有完成删除还原三种状态,其中完成还原只是状态的切换(更新),所以可以算是一个api,然后就是删除是单独一个api。于是我们就能算是完成了增、删、改、查了。接下去的部分就提供代码就行,其实思路跟之前的是一样的,只不过操作的函数不一样罢了。

Todolist的改、删


// server/models/todolist.js
// ...省略
const removeTodolist = function* (id,user_id){
  yield Todolist.destroy({
    where: {
      id,
      user_id
    }
  })
  return true
    }
const updateTodolist = function* (id,user_id,status){
  yield Todolist.update(
    {
      status
    },
    {
      where: {
        id,
        user_id
      }
    }
  )
  return true
    }
module.exports = {
  getTodolistById,
  createTodolist,
  removeTodolist,
  updateTodolist
    }

// server/controllers/todolist.js
// ... 省略
const removeTodolist = function* (){
  const id = this.params.id;
  const user_id = this.params.userId;
  const result = yield todolist.removeTodolist(id,user_id);
  this.body = {
    success: true
  }
    }
const updateTodolist = function* (){
  const id = this.params.id;
  const user_id = this.params.userId;
  let status = this.params.status; 
  status == '0' ? status = true : status =  false;// 状态反转(更新)
  const result = yield todolist.updateTodolist(id,user_id,status);
  this.body = {
    success: true
  }
    }
module.exports = (router) => {
  router.get('/todolist/:id', getTodolist),
  router.post('/todolist', createTodolist),
  router.delete('/todolist/:userId/:id', removeTodolist),
  router.put('/todolist/:userId/:id/:status', updateTodolist)
    }
 <!-- src/components/TodoList.vue -->
....
<!-- 把完成和还原的方法替换成了update -->
<el-button size="small" type="primary" @click="update(index)">完成</el-button>
....
<el-button size="small" type="primary" @click="update(index)">还原</el-button>
....
<script>
// ....省略
  methods:{
    // ... 省略
    update(index) {
      this.$http.put('/api/todolist/'+ this.id + '/' + this.list[index].id + '/' + this.list[index].status)
        .then((res) => {
          if(res.status == 200){
            this.$message({
              type: 'success',
              message: '任务状态更新成功!'
            })
            this.getTodolist();
          }else{
            this.$message.error('任务状态更新失败!')
          }
        }, (err) => {
          this.$message.error('任务状态更新失败!')
          console.log(err)
        })
    },
    remove(index) {
      this.$http.delete('/api/todolist/'+ this.id + '/' + this.list[index].id)
        .then((res) => {
          if(res.status == 200){
            this.$message({
              type: 'success',
              message: '任务删除成功!'
            })
            this.getTodolist();
          }else{
            this.$message.error('任务删除失败!')
          }
        }, (err) => {
          this.$message.error('任务删除失败!')
          console.log(err)
        })
    },
  }
    // ... 省略
</script>
....

让我们来看看最后99%成品的效果吧:

Todolist

todolist

项目部署

很多教程到类似于我上面的部分就结束了。但是实际上我们做一个项目最想要的就是部署给大家用不是么?

在部署这块有些坑,需要让大家也一起知道一下。这个项目是个全栈项目(虽然是个很简单的。。。),所以就涉及到前后端通信的问题,也就会涉及到是同域请求还是跨域请求。

我们也说过,要解决这个问题有两种方便的解决办法,第一种,服务端加上cors,客户端就可以随意的跨域请求。但是这样会有个问题,因为我们是以同域的形式开发,请求的地址也是写的相对地址:/api/*auth/*这样的路径,访问的路径的自然是同域。如果要在服务端加上cors,我们还需要将我们的所有请求地址改成localhost:8889/api/*localhost:8889/auth/*,这样的话,如果服务端的端口号一变,我们还需要重新修改前端所有的请求地址。这样很不方便也很不科学。

因此,要将请求变为同域才是最好的解决办法——不管服务端端口号怎么变,只要是同域都可以请求到。

于是要把Vue和Koa结合起来变成一个完整的项目(之前实际上都是在开发模式下,webpack帮我们进行请求的代理转发,所以看起来像是同域请求,而Vue和Koa并没有完全结合起来),就得在生产模式下,将Vue的静态文件交给Koa“托管”,所有访问前端的请求全部走Koa端,包括静态文件资源的请求,也走Koa端,把Koa作为一个Vue项目的静态资源服务器,这样就可以让Vue里的请求走的都是同域了。(相当于,之前开发模式是webpack开启了一个服务器托管了Vue的资源和请求,现在生产模式下改成Koa托管Vue的资源和请求)

要在开发和生产模式改变不同的托管服务器,其实也很简单,只需要在生产模式下,用Koa的静态资源服务中间件托管构建好的Vue文件即可。

Webpack打包

部署之前我们要用Webpack将我们的前端项目打包输出一下。但是如果直接用npm run build,你会发现打包出来的文件太大了:


                                                  Asset       Size  Chunks             Chunk Names
    static/css/app.d9034fc06fd57ce00d6e75ed49f0dafe.css     120 kB    2, 0  [emitted]  app
                 static/fonts/element-icons.a61be9c.eot    13.5 kB          [emitted]
                   static/img/element-icons.09162bc.svg    17.4 kB          [emitted]
             static/js/manifest.8ea250834bdc80e4d73b.js  832 bytes       0  [emitted]  manifest
               static/js/vendor.75bbe7ecea37b0d4c62d.js     623 kB    1, 0  [emitted]  vendor
                  static/js/app.e2d125562bfc4c57f9cb.js    16.5 kB    2, 0  [emitted]  app
                 static/fonts/element-icons.b02bdc1.ttf    13.2 kB          [emitted]
         static/js/manifest.8ea250834bdc80e4d73b.js.map    8.86 kB       0  [emitted]  manifest
           static/js/vendor.75bbe7ecea37b0d4c62d.js.map    3.94 MB    1, 0  [emitted]  vendor
              static/js/app.e2d125562bfc4c57f9cb.js.map    64.8 kB    2, 0  [emitted]  app
                static/css/app.d9034fc06fd57ce00d6e75ed49f0dafe.css.map     151 kB    2, 0  [emitted]  app
                                             index.html  563 bytes          [emitted]
                                                

竟然有3.94MB的map文件。这肯定是不能接受的。于是要修改一下webpack的输出的设置,取消输出map文件。

找到根目录下的config/index.js:把productionSourceMap: true这句话改成productionSourceMap: false。然后再执行一遍npm run build

                                              Asset       Size  Chunks             Chunk Names
             static/fonts/element-icons.a61be9c.eot    13.5 kB          [emitted]
             static/fonts/element-icons.b02bdc1.ttf    13.2 kB          [emitted]
               static/img/element-icons.09162bc.svg    17.4 kB          [emitted]
         static/js/manifest.3ba218c80028a707a728.js  774 bytes       0  [emitted]  manifest
           static/js/vendor.75bbe7ecea37b0d4c62d.js     623 kB    1, 0  [emitted]  vendor
              static/js/app.b6acaca2531fc0baa447.js    16.5 kB    2, 0  [emitted]  app
                static/css/app.d9034fc06fd57ce00d6e75ed49f0dafe.css     120 kB    2, 0  [emitted]  app
                                         index.html  563 bytes          [emitted]
                                            

把sourceMap去掉了之后,体积就小下来了。虽然600+kb的大小还是有点大,不过放到服务端,gzip之后只剩150+kb的体积勉强还是可以接受。当然,对于webpack输出的优化,不是本文讨论的范围,有很多更好的文章讲述了这个东西,故本文不再详细展开。

打包好后就是相当于输出了一堆静态文件,当然这堆静态文件需要放在服务端才可以访问。我们要将这堆静态资源用Koa托管。

Koa serve静态资源

yarn add koa-static

打开app.js,引入两个新依赖,其中path是nodejs原生自带。

// app.js
// .... 
const path =require('path')
    , serve = require('koa-static');
    // ....
// 静态文件serve在koa-router的其他规则之上 
app.use(serve(path.resolve('dist'))); // 将webpack打包好的项目目录作为Koa静态文件服务的目录
// 下面这些是之前就有的。。。为了方便找位置故标示出来
koa.use('/auth', auth.routes());
koa.use("/api",jwt({secret: 'vue-koa-demo'}),api.routes()) 
// ...

然后重新运行一遍node app.js,看到输出Koa is listening in 8889后,你可以打开浏览器localhost:8889就可以看到如下情景:

vue-koa

至此已经基本上接近尾声,不过还存在一个问题:如果我们登录进去之后,在todolist页面一刷新,就会出现:

404

404

为什么会出现这种情况?简单来说是因为我们使用了前端路由,用了HTML5 的History模式,如果没有做其他任何配置的话,刷新页面,那么浏览器将会去服务端访问这个页面地址,因为服务端并没有配置这个地址的路由,所以自然就返回404 Not Found了。

详细可以参考vue-router的这篇文档

该怎么解决?其实也很简单,多加一个中间件:koa-history-api-fallback即可.

yarn add koa-history-api-fallback


//... 省略
const historyApiFallback = require('koa-history-api-fallback'); // 引入依赖
app.use(require('koa-bodyparser')());
app.use(json());
app.use(logger());
app.use(historyApiFallback()); // 在这个地方加入。一定要加在静态文件的serve之前,否则会失效。
// ...

这个时候,你再重新启动一下koa,登录之后再刷新页面,就不会再出现404 Not Found了。

API Test

本来写到上面基本本文已经算是结束了。但是由于我在开发的过程中遇到了一些问题,所以还需要做一些微调。

我们知道koa的use方法是有顺序只差的。

const app = require('koa');
app.use(A);
app.use(B);
const app = require('koa');
app.use(B);
app.use(A);

这二者是有区别的,谁先被use,谁的规则就放到前面先执行。

因此如果我们将静态文件的serve以及historyApiFallback放在了api的请求之前,那么用postman测试api的时候总会先返回完整的页面:

postman

因此正确的做法,应该是将它们放到我们写的api的规则之后:


// app.js
// ...
koa.use('/auth', auth.routes()); // 挂载到koa-router上,同时会让所有的auth的请求路径前面加上'/auth'的请求路径。
koa.use("/api",jwt({secret: 'vue-koa-demo'}),api.routes()) // 所有走/api/打头的请求都需要经过jwt验证。
app.use(koa.routes()); // 将路由规则挂载到Koa上。
app.use(historyApiFallback()); // 将这两个中间件挂载在api的路由之后
app.use(serve(path.resolve('dist'))); // 将webpack打包好的项目目录作为Koa静态文件服务的目录

这样就能正常返回数据了。

Nginx配置

真正部署到服务器的时候,我们肯定不会让大家输入域名:8889这样的方式让大家访问。所以需要用Nginx监听80端口,把访问我们指定域名的请求引导转发给Koa服务端。

大致的nginx.conf如下:

http {
  # ....
  upstream koa.server{
    server 127.0.0.1:8889;
  }
  server {
    listen   80;
    server_name xxx.xxx.com;
    location / {
      proxy_pass http://koa.server;
      proxy_redirect off;
    }
    #....
  }
  #....
    }

如果有精力还可以配置一下Nginx的Gzip,能让请求的JS\CSS\HTML等静态文件更小,响应速度更快些。

写在最后

至此,我们已经完成了一个从前端到后端,从本地到服务器的完整项目。虽然它真的是个很简单的小东西,被大家也已经用其他的方式写烂了(比如用localStorage做存储)。但是它作为一个完整的前后端的DEMO,我觉得让大家入门也相对更容易一些,能够体会到全栈开发也不是想象中的“那么难”(入门的难度还是可以接受的嘛)。有了Nodejs之后我们能够做的事真的好多!

当然,由于篇幅有限,本文能够讲述东西毕竟不够多,而且讲的东西也不可能面面俱到,很多东西都是点到即止,让大家能够自己发挥。其实还想讲讲Event Bus的简单使用,还有分页的基本实现等等,东西太多了,一时间大家消化不了。

实际上我在做前段时间的项目的时候,也是完全不知道怎么把Vue和Koa结合起来开发。我甚至不知道怎么用Koa来提供API,我只会用Koa来做服务端渲染,比如那些JADE\EJS等模板引擎渲染的页面。所以之前那个项目做完让我自己学到良多东西,故而也分享给大家。

实际上本文的Koa的api提供的形式也尽量和RESTful靠拢了,因此你也可以学会如何通过Koa提供RESTful形式的API了。

最后放上本文项目的Github地址,如果这个项目对你有帮助,希望大家可以fork,给我提建议,如果再有时间,可以点个Star那就更好啦~

新年前的最后一篇文章了吧,提前祝大家新年快乐233。

注: 转载需经过同意,必须署名


本文对你有帮助?欢迎扫码加入前端学习小组微信群: