├── .babelrc ├── .dockerignore ├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .postcssrc.js ├── .vscode ├── launch.json └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── Vue+Koa.md ├── app.js ├── build ├── build.js ├── check-versions.js ├── dev-client.js ├── dev-server.js ├── utils.js ├── vue-loader.conf.js ├── webpack.base.conf.js ├── webpack.dev.conf.js └── webpack.prod.conf.js ├── config ├── dev.env.js ├── index.js └── prod.env.js ├── docker-compose.yml ├── env.js ├── index.html ├── init_db.sh ├── mysql.dockerfile ├── package.json ├── pm2.json ├── server-entry.js ├── server ├── config │ └── db.js ├── controllers │ ├── todolist.js │ └── user.js ├── models │ ├── todolist.js │ └── user.js ├── routes │ ├── api.js │ └── auth.js └── schema │ ├── list.js │ └── user.js ├── sql ├── list.sql └── user.sql ├── src ├── App.vue ├── assets │ └── logo.png ├── components │ ├── Hello.vue │ ├── Login.vue │ └── TodoList.vue └── main.js ├── static └── .gitkeep ├── test ├── client │ ├── __snapshots__ │ │ ├── login.spec.js.snap │ │ └── todolist.spec.js.snap │ ├── login.spec.js │ └── todolist.spec.js └── sever │ ├── todolist.spec.js │ └── user.spec.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { "modules": false }], 4 | "stage-2" 5 | ], 6 | "plugins": [ 7 | "transform-runtime" 8 | ], 9 | "comments": false, 10 | "env": { 11 | "test": { 12 | "plugins": ["transform-es2015-modules-commonjs"], 13 | "presets": [ 14 | ["env", { "targets": { "node": "current" }}] 15 | ] 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | README.md 4 | docker-compose.yml 5 | node_modules/ 6 | dist/ 7 | test/ -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // https://eslint.org/docs/user-guide/configuring 2 | 3 | module.exports = { 4 | root: true, 5 | parser: 'babel-eslint', 6 | parserOptions: { 7 | sourceType: 'module' 8 | }, 9 | env: { 10 | browser: true, 11 | jest: true 12 | }, 13 | // https://github.com/standard/standard/blob/master/docs/RULES-en.md 14 | extends: 'standard', 15 | // required to lint *.vue files 16 | plugins: [ 17 | 'html' 18 | ], 19 | // add your custom rules here 20 | 'rules': { 21 | // allow paren-less arrow functions 22 | 'arrow-parens': 0, 23 | // allow async-await 24 | 'generator-star-spacing': 0, 25 | // allow debugger during development 26 | 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist/ 4 | npm-debug.log 5 | img/ 6 | .idea/ 7 | coverage/ 8 | .env* 9 | .coveralls.yml 10 | yarn-error.log -------------------------------------------------------------------------------- /.postcssrc.js: -------------------------------------------------------------------------------- 1 | // https://github.com/michael-ciniawsky/postcss-load-config 2 | 3 | module.exports = { 4 | "plugins": { 5 | // to edit target browsers: use "browserlist" field in package.json 6 | "autoprefixer": {} 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible Node.js debug attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug Jest", 9 | "type": "node", 10 | "request": "launch", 11 | "program": "${workspaceRoot}/node_modules/jest-cli/bin/jest.js", 12 | "stopOnEntry": false, 13 | "args": [ 14 | "--runInBand", 15 | "--forceExit" 16 | ], 17 | "cwd": "${workspaceRoot}", 18 | "preLaunchTask": null, 19 | "runtimeExecutable": null, 20 | "runtimeArgs": [ 21 | "--nolazy" 22 | ], 23 | "env": { 24 | "NODE_ENV": "test" 25 | }, 26 | "console": "integratedTerminal", 27 | "sourceMaps": true 28 | }, 29 | { 30 | "type": "node", 31 | "request": "launch", 32 | "env": { 33 | "NODE_ENV": "development" 34 | }, 35 | "name": "nodemon", 36 | "runtimeExecutable": "nodemon", 37 | "program": "${workspaceFolder}/server-entry.js", 38 | "restart": true, 39 | "console": "integratedTerminal", 40 | "internalConsoleOptions": "neverOpen" 41 | } 42 | ] 43 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.enable": true, 3 | "eslint.autoFixOnSave": true 4 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:carbon-alpine 2 | WORKDIR /www 3 | COPY . /www 4 | RUN npm install 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | Copyright (c) 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-koa-demo 2 | 3 | A fullstack demo used Vue2 & Koa2(Koa1 version is [here](https://github.com/Molunerfinn/vue-koa-demo/tree/koa1)) 4 | 5 | :sunny: Easy to setup and learn 6 | 7 | :100: Api test coverage 8 | 9 | :rocket: Instant feedback 10 | 11 | :stuck_out_tongue_winking_eye: Vue SSR support in the [ssr](https://github.com/Molunerfinn/vue-koa-demo/tree/ssr) branch 12 | 13 | :tada: Docker support 14 | 15 |

16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | Coverage Status 24 | 25 |

26 | 27 | ![Todolist](https://i.loli.net/2018/12/13/5c123b40a1baa.gif 'todolist') 28 | 29 | View the [article](https://molunerfinn.com/Vue+Koa/) for more details. 30 | 31 | If you want to check the info of the test, view the [article](https://molunerfinn.com/Use-Jest-To-Test-Vue-Koa/) for more details. 32 | 33 | ## Install 34 | 35 | `git clone https://github.com/Molunerfinn/vue-koa-demo.git` 36 | 37 | `npm install` or `yarn` 38 | 39 | if you are using yarn & meet this error: 40 | 41 | ```bash 42 | error upath@1.0.4: The engine "node" is incompatible with this module. Expected version ">=4 <=9". 43 | ``` 44 | 45 | please use 46 | 47 | ``` 48 | yarn --ignore-engines 49 | ``` 50 | 51 | Also you need to install MySQL & create a database named `todolist`,and execute 2 sql files `list.sql` & `user.sql`.They are in `sql/` 52 | 53 | After that, create a `.env` file and set the database username & password: 54 | 55 | ```env 56 | # your database username 57 | DB_USER=XXXX 58 | # your database 59 | DB_PASSWORD=YYYY 60 | # Koa is listening to this port 61 | PORT=8889 62 | ``` 63 | 64 | If you want to run the test for the Project, please create a `.env.test` file to face this situation: 65 | 66 | ```env 67 | # your database username 68 | DB_USER=XXXX 69 | # your database 70 | DB_PASSWORD=YYYY 71 | # The port which is listened by koa in the test environment 72 | PORT=8888 73 | ``` 74 | 75 | ### Run 76 | 77 | > Node.js & Docker support. **You need to create a `.env` file as above**. 78 | 79 | ### Node.js 80 | 81 | Beacuse of using Koa2, `Node.js >= v7.6.0` is needed. 82 | 83 | #### Development: 84 | 85 | `npm run dev` && `npm run server` 86 | 87 | open browser: `localhost:8080` 88 | 89 | > tips: login password is 123 90 | 91 | #### Production: 92 | 93 | `npm run start` 94 | 95 | open browser: `localhost:8889` 96 | 97 | > tips: login password is 123 98 | 99 | #### Test: 100 | 101 | `npm run test` and find the coverage report in the `coverage/lcov/index.html` 102 | 103 | ### Docker 104 | 105 | `docker-compose build` && `docker-compose up` 106 | 107 | > mysql in docker use 3306 port inside & outside. 108 | 109 | open browser: `localhost:8889` 110 | 111 | > tips: login password is 123 112 | 113 | ## License 114 | 115 | [MIT](http://opensource.org/licenses/MIT) 116 | 117 | Copyright (c) 2017 Molunerfinn 118 | 119 | 120 | -------------------------------------------------------------------------------- /Vue+Koa.md: -------------------------------------------------------------------------------- 1 | title: 全栈开发实战:用Vue2+Koa1开发完整的前后端项目(更新Koa2) 2 | tags: 3 | - 前端 4 | - Nodejs 5 | categories: 6 | - Web 7 | - 开发 8 | - Nodejs 9 | date: 2017-05-03 14:09:00 10 | --- 11 | 12 | ## 简介 13 | 14 | 本文从一名新手的角度(默认对Vue有了解,对Koa或者Express有了解)出发,从0开始构建一个数据通过Koa提供API的形式获取,页面通过Vue渲染的完整的前端项目。可以了解到Vue构建单页面的一些知识以及前端路由的使用、Koa如何提供API接口,如何进行访问过滤(路由)、验证(JSON-WEB-TOKEN)以及Sequelize操作MySQL数据库的一些知识和技巧,希望能够作为一篇入门全栈开发的文章吧。 15 | 16 | **更新**:文末给出的github仓库已经更新Koa2版本。请使用Node.js v7.6.0及以上版本体验~ 17 | 18 | 19 | 20 | ## 写在前面 21 | 22 | 我曾经写过一篇[文章](https://molunerfinn.com/nodejs-2/),是用express和mongodb入门Nodejs的前后端开发,这篇文章里简单的做了一个小demo,能够让你读写mongodb数据库,并且从数据库里将数据读取出来显示到页面上。算是一个简单的读写小demo吧,也算是服务端渲染的一次初尝试。并且我还写过用nodejs写简单小爬虫的[文章](https://molunerfinn.com/nodejs-1/),用爬虫来获取数据写入数据库。通过以上的的方法我用express写了一个小网站,记录并显示北邮人论坛每天的十大的[内容](http://topten.piegg.cn)。挺好玩的对吧,可以把想要做的事用代码来实现。 23 | 24 | 后来我接触到了Koa,并开始了学习,从express迁移到Koa其实曲线还算是比较平滑的。不过用Koa的方式也还是采用服务端渲染页面的方式。而且我发现目前网络上少有写过用Koa构建的前后端分离的应用、网站文章,我最近做的一个项目里需要用到的方式就是用Vue构建页面,数据的获取全部走后端API的形式,也就是所谓的前后端分离吧。正好在这过程中走了不少的坑,包括数据库的使用上也算是个新手,所以写篇文章记录一下,用同样的思路和方法构建一个简单的Todolist,欢迎讨论,轻拍~ 25 | 26 | ## 项目架构 27 | 28 | ``` 29 | . 30 | ├── LICENSE 31 | ├── README.md 32 | ├── .env // 环境变量配置文件 33 | ├── app.js // Koa入口文件 34 | ├── build // vue-cli 生成,用于webpack监听、构建 35 | │   ├── build.js 36 | │   ├── check-versions.js 37 | │   ├── dev-client.js 38 | │   ├── dev-server.js 39 | │   ├── utils.js 40 | │   ├── webpack.base.conf.js 41 | │   ├── webpack.dev.conf.js 42 | │   └── webpack.prod.conf.js 43 | ├── config // vue-cli 生成&自己加的一些配置文件 44 | │   ├── default.conf 45 | │   ├── dev.env.js 46 | │   ├── index.js 47 | │   └── prod.env.js 48 | ├── dist // Vue build 后的文件夹 49 | │   ├── index.html // 入口文件 50 | │   └── static // 静态资源 51 | ├── index.html // vue-cli生成,用于容纳Vue组件的主html文件。单页应用就只有一个html 52 | ├── package.json // npm的依赖、项目信息文件 53 | ├── server // Koa后端,用于提供Api 54 | │   ├── config // 配置文件夹 55 | │   ├── controllers // controller-控制器 56 | │   ├── models // model-模型 57 | │   ├── routes // route-路由 58 | │   └── schema // schema-数据库表结构 59 | ├── src // vue-cli 生成&自己添加的utils工具类 60 | │   ├── App.vue // 主文件 61 | │   ├── assets // 相关静态资源存放 62 | │   ├── components // 单文件组件 63 | │   ├── main.js // 引入Vue等资源、挂载Vue的入口js 64 | │   └── utils // 工具文件夹-封装的可复用的方法、功能 65 | └── yarn.lock // 用yarn自动生成的lock文件 66 | ``` 67 | 68 | 看起来好像很复杂的样子,其实很大一部分文件夹的结构是`vue-cli`这个工具帮我们生成的。而我们需要额外添加的主要是Koa的入口文件以及一个`server`文件夹用于Koa提供API。这样的话,在获取数据的方面就可以走Koa所提供的API,而Vue只需关心怎么把这些数据渲染到页面上就好了。 69 | 70 | ## 项目用到的一些关键依赖 71 | 72 | 以下依赖的版本都是本文所写的时候的版本,或者更旧一些 73 | 74 | - Vue.js(v2.1.8) 75 | - Vue-Router(v2.1.1) 76 | - Axios(v0.15.3) 77 | - Element(v1.1.2) 78 | - Koa.js(v1.2.4) // 没采用Koa2 79 | - Koa-Router@5.4\Koa-jwt\Koa-static等一系列Koa中间件 80 | - Mysql(v2.12.0) // nodejs的mysql驱动,并不是mysql本身版本(项目采用mysql5.6) 81 | - Sequelize(v3.28.0) // 操作数据库的ORM 82 | - Yarn(v0.18.1) // 比起npm更快一些 83 | 84 | 剩下依赖可以参考本文最后给出的项目demo仓库。 85 | 86 | ## 项目启动 87 | 88 | Nodejs与npm的安装不再叙述(希望大家装上的node版本大于等于6.x,不然还需要加上--harmony标志才可以开启es6),默认读者已经掌握npm安装依赖的方法。首先全局安装`npm i vue-cli -g`,当然本项目基本上是采用`yarn`,所以也可以`yarn global add vue-cli`。 89 | 90 | > Tips: 可以给yarn换上淘宝源,速度更快: `yarn config set registry "https://registry.npmmirror.com"` 91 | 92 | 然后我们初始化一个`Vue2的webpack`的模板: 93 | 94 | `vue init webpack demo` 95 | 96 | > Tips: 上面的demo可以填写你自己的项目名称 97 | 98 | 然后进行一些基本配置选择之后,你就可以得到一个基本的`vue-cli`生成的项目结构。 99 | 100 | 接着我们进入`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的)然后进行一些基本目录建立: 101 | 102 | 在`vue-cli`生成的`demo`目录下,建立`server`文件夹以及子文件夹: 103 | 104 | ``` 105 | ├── server // Koa后端,用于提供Api 106 |    ├── config // 配置文件夹 107 |    ├── controllers // controller-控制器 108 |     ├── models // model-模型 109 |    ├── routes // route-路由 110 |    └── schema // schema-数据库表结构 111 | ``` 112 | 113 | 然后在`demo`文件夹下我们创建一个`app.js`的文件,作为`Koa`的启动文件。 114 | 115 | 写入如下基本的内容就可以启动`Koa`啦: 116 | 117 | ```javascript 118 | const app = require('koa')() 119 | , koa = require('koa-router')() 120 | , json = require('koa-json') 121 | , logger = require('koa-logger'); // 引入各种依赖 122 | 123 | app.use(require('koa-bodyparser')()); 124 | app.use(json()); 125 | app.use(logger()); 126 | 127 | app.use(function* (next){ 128 | let start = new Date; 129 | yield next; 130 | let ms = new Date - start; 131 | console.log('%s %s - %s', this.method, this.url, ms); // 显示执行的时间 132 | }); 133 | 134 | app.on('error', function(err, ctx){ 135 | console.log('server error', err); 136 | }); 137 | 138 | app.listen(8889,() => { 139 | console.log('Koa is listening in 8889'); 140 | }); 141 | 142 | module.exports = app; 143 | ``` 144 | 145 | 然后在控制台输入`node app.js`,能看到输出`Koa is listening in 8889`,则说明我们的`Koa`已经启动成功了,并在8889端口监听。 146 | 147 | ## 前端页面构建 148 | 149 | 这个DEMO是做一个Todo-List,我们首先来做一个登录页面。 150 | 151 | > Tips: 为了方便构建页面和美观,本文采用的Vue2的前端UI框架是`element-ui`。安装:`yarn add element-ui` 152 | 153 | 模板引擎我习惯用`pug`,CSS预处理我习惯用`stylus`,当然每个人自己的习惯和喜好是不一样的,所以大家根据自己平时的喜好来就行了。 154 | 155 | 为了方便大家查看代码,就不用`pug`了,学习成本相对高一些。不过CSS用`stylus`写起来简便,看起来也不会难懂,是我自己的习惯,所以还需要安装一下`yarn add stylus stylus-loader`。 156 | 157 | > Tips: 安装stylus-loader是为了让webpack能够渲染stylus 158 | 159 | 然后要把`element-ui`引入项目中。打开`src/main.js`,将文件改写如下: 160 | 161 | ```js 162 | import Vue from 'vue' 163 | import App from './App' 164 | import ElementUI from 'element-ui' // 引入element-ui 165 | import 'element-ui/lib/theme-default/index.css' 166 | 167 | Vue.use(ElementUI) // Vue全局使用 168 | 169 | new Vue({ 170 | el: '#app', 171 | template: '', 172 | components: { App } 173 | }) 174 | ``` 175 | 然后我们在项目根目录下输入`npm run dev`,启动开发模式,这个模式有webpack的热加载,也就是你写完代码,浏览器立即就能响应变化。 176 | 177 | 为了实现响应式页面,我们要在项目目录下的`index.html`的`head`标签内加入以下`meta`: 178 | 179 | `` 180 | 181 | 182 | ### 登录界面 183 | 184 | 进入`src/components`目录,新建一个`Login.vue`的文件。然后我们来写第一个页面: 185 | 186 | ```html 187 | 188 | 210 | 211 | 221 | 222 | 233 | 234 | ``` 235 | 236 | 在这里就有一些值得注意的地方。首先是`template`标签内的直接子元素最多只能挂载一个。也就是你不能这么写: 237 | 238 | ```html 239 | 240 | 244 | 245 | ``` 246 | 247 | 否则会报错:`template syntax error Component template should contain exactly one root element`,template下只能有一个根元素。不过为了写多个元素,你可以这样: 248 | 249 | ```html 250 | 251 | 257 | 258 | ``` 259 | 260 | 同时注意到,在`Login.vue`的`style`标签内有个`scoped`属性,这个属性能够使这些样式只在这个组件内生效(因为Webpack在渲染的时候会将这个组件内的元素自动打上一串形如`data-v-62a7f97e`这样的属性,对于这些样式也会变成形如`.title[data-v-62a7f97e]{ font-size: 28px;}`的样子,保证了不会和其他组件的样式冲突。 261 | 262 | 页面写完之后,如果不把组件注册到Vue之下那么页面是不会显示的。因此这个时候需要把`APP.vue`这个文件改写一下: 263 | 264 | ```html 265 | 271 | 272 | 282 | 283 | 293 | 294 | ``` 295 | 296 | 也就是把`Login`这个组件注册到`Vue`下,同时你再看浏览器,已经不再是`vue-cli`默认生成的`Hello`欢迎界面了。 297 | 298 | ![Login](https://img.piegg.cn/vue-koa-demo/login.png "Login") 299 | 300 | 接着我们写一下登录成功后的界面。 301 | 302 | ### TodoList页面 303 | 304 | 还是在`src/components`目录下,写一个叫做`TodoList.vue`的文件。 305 | 306 | 接着我们开始写一个TodoList: 307 | 308 | ```html 309 | 358 | 359 | 421 | 422 | 440 | ``` 441 | 442 | 页面构建其实没有什么特别好说的,但是因为我自己有踩点坑,所以还是专门讲一下: 443 | 444 | 1. `v-if`和`v-for`放在一个元素内同时使用,因为Vue总会先执行`v-for`,所以导致`v-if`不会被执行。替代地,你可以使用一个额外的`template`元素用来放置`v-if`或者`v-for`从而达到同样的目的。这是相关的[issue](https://github.com/vuejs/vue/issues/3106)。 445 | 446 | 2. 计算属性对于直接的数据比如`a: 2` -> `a: 3`这样的数据变动可以直接检测到。但是如果是本例中的`list`的某一项的`status`这个属性变化了,如果我们直接使用`list[index].status = true`这样的写法的话,Vue将无法检测到数据变动。替代地,可以使用`set`方法(全局是`Vue.set()`,实例中是`this.$set()`),通过`set`方法可以让数据的变动变得可以被检测到。从而让计算属性能够捕捉到变化。可以参考官方文档对于响应式原理的[描述](https://cn.vuejs.org/v2/guide/reactivity.html)。 447 | 448 | ![Todolist](https://img.piegg.cn/vue-koa-demo/todolist.gif "Todolist") 449 | 450 | 写完`TodoList`之后,我们需要将它和`vue-router`配合起来,从而使这个单页应用能够进行页面跳转。 451 | 452 | ### 页面路由 453 | 454 | 由于不采用服务端渲染,所以页面路由走的是前端路由。安装一下`vue-router`:`yarn add vue-router`。 455 | 456 | 安装好后,我们挂载一下路由。打开`main.js`文件改写如下: 457 | 458 | ```js 459 | // src/main.js 460 | 461 | import Vue from 'vue' 462 | import App from './App' 463 | import ElementUI from 'element-ui' 464 | import 'element-ui/lib/theme-default/index.css' 465 | import VueRouter from 'vue-router' 466 | 467 | Vue.use(ElementUI); 468 | Vue.use(VueRouter); 469 | 470 | import Login from `./components/Login` 471 | import TodoList from `./components/TodoList` 472 | 473 | const router = new VueRouter({ 474 | mode: 'history', // 开启HTML5的history模式,可以让地址栏的url长得跟正常页面跳转的url一样。(不过还需要后端配合,讲Koa的时候会说) 475 | base: __dirname, 476 | routes: [ 477 | { 478 | path: '/', // 默认首页打开是登录页 479 | component: Login 480 | }, 481 | { 482 | path: '/todolist', 483 | component: TodoList 484 | }, 485 | { 486 | path: '*', 487 | redirect: '/' // 输入其他不存在的地址自动跳回首页 488 | } 489 | ] 490 | }) 491 | 492 | const app = new Vue({ 493 | router: router, // 启用router 494 | render: h => h(App) 495 | }).$mount('#app') //挂载到id为app的元素上 496 | 497 | ``` 498 | 499 | 这样就把路由挂载好了,但是你打开页面发现好像还是没有什么变化。这是因为我们没有把路由视图放到页面上。现在我们改写一下`APP.vue`: 500 | 501 | ```html 502 | 503 | 504 | 510 | 511 | 516 | 517 | 527 | 528 | ``` 529 | 530 | 然后再看一下你的页面,这个时候你如果在地址栏后加上`/todolist`那么就会跳转到`TodoList`页面啦。 531 | 532 | 不过我们如何通过点击登录按钮跳转到`TodoList`呢?改写一下`Login.vue`,就可以跳转了。 533 | 534 | 只需要给登录的`button`加一个方法即可: 535 | 536 | ```html 537 | 538 | ······ 539 | 540 | 541 | 546 | 547 | 548 | 登录 549 | 550 | ······ 551 | 552 | 567 | 568 | ``` 569 | 570 | 然后你就可以通过点击`登录`按钮进行页面跳转了。并且你可以发现,页面地址从`localhost:8080`变成了`localhost:8080/todolist`,长得跟正常的url跳转一样。(但是实际上我们是单页应用,只是在应用内进行页面跳转而已,没有向后端额外请求) 571 | 572 | ![login2todolist](https://img.piegg.cn/vue-koa-demo/login2todolist.gif "login2todolist") 573 | 574 | 至此,我们已经完成了一个纯前端的单页应用,能够进行页面跳转,能够做简单的ToDoList的添加和删除和还原。当然这个东西只能算是个能看不能用的东西——因为登录系统有名无实、ToDoList只要页面刷新一下就没了。 575 | 576 | 于是我们可以先把前端放一放。开启我们的后端之旅。 577 | 578 | ## 后端环境搭建 579 | 580 | ### MySQL 581 | 582 | 之所以没有用Node界大家普遍喜爱的`Mongodb`主要是因为之前我用过它,而没有用过`MySQL`,本着学习的态度,我决定用用`MySQL`。还有就是`Express + Mongodb`的教程其实很早之前就已经满大街都是了。所以如果你觉得`Mongodb`更合你的胃口,看完本文你完全可以用`Mongodb`构建一个类似的应用。 583 | 584 | 去`MySQL`的[官网](http://dev.mysql.com/downloads/)下载安装对应平台`MySQL`的`Community Server`。 585 | 586 | 通常来说安装的步骤都是比较简单的。对于`MySQL`的基本安装、开启步骤可以参考这篇[文章](http://www.rathishkumar.in/2016/01/how-to-install-mysql-server-on-windows.html),这篇是windows的。当然其他平台的安装也是很方便的,都有相应的包管理工具可以获取。值得注意的就是,安装完`MySQL`之后你需要设定一下`root`账户的密码。保证安全性。如果你漏了设定,或者你不知道怎么设定,可以参考这篇[文章](https://www.howtoforge.com/setting-changing-resetting-mysql-root-passwords) 587 | 588 | 因为我对`MySQL`的SQL语句不是很熟悉,所以我需要一个可视化的工具来操作`MySQL`。Windows上我用的是[HediSQL](http://www.heidisql.com/),macOS上我用的是[Sequel Pro](https://www.sequelpro.com/)。它们都是免费的。 589 | 590 | 然后我们可以用这些可视化工具连上MySQL的server(默认端口是3306)之后,创建一个新的数据库,叫做`todolist`。(当然你也可以用SQL语句:`CREATE DATABASE todolist`,之后不再赘述)。 591 | 592 | 接着我们可以来开始创建数据表了。 593 | 594 | 我们需要创建两张表,一张是用户表,一张是待办事项表。用户表用于登录、验证,待办事项表用于展示我们的待办事项。 595 | 596 | 创建一张`user`表,其中`password`我们稍后会进行`bcrypt`加密(取128位)。 597 | 598 | | 字段 | 类型 | 说明| 599 | | --- | --- | --- | 600 | | id | int(自增) | 用户的id | 601 | | user_name | CHAR(50) | 用户的名字 | 602 | | password | CHAR(128) | 用户的密码 | 603 | 604 | 创建一张`list`表,所需的字段是`id`、`user_id`、`content`、`status`即可。 605 | 606 | | 字段 | 类型 | 说明| 607 | | --- | --- | --- | 608 | | id | int(自增) | list的id | 609 | | user_id | int(11) | 用户的id | 610 | | content | CHAR(255) | list的内容 | 611 | | status | tinyint(1) | list的状态 | 612 | 613 | 直接跟数据库打交道的部分基本就是这样了。 614 | 615 | ### Sequelize 616 | 617 | 跟数据库打交道的时候我们都需要一个好的操作数据库的工具,能够让我们用比较简单的方法来对数据库进行增删改查。对于`Mongodb`来说大家熟悉的是[`Mongoose`](http://mongoosejs.com/)以及我用过一个相对更简单点的[`Monk`](https://github.com/Automattic/monk)。对于`MySQL`,我选用的是[`Sequelize`](https://github.com/sequelize/sequelize),它支持多种关系型数据库(`Sqlite`、`MySQL`、`Postgres`等),它的操作基本都能返回一个`Promise`对象,这样在Koa里面我们能够很方便地进行"同步"操作。 618 | 619 | > 更多关于Sequelize的用法,可以参考[官方文档](http://docs.sequelizejs.com/en/latest/),以及这两篇文章——[Sequelize中文API文档](http://itbilu.com/nodejs/npm/VkYIaRPz-.html)、[Sequelize和MySQL对照](https://segmentfault.com/a/1190000003987871) 620 | 621 | 在用`Sequelize`连接数据库之前我们需要把数据库的表结构用`sequelize-auto`导出来。 622 | 623 | > 更多关于`sequelize-auto`的使用可以参考[官方介绍](https://github.com/sequelize/sequelize-auto)或者[这篇文章](http://itbilu.com/nodejs/npm/41mRdls_Z.html) 624 | 625 | 由此我们需要分别安装这几个依赖:`yarn global add sequelize-auto && yarn add sequelize mysql`。 626 | 627 | > 注:上面用yarn安装的mysql是nodejs环境下的mysql驱动。 628 | 629 | 进入`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) 630 | 631 | 然后就会在`schema`文件夹下自动生成两个文件: 632 | 633 | ```js 634 | // user.js 635 | 636 | module.exports = function(sequelize, DataTypes) { 637 | return sequelize.define('user', { 638 | id: { 639 | type: DataTypes.INTEGER(11), // 字段类型 640 | allowNull: false, // 是否允许为NULL 641 | primaryKey: true, // 主键 642 | autoIncrement: true // 是否自增 643 | }, 644 | user_name: { 645 | type: DataTypes.CHAR(50), // 最大长度为50的字符串 646 | allowNull: false 647 | }, 648 | password: { 649 | type: DataTypes.CHAR(32), 650 | allowNull: false 651 | } 652 | }, { 653 | tableName: 'user' // 表名 654 | }); 655 | }; 656 | ``` 657 | 658 | ```js 659 | // list.js 660 | 661 | module.exports = function(sequelize, DataTypes) { 662 | return sequelize.define('list', { 663 | id: { 664 | type: DataTypes.INTEGER(11), 665 | allowNull: false, 666 | primaryKey: true, 667 | autoIncrement: true 668 | }, 669 | user_id: { 670 | type: DataTypes.INTEGER(11), 671 | allowNull: false 672 | }, 673 | content: { 674 | type: DataTypes.CHAR(255), 675 | allowNull: false 676 | }, 677 | status: { 678 | type: DataTypes.INTEGER(1), 679 | allowNull: false 680 | } 681 | }, { 682 | tableName: 'list' 683 | }); 684 | }; 685 | 686 | ``` 687 | 688 | 自动化工具省去了很多我们手动定义表结构的时间。同时注意到生成的数据库表结构文件都自动帮我们`module.exports`出来了,所以很方便我们之后的引入。 689 | 690 | 在`server`目录下的`config`目录下我们新建一个`db.js`,用于初始化`Sequelize`和数据库的连接。 691 | 692 | ```js 693 | // db.js 694 | 695 | const Sequelize = require('sequelize'); // 引入sequelize 696 | 697 | // 使用url连接的形式进行连接,注意将root: 后面的XXXX改成自己数据库的密码 698 | const Todolist = new Sequelize('mysql://root:XXXX@localhost/todolist',{ 699 | define: { 700 | timestamps: false // 取消Sequelzie自动给数据表加入时间戳(createdAt以及updatedAt) 701 | } 702 | }) 703 | 704 | module.exports = { 705 | Todolist // 将Todolist暴露出接口方便Model调用 706 | } 707 | ``` 708 | 709 | 接着我们去`models`文件夹里将数据库和表结构文件连接起来。在这个文件夹下新建一个`user.js`的文件。我们先来写一个查询用户`id`的东西。 710 | 711 | 为此我们可以先在数据库里随意加一条数据: 712 | 713 | ![test](https://img.piegg.cn/vue-koa-demo/database-1.png "test") 714 | 715 | 通常我们要查询一个用户id为1的数据,会很自然的想到类似如下的写法: 716 | 717 | ```js 718 | 719 | const userInfo = User.findOne({ where: { id: 1} }); // 查询 720 | console.log(userInfo); // 输出结果 721 | 722 | ``` 723 | 724 | 但是上面的写法实际上是行不通的。因为JS的特性让它的IO操作是异步的。而上面的写法,`userInfo`将是返回的一个`Promise`对象,而不是最终的`userInfo`。如果又想用同步的写法获取异步IO操作得到的数据的话,通常情况下是不能直接得到的。但是在Koa里,由于有[`co`](https://github.com/tj/co)的存在,让这一切变得十分简单。改写如下: 725 | 726 | ```js 727 | // models/user.js 728 | const db = require('../config/db.js'), 729 | userModel = '../schema/user.js'; // 引入user的表结构 730 | const TodolistDb = db.Todolist; // 引入数据库 731 | 732 | const User = TodolistDb.import(userModel); // 用sequelize的import方法引入表结构,实例化了User。 733 | 734 | const getUserById = function* (id){ // 注意是function* 而不是function 对于需要yield操作的函数都需要这种generator函数。 735 | const userInfo = yield User.findOne({ // 用yield控制异步操作,将返回的Promise对象里的数据返回出来。也就实现了“同步”的写法获取异步IO操作的数据 736 | where: { 737 | id: id 738 | } 739 | }); 740 | 741 | return userInfo // 返回数据 742 | } 743 | 744 | module.exports = { 745 | getUserById // 导出getUserById的方法,将会在controller里调用 746 | } 747 | ``` 748 | 749 | 接着我们在`controllers`写一个user的controller,来执行这个方法,并返回结果。 750 | 751 | ```js 752 | // controllers/user.js 753 | 754 | const user = require('../models/user.js'); 755 | 756 | const getUserInfo = function* (){ 757 | const id = this.params.id; // 获取url里传过来的参数里的id 758 | const result = yield user.getUserById(id); // 通过yield “同步”地返回查询结果 759 | this.body = result // 将请求的结果放到response的body里返回 760 | } 761 | 762 | module.exports = { 763 | getUserInfo // 把获取用户信息的方法暴露出去 764 | } 765 | ``` 766 | 767 | 写完这个还不能直接请求,因为我们还没有定义路由,请求经过`Koa`找不到这个路径是没有反应的。 768 | 769 | 在`routes`文件夹下写一个`auth.js`的文件。(其实`user`表是用于登录的,所以走`auth`) 770 | 771 | ```js 772 | // routes/auth.js 773 | 774 | const auth = require('../controllers/user.js'); 775 | const router = require('koa-router')(); 776 | 777 | router.get('/user/:id', auth.getUserInfo); // 定义url的参数是id,用user的auth方法引入router 778 | 779 | module.exports = router; // 把router规则暴露出去 780 | ``` 781 | 782 | 至此我们已经接近完成我们的第一个API了,还缺最后一步,将这个路由规则“挂载”到Koa上去。 783 | 784 | 回到根目录的`app.js`,改写如下: 785 | 786 | ```js 787 | const app = require('koa')() 788 | , koa = require('koa-router')() 789 | , json = require('koa-json') 790 | , logger = require('koa-logger') 791 | , auth = require('./server/routes/auth.js'); // 引入auth 792 | 793 | app.use(require('koa-bodyparser')()); 794 | app.use(json()); 795 | app.use(logger()); 796 | 797 | app.use(function* (next){ 798 | let start = new Date; 799 | yield next; 800 | let ms = new Date - start; 801 | console.log('%s %s - %s', this.method, this.url, ms); 802 | }); 803 | 804 | app.on('error', function(err, ctx){ 805 | console.log('server error', err); 806 | }); 807 | 808 | koa.use('/auth', auth.routes()); // 挂载到koa-router上,同时会让所有的auth的请求路径前面加上'/auth'的请求路径。 809 | 810 | app.use(koa.routes()); // 将路由规则挂载到Koa上。 811 | 812 | app.listen(8889,() => { 813 | console.log('Koa is listening in 8889'); 814 | }); 815 | 816 | module.exports = app; 817 | ``` 818 | 819 | 打开你的控制台,输入`node app.js`,一切运行正常没有报错的话,大功告成,我们的第一个API已经构建完成! 820 | 821 | 如何测试呢? 822 | 823 | ### API Test 824 | 825 | 接口在跟跟前端对接之前,我们应该先进行一遍测试,防止出现问题。在测试接口的工具上,我推荐[`Postman`](https://www.getpostman.com/),这个工具能够很好的模拟发送的各种请求,方便的查看响应结果,用来进行测试是最好不过了。 826 | 827 | ![Postman](https://img.piegg.cn/vue-koa-demo/postman-1.png) 828 | 829 | 测试成功,我发送了正确的url请求,返回的结果也是我想看到的。我们看到返回的结果实际上是个JSON,这对于我们前后端来说都是十分方便处理的数据格式。 830 | 831 | 但是如果我们代码出了问题,返回error了我们该怎么测试呢?如果说控制台能够反馈一定的信息,但是绝对不充分,并且我们很可能不知道哪步出错了导致最终结果出问题。 832 | 833 | 所以我推荐用[VSCode](https://code.visualstudio.com/)这个工具来帮我们调试nodejs后端的代码。它能够添加断点,能够很方便地查看请求的信息。并且配合上[`nodemon`](https://github.com/remy/nodemon)这类的工具,调试简直不要更舒服。 834 | 835 | 关于`VSCode`的nodejs调试,可以参考官方的这篇[文章](https://code.visualstudio.com/docs/editor/node-debugging) 836 | 837 | > 我自己是用Sublime写代码,用VSCode调试,哈哈。 838 | 839 | ### 登录系统的实现 840 | 841 | 刚才实现的不过是一个简单的用户信息查询的接口,但是我们要实现的是一个登录系统,所以还需要做一些工作。 842 | 843 | #### JSON-WEB-TOKEN 844 | 845 | 基于cookie或者session的登录验证已经屡见不鲜,前段时间`JSON-WEB-TOKEN`出来后很是风光了一把。引入了它之后,能够实现真正无状态的请求,而不是基于session和cookie的存储式的有状态验证。 846 | 847 | 关于JSON-WEB-TOKEN的描述可以参考这篇[文章](http://blog.leapoahead.com/2015/09/07/user-authentication-with-jwt/?utm_source=tuicool&utm_medium=referral)比较简单,我还推荐一篇[文章](https://segmentfault.com/a/1190000005783306),将如何使用JSON-WEB-TOKEN写得很清楚。 848 | 849 | 另外可以在JSON-WEB-TOKEN的[官网](https://jwt.io/)上感受一下。 850 | 851 | > Tips:JSON-WEB-TOKEN分三部分,头部信息+主体信息+密钥信息,其中主体传递的信息(是我们存放我们需要的信息的部分)是用BASE64编码的,所以很容易被解码,一定不能存放明文密码这种关键信息!替代地可以存放一些不是特别关键的信息,比如用户名这样能够做区分的信息。 852 | 853 | 简单来说,运用了JSON-WEB-TOKEN的登录系统应该是这样的: 854 | 855 | 1. 用户在登录页输入账号密码,将账号密码(密码进行md5加密)发送请求给后端 856 | 2. 后端验证一下用户的账号和密码的信息,如果符合,就下发一个TOKEN返回给客户端。如果不符合就不发送TOKEN回去,返回验证错误信息。 857 | 3. 如果登录成功,客户端将TOKEN用某种方式存下来(SessionStorage、LocalStorage),之后要请求其他资源的时候,在请求头(Header)里带上这个TOKEN进行请求。 858 | 4. 后端收到请求信息,先验证一下TOKEN是否有效,有效则下发请求的资源,无效则返回验证错误。 859 | 860 | 通过这个TOKEN的方式,客户端和服务端之间的访问,是`无状态`的:也就是服务端不知道你这个用户到底还在不在线,只要你发送的请求头里的TOKEN是正确的我就给你返回你想要的资源。这样能够不占用服务端宝贵的空间资源,而且如果涉及到服务器集群,如果服务器进行维护或者迁移或者需要CDN节点的分配的话,`无状态`的设计显然维护成本更低。 861 | 862 | 话不多说,我们来把`JSON-WEB-TOKEN`用到我们的项目中。 863 | 864 | `yarn add koa-jwt`,安装`Koa`的`JSON-WEB-TOKEN`库。 865 | 866 | 我们需要在`models`里的`user.js`加一个方法,通过用户名查找用户: 867 | 868 | ```js 869 | // models/user.js 870 | // ...... 871 | // 前面的省略了 872 | 873 | 874 | // 新增一个方法,通过用户名查找 875 | const getUserByName = function* (name){ 876 | const userInfo = yield User.findOne({ 877 | where: { 878 | user_name: name 879 | } 880 | }) 881 | 882 | return userInfo 883 | } 884 | 885 | module.exports = { 886 | getUserById, // 导出getUserById的方法,将会在controller里调用 887 | getUserByName 888 | } 889 | 890 | ``` 891 | 892 | 然后我们写一下`controllers`里的`user.js`: 893 | 894 | ```js 895 | // controllers/user.js 896 | 897 | const user = require('../models/user.js'); 898 | const jwt = require('koa-jwt'); // 引入koa-jwt 899 | 900 | const getUserInfo = function* (){ 901 | const id = this.params.id; // 获取url里传过来的参数里的id 902 | const result = yield user.getUserById(id); // 通过yield “同步”地返回查询结果 903 | this.body = result // 将请求的结果放到response的body里返回 904 | } 905 | 906 | const postUserAuth = function* (){ 907 | const data = this.request.body; // post过来的数据存在request.body里 908 | const userInfo = yield user.getUserByName(data.name); 909 | 910 | if(userInfo != null){ // 如果查无此用户会返回null 911 | if(userInfo.password != data.password){ 912 | this.body = { 913 | success: false, // success标志位是方便前端判断返回是正确与否 914 | info: '密码错误!' 915 | } 916 | }else{ // 如果密码正确 917 | const userToken = { 918 | name: userInfo.user_name, 919 | id: userInfo.id 920 | } 921 | const secret = 'vue-koa-demo'; // 指定密钥,这是之后用来判断token合法性的标志 922 | const token = jwt.sign(userToken,secret); // 签发token 923 | this.body = { 924 | success: true, 925 | token: token, // 返回token 926 | } 927 | } 928 | }else{ 929 | this.body = { 930 | success: false, 931 | info: '用户不存在!' // 如果用户不存在返回用户不存在 932 | } 933 | } 934 | } 935 | 936 | module.exports = { 937 | getUserInfo, 938 | postUserAuth 939 | } 940 | ``` 941 | 942 | 再把`routes`里的路由规则更新一下: 943 | 944 | ```js 945 | // routes/auth.js 946 | 947 | const auth = require('../controllers/user.js'); 948 | const router = require('koa-router')(); 949 | 950 | router.get('/user/:id', auth.getUserInfo); // 定义url的参数是id,用user的auth方法引入router 951 | router.post('/user', auth.postUserAuth); 952 | 953 | module.exports = router; // 把router规则暴露出去 954 | ``` 955 | 956 | 由此我们写完了用户认证的部分。接下去我们要改写一下前端登录的方法。 957 | 958 | #### 引入Axios 959 | 960 | 之前在学`Vue`的时候一直用的是[`vue-resource`](https://github.com/pagekit/vue-resource),不过后来`Vue2`出来之后,Vue官方不再默认推荐它为官方的`ajax`网络请求库了。替代地推荐了一些其他的库,比如就有我们今天要用的[`axios`](https://github.com/mzabriskie/axios)。我之前也没有用过它,不过看完它的star和简要介绍`Promise based HTTP client for the browser and node.js`,能够同时支持node和浏览器端的ajax请求工具(还是基于Promised的!),我想就有必要用一用啦。 961 | 962 | `yarn add axios`,安装`axios`。然后我们在`src/main.js`里面引入`axios`: 963 | 964 | ```js 965 | 966 | // scr/main.js 967 | 968 | // ... 969 | 970 | import Axios from 'axios' 971 | 972 | Vue.prototype.$http = Axios // 类似于vue-resource的调用方法,之后可以在实例里直接用this.$http.get()等 973 | 974 | // ... 975 | 976 | 977 | ``` 978 | 979 | ```js 980 | // Login.vue 981 | // 省略前面的部分 982 | 983 | methods: { 984 | loginToDo() { 985 | let obj = { 986 | name: this.account, 987 | password: this.password 988 | } 989 | this.$http.post('/auth/user', obj) // 将信息发送给后端 990 | .then((res) => { // axios返回的数据都在res.data里 991 | if(res.data.success){ // 如果成功 992 | sessionStorage.setItem('demo-token',res.data.token); // 用sessionStorage把token存下来 993 | this.$message({ // 登录成功,显示提示语 994 | type: 'success', 995 | message: '登录成功!' 996 | }); 997 | this.$router.push('/todolist') // 进入todolist页面,登录成功 998 | }else{ 999 | this.$message.error(res.data.info); // 登录失败,显示提示语 1000 | sessionStorage.setItem('demo-token',null); // 将token清空 1001 | } 1002 | }, (err) => { 1003 | this.$message.error('请求错误!') 1004 | sessionStorage.setItem('demo-token',null); // 将token清空 1005 | }) 1006 | } 1007 | } 1008 | ``` 1009 | 1010 | 1011 | 1012 | #### 密码bcrypt加密 1013 | 1014 | 最早的时候我是在前端用了md5加密,但是后来经过提醒这种方式并不安全。md5加密的容易被破解。所以就采用了`bcrypt`的加密方式。全部走后端加密。也许你会问这样明文给后端发送密码安全吗?没问题,只要用上HTTPS,这将不是问题。 1015 | 1016 | `yarn add bcryptjs`安装bcryptjs。 1017 | 1018 | ```js 1019 | // controllers/user.js 1020 | 1021 | const user = require('../models/user.js'); 1022 | const jwt = require('koa-jwt'); // 引入koa-jwt 1023 | const bcrypt = require('bcryptjs'); 1024 | 1025 | const getUserInfo = function* (){ 1026 | const id = this.params.id; // 获取url里传过来的参数里的id 1027 | const result = yield user.getUserById(id); // 通过yield “同步”地返回查询结果 1028 | this.body = result // 将请求的结果放到response的body里返回 1029 | } 1030 | 1031 | const postUserAuth = function* (){ 1032 | const data = this.request.body; // post过来的数据存在request.body里 1033 | const userInfo = yield user.getUserByName(data.name); 1034 | 1035 | if(userInfo != null){ // 如果查无此用户会返回null 1036 | if(!bcrypt.compareSync(data.password, userInfo.password)){ // 验证密码是否正确 1037 | this.body = { 1038 | success: false, // success标志位是方便前端判断返回是正确与否 1039 | info: '密码错误!' 1040 | } 1041 | }else{ // 如果密码正确 1042 | const userToken = { 1043 | name: userInfo.user_name, 1044 | id: userInfo.id 1045 | } 1046 | const secret = 'vue-koa-demo'; // 指定密钥,这是之后用来判断token合法性的标志 1047 | const token = jwt.sign(userToken,secret); // 签发token 1048 | this.body = { 1049 | success: true, 1050 | token: token, // 返回token 1051 | } 1052 | } 1053 | }else{ 1054 | this.body = { 1055 | success: false, 1056 | info: '用户不存在!' // 如果用户不存在返回用户不存在 1057 | } 1058 | } 1059 | } 1060 | 1061 | module.exports = { 1062 | getUserInfo, 1063 | postUserAuth 1064 | } 1065 | ``` 1066 | 1067 | 因为我们数据库里还是存着明文的`123`作为密码,现在要先将它bcrypt化,加密后变为:`$2a$10$x3f0Y2SNAmyAfqhKVAV.7uE7RHs3FDGuSYw.LlZhOFoyK7cjfZ.Q6`,将其替换掉数据库里的`123`。不做这步我们将无法登录。 1068 | 1069 | 还没有大功告成,因为我们的界面跑在`8080`端口,但是Koa提供的API跑在`8889`端口,所以如果直接通过`/auth/user`这个url去post是请求不到的。就算写成`localhost:8889/auth/user`也会因为跨域问题导致请求失败。 1070 | 1071 | 这个时候有两种最方便的解决办法: 1072 | 1073 | 1. 如果是跨域,服务端只要在请求头上加上[`CORS`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS),客户端即可跨域发送请求。 1074 | 2. 变成同域,即可解决跨域请求问题。 1075 | 1076 | 第一种也很方便,采用[`kcors`](https://github.com/koajs/cors)即可解决。 1077 | 不过为了之后部署方便,我们采用第二种,变成同域请求。 1078 | 1079 | 打开根目录下的`config/index.js`,找到`dev`下的`proxyTable`,利用这个`proxyTable`我们能够将外部的请求通过`webpack`转发给本地,也就能够将跨域请求变成同域请求了。 1080 | 1081 | 将`proxyTable`改写如下: 1082 | 1083 | ```js 1084 | proxyTable: { 1085 | '/auth':{ 1086 | target: 'http://localhost:8889', 1087 | changeOrigin: true 1088 | }, 1089 | '/api':{ 1090 | target: 'http://localhost:8889', 1091 | changeOrigin: true 1092 | } 1093 | } 1094 | ``` 1095 | 1096 | 上面的意思是,我们在组件里请求的地址如果是`/api/xxxx`实际上请求的是`http://localhost:8889/api/xxxx`,但是由于`webpack`帮我们代理了localhost的8889端口的服务,所以我们可以把实际是跨域的请求当做是同域下的接口来调用。 1097 | 1098 | 此时重新启动一下`webpack`:先`ctrl+c`退出当前进程,然后`npm run dev`。 1099 | 1100 | 一切都万事了之后,我们可以看到如下激动人心的画面: 1101 | 1102 | ![login2todolist](https://img.piegg.cn/vue-koa-demo/login2todolist-2.gif "login2todolist") 1103 | 1104 | #### 跳转拦截 1105 | 1106 | 虽然我们现在能够成功登录系统了,但是还是存在一个问题:我在地址栏手动将地址改为`localhost:8080/todolist`我还是能够成功跳转到登录后的界面啊。于是这就需要一个跳转拦截,当没有登录的时候,不管地址栏输入什么地址,最终都重新定向回登录页。 1107 | 1108 | 这个时候,从后端给我们传回来的`token`就派上大用处。有`token`就说明我们的身份是经过验证的,否则就是非法的。 1109 | 1110 | `vue-router`提供了页面跳转的钩子,我们可以在`router`跳转前进行验证,如果`token`存在就跳转,如果不存在就返回登录页。 1111 | 1112 | 参考路由的[导航钩子](https://router.vuejs.org/zh-cn/advanced/navigation-guards.html) 1113 | 1114 | 打开`src/main.js`,修改如下: 1115 | 1116 | ```js 1117 | // src/main.js 1118 | 1119 | // ... 1120 | 1121 | const router = new VueRouter({....}) // 省略 1122 | 1123 | router.beforeEach((to,from,next) =>{ 1124 | const token = sessionStorage.getItem('demo-token'); 1125 | if(to.path == '/'){ // 如果是跳转到登录页的 1126 | if(token != 'null' && token != null){ 1127 | next('/todolist') // 如果有token就转向todolist不返回登录页 1128 | } 1129 | next(); // 否则跳转回登录页 1130 | }else{ 1131 | if(token != 'null' && token != null){ 1132 | next() // 如果有token就正常转向 1133 | }else{ 1134 | next('/') // 否则跳转回登录页 1135 | } 1136 | } 1137 | }) 1138 | 1139 | const app = new Vue({...}) // 省略 1140 | 1141 | ``` 1142 | 1143 | > **注意:一定要确保要调用 `next()` 方法,否则钩子就不会被 resolved。**如果纯粹调用`next(path)`这样的方法最终还是会回到`.beforeEach()`这个钩子里面来,如果没有写对条件就有可能出现死循环,栈溢出的情况。 1144 | 1145 | 然后我们就可以看到如下效果: 1146 | 1147 | ![login2todolist](https://img.piegg.cn/vue-koa-demo/login2todolist-3.gif "login2todolist") 1148 | 1149 | > Tips:这种只判断token存不存在就通过的验证是很不安全的,此例只是做了一个演示,实际上还应该进行更深一层的判断,比如从token解包出来的信息里包含我们想要的信息才可以作为有效token,才可以登录。等等。本文只是做一个简要介绍。 1150 | 1151 | #### 解析token 1152 | 1153 | 注意到我们在签发`token`的时候,写过这样几句话: 1154 | 1155 | ```js 1156 | 1157 | // server/controllers/user.js 1158 | 1159 | // ... 1160 | 1161 | const userToken = { 1162 | name: userInfo.user_name, 1163 | id: userInfo.id 1164 | } 1165 | const secret = 'vue-koa-demo'; // 指定密钥,这是之后用来判断token合法性的标志 1166 | const token = jwt.sign(userToken,secret); // 签发token 1167 | 1168 | // ... 1169 | ``` 1170 | 1171 | 我们将用户名和id打包进JWT的主体部分,同时我们解密的密钥是`vue-koa-demo`。所以我们可以通过这个信息,来进行登录后的用户名显示,以及用来区别这个用户是谁,这个用户有哪些`Todolist`。 1172 | 1173 | 接下来在`Todolist`页面进行token解析,从而让用户名显示为登录用户名。 1174 | 1175 | **注意:** 前端直接暴露`secret-key`的做法其实并不安全。正确的做法应该是把token跟用户名和其他不是很重要的信息一起传过来,token只用于验证,而其他信息作为返回值正常返回。这样就不会暴露`secret-key`了。当然本文只是为了方便说明,给出的一个不恰当的获取用户信息的例子。 1176 | 1177 | ```js 1178 | 1179 | // src/components/TodoList.vue 1180 | 1181 | // ... 1182 | 1183 | import jwt from 'jsonwebtoken' // 我们安装koa-jwt的时候会自动下载这个依赖 1184 | 1185 | export default { 1186 | 1187 | created(){ // 组件创建时调用 1188 | const userInfo = this.getUserInfo(); // 新增一个获取用户信息的方法 1189 | if(userInfo != null){ 1190 | this.id = userInfo.id; 1191 | this.name = userInfo.name; 1192 | }else{ 1193 | this.id = ''; 1194 | this.name = '' 1195 | } 1196 | }, 1197 | 1198 | data () { 1199 | return { 1200 | name: '', // 用户名改为空 1201 | todos: '', 1202 | activeName: 'first', 1203 | list:[], 1204 | count: 0, 1205 | id: '' // 新增用户id属性,用于区别用户 1206 | }; 1207 | }, 1208 | computed: {...}, //省略 1209 | 1210 | methods: { 1211 | addTodos() {...}, // 省略 1212 | finished(index) {...}, 1213 | remove(index) {...}, 1214 | restore(index) {...}, 1215 | getUserInfo(){ // 获取用户信息 1216 | const token = sessionStorage.getItem('demo-token'); 1217 | if(token != null && token != 'null'){ 1218 | let decode = jwt.verify(token,'vue-koa-demo'); // 解析token 1219 | return decode // decode解析出来实际上就是{name: XXX,id: XXX} 1220 | }else { 1221 | return null 1222 | } 1223 | } 1224 | } 1225 | }; 1226 | 1227 | // ... 1228 | ``` 1229 | 1230 | 于是你就可以看到: 1231 | 1232 | ![todolist](https://img.piegg.cn/vue-koa-demo/todolist-1.png "todolist") 1233 | 1234 | 用户名已经不是我们之前默认的`Molunerfinn`而是登录名`molunerfinn`了。 1235 | 1236 | ## Todolist 增删改查的实现 1237 | 1238 | 这个部分就是前后端协作了。我们要实现之前在纯前端部分实现的内容。我以最基本的两个方法来举例子:获取`Todolist`以及增加`Todolist`,剩下其实思路大同小异,我就提供代码和注释了,我相信也很容易懂。 1239 | 1240 | ### Token的发送 1241 | 1242 | 之前说了,用JSON-WEB-TOKEN之后,这个系统的验证就完全依靠token了。如果token正确就下发资源,如果资源不正确,就返回错误信息。 1243 | 1244 | 因为我们用了`koa-jwt`,所以只需要在每条请求头上加上`Authorization`属性,值是`Bearer {token值}`,然后让`Koa`在接收请求之前验证一下token即可。但是如果每发一条请求就要手动写一句这个,太累了。于是我们可以做到全局`Header`设定。 1245 | 1246 | 打开`src/main.js`,在路由跳转的钩子里加上这句: 1247 | 1248 | ```js 1249 | 1250 | // scr/main.json 1251 | 1252 | router.beforeEach((to,from,next) =>{ 1253 | const token = sessionStorage.getItem('demo-token'); 1254 | if(to.path == '/'){ 1255 | if(token != 'null' && token != null){ 1256 | next('/todolist') 1257 | } 1258 | next(); 1259 | }else{ 1260 | if(token != 'null' && token != null){ 1261 | Vue.prototype.$http.defaults.headers.common['Authorization'] = 'Bearer ' + token; // 全局设定header的token验证,注意Bearer后有个空格 1262 | next() 1263 | }else{ 1264 | next('/') 1265 | } 1266 | } 1267 | }) 1268 | 1269 | ``` 1270 | 1271 | 这样就完成了token的客户端发送设定。 1272 | 1273 | ### Koa端对Token的验证 1274 | 1275 | 接着我们实现两个简单的api,这两个api请求的路径就不是`/auth/xxx`而是`/api/xxx`了。我们还需要实现,访问`/api/*`路径的请求都需要经过`koa-jwt`的验证,而`/auth/*`的请求不需要。 1276 | 1277 | 首先去`models`目录下新建一个`todolist.js`的文件: 1278 | 1279 | ```js 1280 | 1281 | // server/models/todolist.js 1282 | 1283 | const db = require('../config/db.js'), 1284 | todoModel = '../schema/list.js'; // 引入todolist的表结构 1285 | const TodolistDb = db.Todolist; // 引入数据库 1286 | 1287 | const Todolist = TodolistDb.import(todoModel); 1288 | 1289 | const getTodolistById = function* (id){ // 获取某个用户的全部todolist 1290 | const todolist = yield Todolist.findAll({ // 查找全部的todolist 1291 | where: { 1292 | user_id: id 1293 | }, 1294 | attributes: ['id','content','status'] // 只需返回这三个字段的结果即可 1295 | }); 1296 | 1297 | return todolist // 返回数据 1298 | } 1299 | 1300 | const createTodolist = function* (data){ // 给某个用户创建一条todolist 1301 | yield Todolist.create({ 1302 | user_id: data.id, // 用户的id,用来确定给哪个用户创建 1303 | content: data.content, 1304 | status: data.status 1305 | }) 1306 | return true 1307 | } 1308 | 1309 | module.exports = { 1310 | getTodolistById, 1311 | createTodolist 1312 | } 1313 | ``` 1314 | 1315 | 接着去`controllers`目录下新建一个`todolist.js`的文件: 1316 | 1317 | ```js 1318 | // server/controllers/todolist 1319 | 1320 | const todolist = require('../models/todolist.js'); 1321 | 1322 | const getTodolist = function* (){ // 获取某个用户的所有todolist 1323 | const id = this.params.id; // 获取url里传过来的参数里的id 1324 | const result = yield todolist.getTodolistById(id); // 通过yield “同步”地返回查询结果 1325 | this.body = result // 将请求的结果放到response的body里返回 1326 | } 1327 | 1328 | const createTodolist = function* (){ // 给某个用户创建一条todolist 1329 | const data = this.request.body; // post请求,数据是在request.body里的 1330 | const result = yield todolist.createTodolist(data); 1331 | 1332 | this.body = { 1333 | success: true 1334 | } 1335 | } 1336 | 1337 | 1338 | module.exports = { 1339 | getTodolist, 1340 | createTodolist 1341 | } 1342 | ``` 1343 | 1344 | 然后去`routes`文件夹里新建一个`api.js`文件: 1345 | 1346 | ```js 1347 | 1348 | // server/routes/api.js 1349 | 1350 | const todolist = require('../controllers/todolist.js'); 1351 | const router = require('koa-router')(); 1352 | 1353 | todolist(router); // 引入koa-router 1354 | 1355 | module.exports = router; // 导出router规则 1356 | 1357 | ``` 1358 | 1359 | 最后,去根目录下的`app.js`,给koa加上新的路由规则: 1360 | 1361 | ```js 1362 | 1363 | // app.js 1364 | 1365 | const app = require('koa')() 1366 | , koa = require('koa-router')() 1367 | , json = require('koa-json') 1368 | , logger = require('koa-logger') 1369 | , auth = require('./server/routes/auth.js') 1370 | , api = require('./server/routes/api.js') 1371 | , jwt = require('koa-jwt'); 1372 | 1373 | // ..... 省略 1374 | 1375 | app.use(function* (next){ 1376 | let start = new Date; 1377 | yield next; 1378 | let ms = new Date - start; 1379 | console.log('%s %s - %s', this.method, this.url, ms); 1380 | }); 1381 | 1382 | app.use(function *(next){ // 如果JWT验证失败,返回验证失败信息 1383 | try { 1384 | yield next; 1385 | } catch (err) { 1386 | if (401 == err.status) { 1387 | this.status = 401; 1388 | this.body = { 1389 | success: false, 1390 | token: null, 1391 | info: 'Protected resource, use Authorization header to get access' 1392 | }; 1393 | } else { 1394 | throw err; 1395 | } 1396 | } 1397 | }); 1398 | 1399 | app.on('error', function(err, ctx){ 1400 | console.log('server error', err); 1401 | }); 1402 | 1403 | koa.use('/auth', auth.routes()); // 挂载到koa-router上,同时会让所有的auth的请求路径前面加上'/auth'的请求路径。 1404 | koa.use("/api",jwt({secret: 'vue-koa-demo'}),api.routes()) // 所有走/api/打头的请求都需要经过jwt中间件的验证。secret密钥必须跟我们当初签发的secret一致 1405 | 1406 | app.use(koa.routes()); // 将路由规则挂载到Koa上。 1407 | 1408 | // ...省略 1409 | 1410 | ``` 1411 | 1412 | 至此,后端的两个api已经构建完成。 1413 | 1414 | 初始化配置相对复杂一些,涉及到`model`、`controllers`、`routes`和`app.js`,可能会让人望而却步。实际上第一次构建完成之后,后续要添加api,基本上只需要在`model`和`controllers`写好方法,定好接口即可,十分方便。 1415 | 1416 | ### 前端对接接口 1417 | 1418 | 后端接口已经开放,接下来要把前端和后端进行对接。主要有两个对接接口: 1419 | 1420 | 1. 获取某个用户的所有todolist 1421 | 2. 创建某个用户的一条todolist 1422 | 1423 | 接下来就是改写`Todolist.vue`里的方法了: 1424 | 1425 | ```js 1426 | 1427 | // todolist.js 1428 | 1429 | // ... 省略 1430 | 1431 | created(){ 1432 | const userInfo = this.getUserInfo(); 1433 | if(userInfo != null){ 1434 | this.id = userInfo.id; 1435 | this.name = userInfo.name; 1436 | }else{ 1437 | this.id = ''; 1438 | this.name = '' 1439 | } 1440 | this.getTodolist(); // 新增:在组件创建时获取todolist 1441 | }, 1442 | 1443 | // ... 省略 1444 | 1445 | methods: { 1446 | addTodos() { 1447 | if(this.todos == '') 1448 | return 1449 | let obj = { 1450 | status: false, 1451 | content: this.todos, 1452 | id: this.id 1453 | } 1454 | this.$http.post('/api/todolist', obj) // 新增创建请求 1455 | .then((res) => { 1456 | if(res.status == 200){ // 当返回的状态为200成功时 1457 | this.$message({ 1458 | type: 'success', 1459 | message: '创建成功!' 1460 | }) 1461 | this.getTodolist(); // 获得最新的todolist 1462 | }else{ 1463 | this.$message.error('创建失败!') // 当返回不是200说明处理出问题 1464 | } 1465 | }, (err) => { 1466 | this.$message.error('创建失败!') // 当没有返回值说明服务端错误或者请求没发送出去 1467 | console.log(err) 1468 | }) 1469 | this.todos = ''; // 将当前todos清空 1470 | }, 1471 | // ... 省略一些方法 1472 | getTodolist(){ 1473 | this.$http.get('/api/todolist/' + this.id) // 向后端发送获取todolist的请求 1474 | .then((res) => { 1475 | if(res.status == 200){ 1476 | this.list = res.data // 将获取的信息塞入实例里的list 1477 | }else{ 1478 | this.$message.error('获取列表失败!') 1479 | } 1480 | }, (err) => { 1481 | this.$message.error('获取列表失败!') 1482 | console.log(err) 1483 | }) 1484 | } 1485 | } 1486 | 1487 | ``` 1488 | 1489 | 至此,前后端的部分已经完整构建。让我们来看看效果: 1490 | 1491 | ![todolist](https://img.piegg.cn/vue-koa-demo/login2todolist-4.gif "todolist") 1492 | 1493 | 做到这一步的时候其实我们的应用已经基本完成了。最后的收尾工作,让我们来收一下。 1494 | 1495 | 原本的前端版本还有`完成`、`删除`、`还原`三种状态,其中`完成`和`还原`只是状态的切换(更新),所以可以算是一个api,然后就是删除是单独一个api。于是我们就能算是完成了增、删、改、查了。接下去的部分就提供代码就行,其实思路跟之前的是一样的,只不过操作的函数不一样罢了。 1496 | 1497 | ### Todolist的改、删 1498 | 1499 | ```js 1500 | 1501 | // server/models/todolist.js 1502 | 1503 | // ...省略 1504 | 1505 | const removeTodolist = function* (id,user_id){ 1506 | yield Todolist.destroy({ 1507 | where: { 1508 | id, 1509 | user_id 1510 | } 1511 | }) 1512 | return true 1513 | } 1514 | 1515 | const updateTodolist = function* (id,user_id,status){ 1516 | yield Todolist.update( 1517 | { 1518 | status 1519 | }, 1520 | { 1521 | where: { 1522 | id, 1523 | user_id 1524 | } 1525 | } 1526 | ) 1527 | return true 1528 | } 1529 | 1530 | module.exports = { 1531 | getTodolistById, 1532 | createTodolist, 1533 | removeTodolist, 1534 | updateTodolist 1535 | } 1536 | 1537 | ``` 1538 | 1539 | 1540 | ```js 1541 | 1542 | // server/controllers/todolist.js 1543 | 1544 | // ... 省略 1545 | 1546 | const removeTodolist = function* (){ 1547 | const id = this.params.id; 1548 | const user_id = this.params.userId; 1549 | const result = yield todolist.removeTodolist(id,user_id); 1550 | 1551 | this.body = { 1552 | success: true 1553 | } 1554 | } 1555 | 1556 | const updateTodolist = function* (){ 1557 | const id = this.params.id; 1558 | const user_id = this.params.userId; 1559 | let status = this.params.status; 1560 | status == '0' ? status = true : status = false;// 状态反转(更新) 1561 | 1562 | const result = yield todolist.updateTodolist(id,user_id,status); 1563 | 1564 | this.body = { 1565 | success: true 1566 | } 1567 | } 1568 | 1569 | module.exports = (router) => { 1570 | getTodolist, 1571 | createTodolist, 1572 | removeTodolist, 1573 | updateTodolist 1574 | } 1575 | 1576 | ``` 1577 | 1578 | ```html 1579 | 1580 | 1581 | .... 1582 | 1583 | 1584 | 完成 1585 | .... 1586 | 还原 1587 | .... 1588 | 1629 | .... 1630 | ``` 1631 | 1632 | 让我们来看看最后99%成品的效果吧: 1633 | 1634 | ![Todolist](https://img.piegg.cn/vue-koa-demo/todolist-5.gif 'todolist') 1635 | 1636 | ## 项目部署 1637 | 1638 | 很多教程到类似于我上面的部分就结束了。但是实际上我们做一个项目最想要的就是部署给大家用不是么? 1639 | 1640 | 在部署这块有些坑,需要让大家也一起知道一下。这个项目是个全栈项目(虽然是个很简单的。。。),所以就涉及到前后端通信的问题,也就会涉及到是同域请求还是跨域请求。 1641 | 1642 | 我们也说过,要解决这个问题有两种方便的解决办法,第一种,服务端加上`cors`,客户端就可以随意的跨域请求。但是这样会有个问题,因为我们是以同域的形式开发,请求的地址也是写的相对地址:`/api/*`、`auth/*`这样的路径,访问的路径的自然是同域。如果要在服务端加上`cors`,我们还需要将我们的所有请求地址改成`localhost:8889/api/*`,`localhost:8889/auth/*`,这样的话,如果服务端的端口号一变,我们还需要重新修改前端所有的请求地址。这样很不方便也很不科学。 1643 | 1644 | 因此,要将请求变为同域才是最好的解决办法——不管服务端端口号怎么变,只要是同域都可以请求到。 1645 | 1646 | 于是要把Vue和Koa结合起来变成一个完整的项目(之前实际上都是在开发模式下,webpack帮我们进行请求的代理转发,所以看起来像是同域请求,而Vue和Koa并没有完全结合起来),就得在生产模式下,将Vue的静态文件交给Koa“托管”,所有访问前端的请求全部走Koa端,包括静态文件资源的请求,也走Koa端,把Koa作为一个Vue项目的静态资源服务器,这样就可以让Vue里的请求走的都是同域了。(相当于,之前开发模式是webpack开启了一个服务器托管了Vue的资源和请求,现在生产模式下改成Koa托管Vue的资源和请求) 1647 | 1648 | 要在开发和生产模式改变不同的托管服务器,其实也很简单,只需要在生产模式下,用Koa的静态资源服务中间件托管构建好的Vue文件即可。 1649 | 1650 | ### Webpack打包 1651 | 1652 | 部署之前我们要用Webpack将我们的前端项目打包输出一下。但是如果直接用`npm run build`,你会发现打包出来的文件太大了: 1653 | 1654 | ```bash 1655 | 1656 | Asset Size Chunks Chunk Names 1657 | static/css/app.d9034fc06fd57ce00d6e75ed49f0dafe.css 120 kB 2, 0 [emitted] app 1658 | static/fonts/element-icons.a61be9c.eot 13.5 kB [emitted] 1659 | static/img/element-icons.09162bc.svg 17.4 kB [emitted] 1660 | static/js/manifest.8ea250834bdc80e4d73b.js 832 bytes 0 [emitted] manifest 1661 | static/js/vendor.75bbe7ecea37b0d4c62d.js 623 kB 1, 0 [emitted] vendor 1662 | static/js/app.e2d125562bfc4c57f9cb.js 16.5 kB 2, 0 [emitted] app 1663 | static/fonts/element-icons.b02bdc1.ttf 13.2 kB [emitted] 1664 | static/js/manifest.8ea250834bdc80e4d73b.js.map 8.86 kB 0 [emitted] manifest 1665 | static/js/vendor.75bbe7ecea37b0d4c62d.js.map 3.94 MB 1, 0 [emitted] vendor 1666 | static/js/app.e2d125562bfc4c57f9cb.js.map 64.8 kB 2, 0 [emitted] app 1667 | static/css/app.d9034fc06fd57ce00d6e75ed49f0dafe.css.map 151 kB 2, 0 [emitted] app 1668 | index.html 563 bytes [emitted] 1669 | ``` 1670 | 1671 | 竟然有3.94MB的map文件。这肯定是不能接受的。于是要修改一下webpack的输出的设置,取消输出map文件。 1672 | 1673 | 找到根目录下的`config/index.js`:把`productionSourceMap: true`这句话改成`productionSourceMap: false`。然后再执行一遍`npm run build`。 1674 | 1675 | ```bash 1676 | Asset Size Chunks Chunk Names 1677 | static/fonts/element-icons.a61be9c.eot 13.5 kB [emitted] 1678 | static/fonts/element-icons.b02bdc1.ttf 13.2 kB [emitted] 1679 | static/img/element-icons.09162bc.svg 17.4 kB [emitted] 1680 | static/js/manifest.3ba218c80028a707a728.js 774 bytes 0 [emitted] manifest 1681 | static/js/vendor.75bbe7ecea37b0d4c62d.js 623 kB 1, 0 [emitted] vendor 1682 | static/js/app.b6acaca2531fc0baa447.js 16.5 kB 2, 0 [emitted] app 1683 | static/css/app.d9034fc06fd57ce00d6e75ed49f0dafe.css 120 kB 2, 0 [emitted] app 1684 | index.html 563 bytes [emitted] 1685 | ``` 1686 | 1687 | 把sourceMap去掉了之后,体积就小下来了。虽然600+kb的大小还是有点大,不过放到服务端,gzip之后只剩150+kb的体积勉强还是可以接受。当然,对于webpack输出的优化,不是本文讨论的范围,有很多更好的文章讲述了这个东西,故本文不再详细展开。 1688 | 1689 | 打包好后就是相当于输出了一堆静态文件,当然这堆静态文件需要放在服务端才可以访问。我们要将这堆静态资源用Koa托管。 1690 | 1691 | ### Koa serve静态资源 1692 | 1693 | `yarn add koa-static` 1694 | 1695 | 打开`app.js`,引入两个新依赖,其中`path`是nodejs原生自带。 1696 | 1697 | ```js 1698 | // app.js 1699 | 1700 | // .... 1701 | const path =require('path') 1702 | , serve = require('koa-static'); 1703 | // .... 1704 | 1705 | // 静态文件serve在koa-router的其他规则之上 1706 | app.use(serve(path.resolve('dist'))); // 将webpack打包好的项目目录作为Koa静态文件服务的目录 1707 | 1708 | // 下面这些是之前就有的。。。为了方便找位置故标示出来 1709 | koa.use('/auth', auth.routes()); 1710 | koa.use("/api",jwt({secret: 'vue-koa-demo'}),api.routes()) 1711 | 1712 | // ... 1713 | ``` 1714 | 1715 | 然后重新运行一遍`node app.js`,看到输出`Koa is listening in 8889`后,你可以打开浏览器`localhost:8889`就可以看到如下情景: 1716 | 1717 | ![vue-koa](https://img.piegg.cn/vue-koa-demo/vue-koa.png) 1718 | 1719 | 至此已经基本上接近尾声,不过还存在一个问题:如果我们登录进去之后,在todolist页面一刷新,就会出现: 1720 | 1721 | ![404](https://img.piegg.cn/vue-koa-demo/404.png '404') 1722 | 1723 | 为什么会出现这种情况?简单来说是因为我们使用了前端路由,用了HTML5 的History模式,如果没有做其他任何配置的话,刷新页面,那么浏览器将会去服务端访问这个页面地址,因为服务端并没有配置这个地址的路由,所以自然就返回404 Not Found了。 1724 | 1725 | 详细可以参考vue-router的[这篇文档](https://router.vuejs.org/zh-cn/essentials/history-mode.html) 1726 | 1727 | 该怎么解决?其实也很简单,多加一个中间件:`koa-history-api-fallback`即可. 1728 | 1729 | `yarn add koa-history-api-fallback` 1730 | 1731 | ```js 1732 | 1733 | //... 省略 1734 | 1735 | const historyApiFallback = require('koa-history-api-fallback'); // 引入依赖 1736 | 1737 | app.use(require('koa-bodyparser')()); 1738 | app.use(json()); 1739 | app.use(logger()); 1740 | app.use(historyApiFallback()); // 在这个地方加入。一定要加在静态文件的serve之前,否则会失效。 1741 | 1742 | // ... 1743 | ``` 1744 | 1745 | 这个时候,你再重新启动一下koa,登录之后再刷新页面,就不会再出现404 Not Found了。 1746 | 1747 | ### API Test 1748 | 1749 | 本来写到上面基本本文已经算是结束了。但是由于我在开发的过程中遇到了一些问题,所以还需要做一些微调。 1750 | 1751 | 我们知道koa的use方法是有顺序只差的。 1752 | 1753 | ```js 1754 | const app = require('koa'); 1755 | app.use(A); 1756 | app.use(B); 1757 | ``` 1758 | 1759 | ```js 1760 | const app = require('koa'); 1761 | app.use(B); 1762 | app.use(A); 1763 | ``` 1764 | 1765 | 这二者是有区别的,谁先被use,谁的规则就放到前面先执行。 1766 | 1767 | 因此如果我们将静态文件的serve以及`historyApiFallback`放在了api的请求之前,那么用postman测试api的时候总会先返回完整的页面: 1768 | 1769 | ![postman](https://img.piegg.cn/vue-koa-demo/postman.png) 1770 | 1771 | 因此正确的做法,应该是将它们放到我们写的api的规则之后: 1772 | 1773 | ```js 1774 | 1775 | // app.js 1776 | // ... 1777 | 1778 | koa.use('/auth', auth.routes()); // 挂载到koa-router上,同时会让所有的auth的请求路径前面加上'/auth'的请求路径。 1779 | koa.use("/api",jwt({secret: 'vue-koa-demo'}),api.routes()) // 所有走/api/打头的请求都需要经过jwt验证。 1780 | 1781 | app.use(koa.routes()); // 将路由规则挂载到Koa上。 1782 | 1783 | app.use(historyApiFallback()); // 将这两个中间件挂载在api的路由之后 1784 | app.use(serve(path.resolve('dist'))); // 将webpack打包好的项目目录作为Koa静态文件服务的目录 1785 | 1786 | ``` 1787 | 1788 | 这样就能正常返回数据了。 1789 | 1790 | ### Nginx配置 1791 | 1792 | 真正部署到服务器的时候,我们肯定不会让大家输入`域名:8889`这样的方式让大家访问。所以需要用Nginx监听80端口,把访问我们指定域名的请求引导转发给Koa服务端。 1793 | 1794 | 大致的`nginx.conf`如下: 1795 | 1796 | ```nginx 1797 | http { 1798 | 1799 | # .... 1800 | upstream koa.server{ 1801 | server 127.0.0.1:8889; 1802 | } 1803 | 1804 | server { 1805 | listen 80; 1806 | server_name xxx.xxx.com; 1807 | 1808 | location / { 1809 | proxy_pass http://koa.server; 1810 | proxy_redirect off; 1811 | } 1812 | 1813 | #.... 1814 | } 1815 | #.... 1816 | } 1817 | ``` 1818 | 1819 | 如果有精力还可以配置一下Nginx的Gzip,能让请求的JS\CSS\HTML等静态文件更小,响应速度更快些。 1820 | 1821 | ## 写在最后 1822 | 1823 | 至此,我们已经完成了一个从前端到后端,从本地到服务器的完整项目。虽然它真的是个很简单的小东西,被大家也已经用其他的方式写烂了(比如用localStorage做存储)。但是它作为一个完整的前后端的DEMO,我觉得让大家入门也相对更容易一些,能够体会到全栈开发也不是想象中的“那么难”(入门的难度还是可以接受的嘛)。有了Nodejs之后我们能够做的事真的好多! 1824 | 1825 | 当然,由于篇幅有限,本文能够讲述东西毕竟不够多,而且讲的东西也不可能面面俱到,很多东西都是点到即止,让大家能够自己发挥。其实还想讲讲`Event Bus`的简单使用,还有分页的基本实现等等,东西太多了,一时间大家消化不了。 1826 | 1827 | 实际上我在做前段时间的项目的时候,也是完全不知道怎么把Vue和Koa结合起来开发。我甚至不知道怎么用Koa来提供API,我只会用Koa来做服务端渲染,比如那些JADE\EJS等模板引擎渲染的页面。所以之前那个项目做完让我自己学到良多东西,故而也分享给大家。 1828 | 1829 | 实际上本文的Koa的api提供的形式也尽量和RESTful靠拢了,因此你也可以学会如何通过Koa提供RESTful形式的API了。 1830 | 1831 | 最后放上本文项目的Github[地址](https://github.com/Molunerfinn/vue-koa-demo),如果这个项目对你有帮助,希望大家可以fork,给我提建议,如果再有时间,可以点个Star那就更好啦~ 1832 | 1833 | 另外,本文的版本是用Koa1写成的。仓库已经更新Koa2。从Koa1->Koa2并没有什么难度,其实很关键的两点是: 1834 | 1835 | 1. 用`async await`替代`yield generation` 1836 | 2. 用`koa2`的中间件替代`koa1`的中间件,原因同上一条 1837 | 1838 | 互相学习,如果能从这个项目里学到东西我就很开心啦~ 1839 | 1840 | > 注: 转载需经过同意,必须署名 1841 | 1842 | 1843 | 1844 | 1845 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | import './env' 2 | import Koa from 'koa' 3 | import json from 'koa-json' 4 | import logger from 'koa-logger' 5 | import auth from './server/routes/auth.js' 6 | import api from './server/routes/api.js' 7 | import jwt from 'koa-jwt' 8 | import path from 'path' 9 | import serve from 'koa-static' 10 | import historyApiFallback from 'koa2-history-api-fallback' 11 | import koaRouter from 'koa-router' 12 | import koaBodyparser from 'koa-bodyparser' 13 | 14 | const app = new Koa() 15 | const router = koaRouter() 16 | 17 | let port = process.env.PORT 18 | 19 | app.use(koaBodyparser()) 20 | app.use(json()) 21 | app.use(logger()) 22 | 23 | app.use(async function (ctx, next) { 24 | let start = new Date() 25 | await next() 26 | let ms = new Date() - start 27 | console.log('%s %s - %s', ctx.method, ctx.url, ms) 28 | }) 29 | 30 | app.use(async function (ctx, next) { // 如果JWT验证失败,返回验证失败信息 31 | try { 32 | await next() 33 | } catch (err) { 34 | if (err.status === 401) { 35 | ctx.status = 401 36 | ctx.body = { 37 | success: false, 38 | token: null, 39 | info: 'Protected resource, use Authorization header to get access' 40 | } 41 | } else { 42 | throw err 43 | } 44 | } 45 | }) 46 | 47 | app.on('error', function (err, ctx) { 48 | console.log('server error', err) 49 | }) 50 | 51 | router.use('/auth', auth.routes()) // 挂载到koa-router上,同时会让所有的auth的请求路径前面加上'/auth'的请求路径。 52 | router.use('/api', jwt({secret: 'vue-koa-demo'}), api.routes()) // 所有走/api/打头的请求都需要经过jwt验证。 53 | 54 | app.use(router.routes()) // 将路由规则挂载到Koa上。 55 | app.use(historyApiFallback()) 56 | app.use(serve(path.resolve('dist'))) // 将webpack打包好的项目目录作为Koa静态文件服务的目录 57 | 58 | export default app.listen(port, () => { 59 | console.log(`Koa is listening in ${port}`) 60 | }) 61 | -------------------------------------------------------------------------------- /build/build.js: -------------------------------------------------------------------------------- 1 | require('./check-versions')() 2 | 3 | process.env.NODE_ENV = 'production' 4 | 5 | var ora = require('ora') 6 | var rm = require('rimraf') 7 | var path = require('path') 8 | var chalk = require('chalk') 9 | var webpack = require('webpack') 10 | var config = require('../config') 11 | var webpackConfig = require('./webpack.prod.conf') 12 | 13 | var spinner = ora('building for production...') 14 | spinner.start() 15 | 16 | rm(path.join(config.build.assetsRoot, config.build.assetsSubDirectory), err => { 17 | if (err) throw err 18 | webpack(webpackConfig, function (err, stats) { 19 | spinner.stop() 20 | if (err) throw err 21 | process.stdout.write(stats.toString({ 22 | colors: true, 23 | modules: false, 24 | children: false, 25 | chunks: false, 26 | chunkModules: false 27 | }) + '\n\n') 28 | 29 | console.log(chalk.cyan(' Build complete.\n')) 30 | console.log(chalk.yellow( 31 | ' Tip: built files are meant to be served over an HTTP server.\n' + 32 | ' Opening index.html over file:// won\'t work.\n' 33 | )) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /build/check-versions.js: -------------------------------------------------------------------------------- 1 | var chalk = require('chalk') 2 | var semver = require('semver') 3 | var packageConfig = require('../package.json') 4 | var shell = require('shelljs') 5 | function exec (cmd) { 6 | return require('child_process').execSync(cmd).toString().trim() 7 | } 8 | 9 | var versionRequirements = [ 10 | { 11 | name: 'node', 12 | currentVersion: semver.clean(process.version), 13 | versionRequirement: packageConfig.engines.node 14 | }, 15 | ] 16 | 17 | if (shell.which('npm')) { 18 | versionRequirements.push({ 19 | name: 'npm', 20 | currentVersion: exec('npm --version'), 21 | versionRequirement: packageConfig.engines.npm 22 | }) 23 | } 24 | 25 | module.exports = function () { 26 | var warnings = [] 27 | for (var i = 0; i < versionRequirements.length; i++) { 28 | var mod = versionRequirements[i] 29 | if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) { 30 | warnings.push(mod.name + ': ' + 31 | chalk.red(mod.currentVersion) + ' should be ' + 32 | chalk.green(mod.versionRequirement) 33 | ) 34 | } 35 | } 36 | 37 | if (warnings.length) { 38 | console.log('') 39 | console.log(chalk.yellow('To use this template, you must update following to modules:')) 40 | console.log() 41 | for (var i = 0; i < warnings.length; i++) { 42 | var warning = warnings[i] 43 | console.log(' ' + warning) 44 | } 45 | console.log() 46 | process.exit(1) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /build/dev-client.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | require('eventsource-polyfill') 3 | var hotClient = require('webpack-hot-middleware/client?noInfo=true&reload=true') 4 | 5 | hotClient.subscribe(function (event) { 6 | if (event.action === 'reload') { 7 | window.location.reload() 8 | } 9 | }) 10 | -------------------------------------------------------------------------------- /build/dev-server.js: -------------------------------------------------------------------------------- 1 | require('./check-versions')() 2 | 3 | var config = require('../config') 4 | if (!process.env.NODE_ENV) { 5 | process.env.NODE_ENV = JSON.parse(config.dev.env.NODE_ENV) 6 | } 7 | 8 | var opn = require('opn') 9 | var path = require('path') 10 | var express = require('express') 11 | var webpack = require('webpack') 12 | var proxyMiddleware = require('http-proxy-middleware') 13 | var webpackConfig = require('./webpack.dev.conf') 14 | 15 | // default port where dev server listens for incoming traffic 16 | var port = process.env.PORT || config.dev.port 17 | // automatically open browser, if not set will be false 18 | var autoOpenBrowser = !!config.dev.autoOpenBrowser 19 | // Define HTTP proxies to your custom API backend 20 | // https://github.com/chimurai/http-proxy-middleware 21 | var proxyTable = config.dev.proxyTable 22 | 23 | var app = express() 24 | var compiler = webpack(webpackConfig) 25 | 26 | var devMiddleware = require('webpack-dev-middleware')(compiler, { 27 | publicPath: webpackConfig.output.publicPath, 28 | quiet: true 29 | }) 30 | 31 | var hotMiddleware = require('webpack-hot-middleware')(compiler, { 32 | log: () => {} 33 | }) 34 | // force page reload when html-webpack-plugin template changes 35 | compiler.plugin('compilation', function (compilation) { 36 | compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) { 37 | hotMiddleware.publish({ action: 'reload' }) 38 | cb() 39 | }) 40 | }) 41 | 42 | // proxy api requests 43 | Object.keys(proxyTable).forEach(function (context) { 44 | var options = proxyTable[context] 45 | if (typeof options === 'string') { 46 | options = { target: options } 47 | } 48 | app.use(proxyMiddleware(options.filter || context, options)) 49 | }) 50 | 51 | // handle fallback for HTML5 history API 52 | app.use(require('connect-history-api-fallback')()) 53 | 54 | // serve webpack bundle output 55 | app.use(devMiddleware) 56 | 57 | // enable hot-reload and state-preserving 58 | // compilation error display 59 | app.use(hotMiddleware) 60 | 61 | // serve pure static assets 62 | var staticPath = path.posix.join(config.dev.assetsPublicPath, config.dev.assetsSubDirectory) 63 | app.use(staticPath, express.static('./static')) 64 | 65 | var uri = 'http://localhost:' + port 66 | 67 | var _resolve 68 | var readyPromise = new Promise(resolve => { 69 | _resolve = resolve 70 | }) 71 | 72 | console.log('> Starting dev server...') 73 | devMiddleware.waitUntilValid(() => { 74 | console.log('> Listening at ' + uri + '\n') 75 | // when env is testing, don't need open it 76 | if (autoOpenBrowser && process.env.NODE_ENV !== 'testing') { 77 | opn(uri) 78 | } 79 | _resolve() 80 | }) 81 | 82 | var server = app.listen(port) 83 | 84 | module.exports = { 85 | ready: readyPromise, 86 | close: () => { 87 | server.close() 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /build/utils.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var config = require('../config') 3 | var ExtractTextPlugin = require('extract-text-webpack-plugin') 4 | 5 | exports.assetsPath = function (_path) { 6 | var assetsSubDirectory = process.env.NODE_ENV === 'production' 7 | ? config.build.assetsSubDirectory 8 | : config.dev.assetsSubDirectory 9 | return path.posix.join(assetsSubDirectory, _path) 10 | } 11 | 12 | exports.cssLoaders = function (options) { 13 | options = options || {} 14 | 15 | var cssLoader = { 16 | loader: 'css-loader', 17 | options: { 18 | minimize: process.env.NODE_ENV === 'production', 19 | sourceMap: options.sourceMap 20 | } 21 | } 22 | 23 | // generate loader string to be used with extract text plugin 24 | function generateLoaders (loader, loaderOptions) { 25 | var loaders = [cssLoader] 26 | if (loader) { 27 | loaders.push({ 28 | loader: loader + '-loader', 29 | options: Object.assign({}, loaderOptions, { 30 | sourceMap: options.sourceMap 31 | }) 32 | }) 33 | } 34 | 35 | // Extract CSS when that option is specified 36 | // (which is the case during production build) 37 | if (options.extract) { 38 | return ExtractTextPlugin.extract({ 39 | use: loaders, 40 | fallback: 'vue-style-loader' 41 | }) 42 | } else { 43 | return ['vue-style-loader'].concat(loaders) 44 | } 45 | } 46 | 47 | // https://vue-loader.vuejs.org/en/configurations/extract-css.html 48 | return { 49 | css: generateLoaders(), 50 | postcss: generateLoaders(), 51 | less: generateLoaders('less'), 52 | sass: generateLoaders('sass', { indentedSyntax: true }), 53 | scss: generateLoaders('sass'), 54 | stylus: generateLoaders('stylus'), 55 | styl: generateLoaders('stylus') 56 | } 57 | } 58 | 59 | // Generate loaders for standalone style files (outside of .vue) 60 | exports.styleLoaders = function (options) { 61 | var output = [] 62 | var loaders = exports.cssLoaders(options) 63 | for (var extension in loaders) { 64 | var loader = loaders[extension] 65 | output.push({ 66 | test: new RegExp('\\.' + extension + '$'), 67 | use: loader 68 | }) 69 | } 70 | return output 71 | } 72 | -------------------------------------------------------------------------------- /build/vue-loader.conf.js: -------------------------------------------------------------------------------- 1 | var utils = require('./utils') 2 | var config = require('../config') 3 | var isProduction = process.env.NODE_ENV === 'production' 4 | 5 | module.exports = { 6 | loaders: utils.cssLoaders({ 7 | sourceMap: isProduction 8 | ? config.build.productionSourceMap 9 | : config.dev.cssSourceMap, 10 | extract: isProduction 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /build/webpack.base.conf.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var utils = require('./utils') 3 | var config = require('../config') 4 | var vueLoaderConfig = require('./vue-loader.conf') 5 | 6 | function resolve (dir) { 7 | return path.join(__dirname, '..', dir) 8 | } 9 | 10 | module.exports = { 11 | node: { 12 | net: 'empty', 13 | tls: 'empty', 14 | dns: 'empty' 15 | }, 16 | entry: { 17 | app: './src/main.js' 18 | }, 19 | output: { 20 | path: config.build.assetsRoot, 21 | filename: '[name].js', 22 | publicPath: process.env.NODE_ENV === 'production' 23 | ? config.build.assetsPublicPath 24 | : config.dev.assetsPublicPath 25 | }, 26 | resolve: { 27 | extensions: ['.js', '.vue', '.json'], 28 | alias: { 29 | 'vue$': 'vue/dist/vue.esm.js', 30 | '@': resolve('src') 31 | } 32 | }, 33 | module: { 34 | rules: [ 35 | { 36 | test: /\.(js|vue)$/, 37 | loader: 'eslint-loader', 38 | enforce: 'pre', 39 | include: [resolve('src'), resolve('test')], 40 | options: { 41 | formatter: require('eslint-friendly-formatter') 42 | } 43 | }, 44 | { 45 | test: /\.vue$/, 46 | loader: 'vue-loader', 47 | options: vueLoaderConfig 48 | }, 49 | { 50 | test: /\.js$/, 51 | loader: 'babel-loader', 52 | include: [resolve('src'), resolve('test')] 53 | }, 54 | { 55 | test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, 56 | loader: 'url-loader', 57 | options: { 58 | limit: 10000, 59 | name: utils.assetsPath('img/[name].[hash:7].[ext]') 60 | } 61 | }, 62 | { 63 | test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, 64 | loader: 'url-loader', 65 | options: { 66 | limit: 10000, 67 | name: utils.assetsPath('fonts/[name].[hash:7].[ext]') 68 | } 69 | } 70 | ] 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /build/webpack.dev.conf.js: -------------------------------------------------------------------------------- 1 | var utils = require('./utils') 2 | var webpack = require('webpack') 3 | var config = require('../config') 4 | var merge = require('webpack-merge') 5 | var baseWebpackConfig = require('./webpack.base.conf') 6 | var HtmlWebpackPlugin = require('html-webpack-plugin') 7 | var FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin') 8 | 9 | // add hot-reload related code to entry chunks 10 | Object.keys(baseWebpackConfig.entry).forEach(function (name) { 11 | baseWebpackConfig.entry[name] = ['./build/dev-client'].concat(baseWebpackConfig.entry[name]) 12 | }) 13 | 14 | module.exports = merge(baseWebpackConfig, { 15 | module: { 16 | rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap }) 17 | }, 18 | // cheap-module-eval-source-map is faster for development 19 | devtool: '#cheap-module-eval-source-map', 20 | plugins: [ 21 | new webpack.DefinePlugin({ 22 | 'process.env': config.dev.env 23 | }), 24 | // https://github.com/glenjamin/webpack-hot-middleware#installation--usage 25 | new webpack.HotModuleReplacementPlugin(), 26 | new webpack.NoEmitOnErrorsPlugin(), 27 | // https://github.com/ampedandwired/html-webpack-plugin 28 | new HtmlWebpackPlugin({ 29 | filename: 'index.html', 30 | template: 'index.html', 31 | inject: true 32 | }), 33 | new FriendlyErrorsPlugin() 34 | ] 35 | }) 36 | -------------------------------------------------------------------------------- /build/webpack.prod.conf.js: -------------------------------------------------------------------------------- 1 | var path = require('path') 2 | var utils = require('./utils') 3 | var webpack = require('webpack') 4 | var config = require('../config') 5 | var merge = require('webpack-merge') 6 | var baseWebpackConfig = require('./webpack.base.conf') 7 | var CopyWebpackPlugin = require('copy-webpack-plugin') 8 | var HtmlWebpackPlugin = require('html-webpack-plugin') 9 | var ExtractTextPlugin = require('extract-text-webpack-plugin') 10 | var OptimizeCSSPlugin = require('optimize-css-assets-webpack-plugin') 11 | 12 | var env = config.build.env 13 | 14 | var webpackConfig = merge(baseWebpackConfig, { 15 | module: { 16 | rules: utils.styleLoaders({ 17 | sourceMap: config.build.productionSourceMap, 18 | extract: true 19 | }) 20 | }, 21 | devtool: config.build.productionSourceMap ? '#source-map' : false, 22 | output: { 23 | path: config.build.assetsRoot, 24 | filename: utils.assetsPath('js/[name].[chunkhash].js'), 25 | chunkFilename: utils.assetsPath('js/[id].[chunkhash].js') 26 | }, 27 | plugins: [ 28 | // http://vuejs.github.io/vue-loader/en/workflow/production.html 29 | new webpack.DefinePlugin({ 30 | 'process.env': env 31 | }), 32 | new webpack.optimize.UglifyJsPlugin({ 33 | compress: { 34 | warnings: false 35 | }, 36 | sourceMap: true 37 | }), 38 | // extract css into its own file 39 | new ExtractTextPlugin({ 40 | filename: utils.assetsPath('css/[name].[contenthash].css') 41 | }), 42 | // Compress extracted CSS. We are using this plugin so that possible 43 | // duplicated CSS from different components can be deduped. 44 | new OptimizeCSSPlugin({ 45 | cssProcessorOptions: { 46 | safe: true 47 | } 48 | }), 49 | // generate dist index.html with correct asset hash for caching. 50 | // you can customize output by editing /index.html 51 | // see https://github.com/ampedandwired/html-webpack-plugin 52 | new HtmlWebpackPlugin({ 53 | filename: config.build.index, 54 | template: 'index.html', 55 | inject: true, 56 | minify: { 57 | removeComments: true, 58 | collapseWhitespace: true, 59 | removeAttributeQuotes: true 60 | // more options: 61 | // https://github.com/kangax/html-minifier#options-quick-reference 62 | }, 63 | // necessary to consistently work with multiple chunks via CommonsChunkPlugin 64 | chunksSortMode: 'dependency' 65 | }), 66 | // split vendor js into its own file 67 | new webpack.optimize.CommonsChunkPlugin({ 68 | name: 'vendor', 69 | minChunks: function (module, count) { 70 | // any required modules inside node_modules are extracted to vendor 71 | return ( 72 | module.resource && 73 | /\.js$/.test(module.resource) && 74 | module.resource.indexOf( 75 | path.join(__dirname, '../node_modules') 76 | ) === 0 77 | ) 78 | } 79 | }), 80 | // extract webpack runtime and module manifest to its own file in order to 81 | // prevent vendor hash from being updated whenever app bundle is updated 82 | new webpack.optimize.CommonsChunkPlugin({ 83 | name: 'manifest', 84 | chunks: ['vendor'] 85 | }), 86 | // copy custom static assets 87 | new CopyWebpackPlugin([ 88 | { 89 | from: path.resolve(__dirname, '../static'), 90 | to: config.build.assetsSubDirectory, 91 | ignore: ['.*'] 92 | } 93 | ]) 94 | ] 95 | }) 96 | 97 | if (config.build.productionGzip) { 98 | var CompressionWebpackPlugin = require('compression-webpack-plugin') 99 | 100 | webpackConfig.plugins.push( 101 | new CompressionWebpackPlugin({ 102 | asset: '[path].gz[query]', 103 | algorithm: 'gzip', 104 | test: new RegExp( 105 | '\\.(' + 106 | config.build.productionGzipExtensions.join('|') + 107 | ')$' 108 | ), 109 | threshold: 10240, 110 | minRatio: 0.8 111 | }) 112 | ) 113 | } 114 | 115 | if (config.build.bundleAnalyzerReport) { 116 | var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin 117 | webpackConfig.plugins.push(new BundleAnalyzerPlugin()) 118 | } 119 | 120 | module.exports = webpackConfig 121 | -------------------------------------------------------------------------------- /config/dev.env.js: -------------------------------------------------------------------------------- 1 | var merge = require('webpack-merge') 2 | var prodEnv = require('./prod.env') 3 | 4 | module.exports = merge(prodEnv, { 5 | NODE_ENV: '"development"' 6 | }) 7 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | // see http://vuejs-templates.github.io/webpack for documentation. 2 | var path = require('path') 3 | 4 | module.exports = { 5 | build: { 6 | env: require('./prod.env'), 7 | index: path.resolve(__dirname, '../dist/index.html'), 8 | assetsRoot: path.resolve(__dirname, '../dist'), 9 | assetsSubDirectory: 'static', 10 | assetsPublicPath: '/', 11 | productionSourceMap: false, 12 | // Gzip off by default as many popular static hosts such as 13 | // Surge or Netlify already gzip all static assets for you. 14 | // Before setting to `true`, make sure to: 15 | // npm install --save-dev compression-webpack-plugin 16 | productionGzip: false, 17 | productionGzipExtensions: ['js', 'css'], 18 | // Run the build command with an extra argument to 19 | // View the bundle analyzer report after build finishes: 20 | // `npm run build --report` 21 | // Set to `true` or `false` to always turn it on or off 22 | bundleAnalyzerReport: process.env.npm_config_report 23 | }, 24 | dev: { 25 | env: require('./dev.env'), 26 | port: 8080, 27 | autoOpenBrowser: true, 28 | assetsSubDirectory: 'static', 29 | assetsPublicPath: '/', 30 | proxyTable: { 31 | '/auth':{ 32 | target: 'http://localhost:8889', 33 | changeOrigin: true 34 | }, 35 | '/api':{ 36 | target: 'http://localhost:8889', 37 | changeOrigin: true 38 | } 39 | }, 40 | // CSS Sourcemaps off by default because relative paths are "buggy" 41 | // with this option, according to the CSS-Loader README 42 | // (https://github.com/webpack/css-loader#sourcemaps) 43 | // In our experience, they generally work as expected, 44 | // just be aware of this issue when enabling this option. 45 | cssSourceMap: false 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /config/prod.env.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | NODE_ENV: '"production"' 3 | } 4 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | volumes: 4 | mysqlstorage: 5 | 6 | services: 7 | node: 8 | build: 9 | context: . 10 | dockerfile: Dockerfile 11 | env_file: 12 | - ./.env 13 | volumes: 14 | - ./:/www 15 | ports: 16 | - ${PORT}:${PORT} 17 | command: "npm run start" 18 | environment: 19 | DB_URL: mysql 20 | links: 21 | - mysql 22 | depends_on: 23 | - mysql 24 | 25 | mysql: 26 | image: 'mysql:5.7' 27 | env_file: 28 | - ./.env 29 | environment: 30 | MYSQL_ROOT_PASSWORD: ${DB_PASSWORD} 31 | MYSQL_USER: ${DB_USER} 32 | build: 33 | context: . 34 | dockerfile: mysql.dockerfile 35 | args: 36 | MYSQL_ROOT_PASSWORD: ${DB_PASSWORD} 37 | restart: always 38 | volumes: 39 | - mysqlstorage:/data/db 40 | ports: 41 | - "3306:3306" -------------------------------------------------------------------------------- /env.js: -------------------------------------------------------------------------------- 1 | import * as dotenv from 'dotenv' 2 | let path = process.env.NODE_ENV === 'test' ? '.env.test' : '.env' 3 | dotenv.config({path, silent: true}) 4 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | vue-koa-demo 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /init_db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | mysql -u$MYSQL_USER -p$MYSQL_ROOT_PASSWORD <", 6 | "private": true, 7 | "scripts": { 8 | "dev": "node build/dev-server.js", 9 | "build": "node build/build.js", 10 | "server": "nodemon -w app.js -w server server-entry.js", 11 | "test": "cross-env NODE_ENV=test jest --forceExit --runInBand", 12 | "coverage": "cat ./coverage/lcov.info | coveralls", 13 | "prod": "cross-env NODE_ENV=production node server-entry.js", 14 | "start": "npm run build && npm run prod", 15 | "start:pm2": "cross-env NODE_ENV=production pm2 start pm2.json" 16 | }, 17 | "dependencies": { 18 | "axios": "^0.15.3", 19 | "bcryptjs": "^2.4.0", 20 | "element-ui": "^1.2.9", 21 | "koa": "^2.2.0", 22 | "koa-bodyparser": "^4.2.0", 23 | "koa-json": "^2.0.2", 24 | "koa-jwt": "^3.2.1", 25 | "koa-logger": "^2.0.1", 26 | "koa-router": "^7.1.1", 27 | "koa-static": "^3.0.0", 28 | "koa2-history-api-fallback": "^0.0.2", 29 | "mysql": "^2.12.0", 30 | "pm2": "^2.10.1", 31 | "sequelize": "^3.29.0", 32 | "stylus": "^0.54.5", 33 | "stylus-loader": "^2.4.0", 34 | "supertest": "^3.0.0", 35 | "vue": "2.5.2", 36 | "vue-router": "^2.3.1" 37 | }, 38 | "devDependencies": { 39 | "@vue/test-utils": "^1.0.0-beta.12", 40 | "autoprefixer": "^7.1.2", 41 | "babel-core": "^6.22.1", 42 | "babel-eslint": "^7.1.1", 43 | "babel-jest": "^21.2.0", 44 | "babel-loader": "^7.1.1", 45 | "babel-plugin-transform-runtime": "^6.22.0", 46 | "babel-preset-env": "^1.3.2", 47 | "babel-preset-stage-2": "^6.22.0", 48 | "babel-register": "^6.22.0", 49 | "chalk": "^2.0.1", 50 | "connect-history-api-fallback": "^1.3.0", 51 | "copy-webpack-plugin": "^4.0.1", 52 | "coveralls": "^3.0.0", 53 | "cross-env": "^5.1.1", 54 | "css-loader": "^0.28.0", 55 | "dotenv": "^4.0.0", 56 | "eslint": "^3.19.0", 57 | "eslint-config-standard": "^10.2.1", 58 | "eslint-friendly-formatter": "^3.0.0", 59 | "eslint-loader": "^1.7.1", 60 | "eslint-plugin-html": "^3.0.0", 61 | "eslint-plugin-import": "^2.7.0", 62 | "eslint-plugin-node": "^5.2.0", 63 | "eslint-plugin-promise": "^3.4.0", 64 | "eslint-plugin-standard": "^3.0.1", 65 | "eventsource-polyfill": "^0.9.6", 66 | "express": "^4.14.1", 67 | "extract-text-webpack-plugin": "^3.0.0", 68 | "file-loader": "^1.1.4", 69 | "friendly-errors-webpack-plugin": "^1.6.1", 70 | "html-webpack-plugin": "^2.30.1", 71 | "http-proxy-middleware": "^0.17.3", 72 | "jest": "^21.2.1", 73 | "jest-serializer-vue": "^0.3.0", 74 | "mock-local-storage": "^1.0.5", 75 | "nodemon": "1.12.1", 76 | "opn": "^5.1.0", 77 | "optimize-css-assets-webpack-plugin": "^3.2.0", 78 | "ora": "^1.2.0", 79 | "portfinder": "^1.0.13", 80 | "rimraf": "^2.6.0", 81 | "semver": "^5.3.0", 82 | "shelljs": "^0.7.6", 83 | "url-loader": "^0.5.8", 84 | "vue-jest": "^2.3.0", 85 | "vue-loader": "^13.3.0", 86 | "vue-server-renderer": "^2.5.16", 87 | "vue-style-loader": "^3.0.1", 88 | "vue-template-compiler": "2.5.2", 89 | "webpack": "^3.6.0", 90 | "webpack-bundle-analyzer": "^2.9.0", 91 | "webpack-dev-middleware": "^1.12.0", 92 | "webpack-hot-middleware": "^2.18.2", 93 | "webpack-merge": "^4.1.0" 94 | }, 95 | "engines": { 96 | "node": ">= 4.0.0", 97 | "npm": ">= 3.0.0" 98 | }, 99 | "jest": { 100 | "verbose": true, 101 | "moduleFileExtensions": [ 102 | "js" 103 | ], 104 | "transform": { 105 | ".*\\.(vue)$": "/node_modules/vue-jest", 106 | "^.+\\.js$": "/node_modules/babel-jest" 107 | }, 108 | "coverageDirectory": "coverage", 109 | "mapCoverage": true, 110 | "collectCoverage": true, 111 | "coverageReporters": [ 112 | "lcov", 113 | "text" 114 | ], 115 | "moduleNameMapper": { 116 | "@/(.*)$": "/src/$1" 117 | }, 118 | "snapshotSerializers": [ 119 | "/node_modules/jest-serializer-vue" 120 | ], 121 | "setupTestFrameworkScriptFile": "mock-local-storage", 122 | "collectCoverageFrom": [ 123 | "!env.js", 124 | "server/**/*.js", 125 | "app.js" 126 | ] 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /pm2.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "vue-koa-demo", 5 | "script": "server-entry.js", 6 | "watch": ["server","app.js","public",".env"], 7 | "ignore_watch": ["node_modules"], 8 | "log_date_format": "YYYY-MM=DD HH:mm:ss" 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /server-entry.js: -------------------------------------------------------------------------------- 1 | require('babel-core/register')({ 2 | 'presets': [ 3 | ['env', { 4 | 'targets': { 5 | 'node': true 6 | } 7 | }] 8 | ] 9 | }) 10 | require('./app.js') 11 | -------------------------------------------------------------------------------- /server/config/db.js: -------------------------------------------------------------------------------- 1 | import '../../env' 2 | import Sequelize from 'sequelize' 3 | 4 | const Todolist = new Sequelize(`mysql://${process.env.DB_USER}:${process.env.DB_PASSWORD}@${process.env.DB_URL || 'localhost'}/todolist`, { 5 | define: { 6 | timestamps: false // 取消Sequelzie自动给数据表加入时间戳(createdAt以及updatedAt) 7 | } 8 | }) 9 | 10 | export default { 11 | Todolist // 将Todolist暴露出接口方便Model调用 12 | } 13 | -------------------------------------------------------------------------------- /server/controllers/todolist.js: -------------------------------------------------------------------------------- 1 | import todolist from '../models/todolist.js' 2 | 3 | const getTodolist = async function (ctx) { 4 | const id = ctx.params.id // 获取url里传过来的参数里的id 5 | const result = await todolist.getTodolistById(id) // 通过await “同步”地返回查询结果 6 | ctx.body = { 7 | success: true, 8 | result // 将请求的结果放到response的body里返回 9 | } 10 | } 11 | 12 | const createTodolist = async function (ctx) { 13 | const data = ctx.request.body 14 | const success = await todolist.createTodolist(data) 15 | ctx.body = { 16 | success 17 | } 18 | } 19 | 20 | const removeTodolist = async function (ctx) { 21 | const id = ctx.params.id 22 | const userId = ctx.params.userId 23 | const success = await todolist.removeTodolist(id, userId) 24 | 25 | ctx.body = { 26 | success 27 | } 28 | } 29 | 30 | const updateTodolist = async function (ctx) { 31 | const id = ctx.params.id 32 | const userId = ctx.params.userId 33 | let status = ctx.params.status 34 | status === '0' ? status = true : status = false// 状态反转(更新) 35 | 36 | const success = await todolist.updateTodolist(id, userId, status) 37 | 38 | ctx.body = { 39 | success 40 | } 41 | } 42 | 43 | export default { 44 | getTodolist, 45 | createTodolist, 46 | removeTodolist, 47 | updateTodolist 48 | } 49 | -------------------------------------------------------------------------------- /server/controllers/user.js: -------------------------------------------------------------------------------- 1 | import user from '../models/user.js' 2 | import jwt from 'jsonwebtoken' 3 | import bcrypt from 'bcryptjs' 4 | 5 | const getUserInfo = async function (ctx) { 6 | const id = ctx.params.id // 获取url里传过来的参数里的id 7 | const result = await user.getUserById(id) // 通过await “同步”地返回查询结果 8 | ctx.body = result // 将请求的结果放到response的body里返回 9 | } 10 | 11 | const postUserAuth = async function (ctx) { 12 | const data = ctx.request.body // post过来的数据存在request.body里 13 | const userInfo = await user.getUserByName(data.name) 14 | if (userInfo != null) { // 如果查无此用户会返回null 15 | if (!bcrypt.compareSync(data.password, userInfo.password)) { 16 | ctx.body = { 17 | success: false, // success标志位是方便前端判断返回是正确与否 18 | info: '密码错误!' 19 | } 20 | } else { 21 | const userToken = { 22 | name: userInfo.user_name, 23 | id: userInfo.id 24 | } 25 | const secret = 'vue-koa-demo' // 指定密钥 26 | const token = jwt.sign(userToken, secret) // 签发token 27 | ctx.body = { 28 | success: true, 29 | token: token // 返回token 30 | } 31 | } 32 | } else { 33 | ctx.body = { 34 | success: false, 35 | info: '用户不存在!' // 如果用户不存在返回用户不存在 36 | } 37 | } 38 | } 39 | 40 | export default { 41 | getUserInfo, 42 | postUserAuth 43 | } 44 | -------------------------------------------------------------------------------- /server/models/todolist.js: -------------------------------------------------------------------------------- 1 | import db from '../config/db.js' // 引入todolist的表结构 2 | const todoModel = '../schema/list.js' 3 | const TodolistDb = db.Todolist // 引入数据库 4 | 5 | const Todolist = TodolistDb.import(todoModel) 6 | 7 | const getTodolistById = async function (id) { 8 | const todolist = await Todolist.findAll({ // 查找全部的todolist 9 | where: { 10 | user_id: id 11 | }, 12 | attributes: ['id', 'content', 'status'] // 只需返回这三个字段的结果即可 13 | }) 14 | 15 | return todolist // 返回数据 16 | } 17 | 18 | const createTodolist = async function (data) { 19 | await Todolist.create({ 20 | user_id: data.id, 21 | content: data.content, 22 | status: data.status 23 | }) 24 | return true 25 | } 26 | 27 | const removeTodolist = async function (id, userId) { 28 | const result = await Todolist.destroy({ 29 | where: { 30 | id, 31 | user_id: userId 32 | } 33 | }) 34 | return result === 1 // 如果成功删除了记录,返回1,否则返回0 35 | } 36 | 37 | const updateTodolist = async function (id, userId, status) { 38 | const result = await Todolist.update( 39 | { 40 | status 41 | }, 42 | { 43 | where: { 44 | id, 45 | user_id: userId 46 | } 47 | } 48 | ) 49 | return result[0] === 1 // 返回一个数组,更新成功的条目为1否则为0。由于只更新一个条目,所以只返回一个元素 50 | } 51 | 52 | export default { 53 | getTodolistById, 54 | createTodolist, 55 | removeTodolist, 56 | updateTodolist 57 | } 58 | -------------------------------------------------------------------------------- /server/models/user.js: -------------------------------------------------------------------------------- 1 | import db from '../config/db.js' // 引入user的表结构 2 | const userModel = '../schema/user.js' 3 | const TodolistDb = db.Todolist // 引入数据库 4 | 5 | const User = TodolistDb.import(userModel) // 用sequelize的import方法引入表结构,实例化了User。 6 | 7 | const getUserById = async function (id) { // 注意是async function 而不是function。对于需要等待promise结果的函数都需要async await。 8 | const userInfo = await User.findOne({ // 用await控制异步操作,将返回的Promise对象里的数据返回出来。也就实现了“同步”的写法获取异步IO操作的数据 9 | where: { 10 | id: id 11 | } 12 | }) 13 | 14 | return userInfo // 返回数据 15 | } 16 | 17 | const getUserByName = async function (name) { 18 | const userInfo = await User.findOne({ 19 | where: { 20 | user_name: name 21 | } 22 | }) 23 | 24 | return userInfo 25 | } 26 | 27 | export default { 28 | getUserById, // 导出getUserById的方法,将会在controller里调用 29 | getUserByName 30 | } 31 | -------------------------------------------------------------------------------- /server/routes/api.js: -------------------------------------------------------------------------------- 1 | import api from '../controllers/todolist.js' 2 | import koaRouter from 'koa-router' 3 | const router = koaRouter() 4 | 5 | router.get('/todolist/:id', api.getTodolist) 6 | router.post('/todolist', api.createTodolist) 7 | router.delete('/todolist/:userId/:id', api.removeTodolist) 8 | router.put('/todolist/:userId/:id/:status', api.updateTodolist) 9 | 10 | export default router 11 | -------------------------------------------------------------------------------- /server/routes/auth.js: -------------------------------------------------------------------------------- 1 | import auth from '../controllers/user.js' 2 | import koaRouter from 'koa-router' 3 | const router = koaRouter() 4 | 5 | router.get('/user/:id', auth.getUserInfo) // 定义url的参数是id 6 | router.post('/user', auth.postUserAuth) 7 | 8 | export default router 9 | -------------------------------------------------------------------------------- /server/schema/list.js: -------------------------------------------------------------------------------- 1 | /* jshint indent: 2 */ 2 | 3 | export default function (sequelize, DataTypes) { 4 | return sequelize.define('list', { 5 | id: { 6 | type: DataTypes.INTEGER(11), 7 | allowNull: false, 8 | primaryKey: true, 9 | autoIncrement: true 10 | }, 11 | user_id: { 12 | type: DataTypes.INTEGER(11), 13 | allowNull: false 14 | }, 15 | content: { 16 | type: DataTypes.CHAR(255), 17 | allowNull: false 18 | }, 19 | status: { 20 | type: DataTypes.INTEGER(1), 21 | allowNull: false 22 | } 23 | }, { 24 | tableName: 'list' 25 | }) 26 | }; 27 | -------------------------------------------------------------------------------- /server/schema/user.js: -------------------------------------------------------------------------------- 1 | /* jshint indent: 2 */ 2 | 3 | export default function (sequelize, DataTypes) { 4 | return sequelize.define('user', { 5 | id: { 6 | type: DataTypes.INTEGER(11), // 字段类型 7 | allowNull: false, // 是否允许为NULL 8 | primaryKey: true, // 主键 9 | autoIncrement: true // 是否自增 10 | }, 11 | user_name: { 12 | type: DataTypes.CHAR(50), 13 | allowNull: false 14 | }, 15 | password: { 16 | type: DataTypes.CHAR(128), 17 | allowNull: false 18 | } 19 | }, { 20 | tableName: 'user' 21 | }) 22 | }; 23 | -------------------------------------------------------------------------------- /sql/list.sql: -------------------------------------------------------------------------------- 1 | -- -------------------------------------------------------- 2 | -- 主机: 127.0.0.1 3 | -- 服务器版本: 5.7.15-log - MySQL Community Server (GPL) 4 | -- 服务器操作系统: Win64 5 | -- HeidiSQL 版本: 9.4.0.5125 6 | -- -------------------------------------------------------- 7 | 8 | /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; 9 | /*!40101 SET NAMES utf8 */; 10 | /*!50503 SET NAMES utf8mb4 */; 11 | /*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; 12 | /*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; 13 | 14 | 15 | -- 导出 todolist 的数据库结构 16 | CREATE DATABASE IF NOT EXISTS `todolist` /*!40100 DEFAULT CHARACTER SET utf8 */; 17 | USE `todolist`; 18 | 19 | -- 导出 表 todolist.list 结构 20 | CREATE TABLE IF NOT EXISTS `list` ( 21 | `id` int(11) NOT NULL AUTO_INCREMENT, 22 | `user_id` int(11) NOT NULL, 23 | `content` char(255) NOT NULL, 24 | `status` tinyint(1) unsigned zerofill NOT NULL, 25 | PRIMARY KEY (`id`) 26 | ) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8; 27 | 28 | -- 正在导出表 todolist.list 的数据:~0 rows (大约) 29 | DELETE FROM `list`; 30 | /*!40000 ALTER TABLE `list` DISABLE KEYS */; 31 | /*!40000 ALTER TABLE `list` ENABLE KEYS */; 32 | 33 | /*!40101 SET SQL_MODE=IFNULL(@OLD_SQL_MODE, '') */; 34 | /*!40014 SET FOREIGN_KEY_CHECKS=IF(@OLD_FOREIGN_KEY_CHECKS IS NULL, 1, @OLD_FOREIGN_KEY_CHECKS) */; 35 | /*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; 36 | -------------------------------------------------------------------------------- /sql/user.sql: -------------------------------------------------------------------------------- 1 | -- -------------------------------------------------------- 2 | -- 主机: 127.0.0.1 3 | -- 服务器版本: 5.7.15-log - MySQL Community Server (GPL) 4 | -- 服务器操作系统: Win64 5 | -- HeidiSQL 版本: 9.4.0.5125 6 | -- -------------------------------------------------------- 7 | 8 | /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; 9 | /*!40101 SET NAMES utf8 */; 10 | /*!50503 SET NAMES utf8mb4 */; 11 | /*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; 12 | /*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; 13 | 14 | 15 | -- 导出 todolist 的数据库结构 16 | CREATE DATABASE IF NOT EXISTS `todolist` /*!40100 DEFAULT CHARACTER SET utf8 */; 17 | USE `todolist`; 18 | 19 | -- 导出 表 todolist.user 结构 20 | CREATE TABLE IF NOT EXISTS `user` ( 21 | `id` int(11) NOT NULL AUTO_INCREMENT, 22 | `user_name` char(50) NOT NULL, 23 | `password` char(128) NOT NULL, 24 | PRIMARY KEY (`id`) 25 | ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8; 26 | 27 | -- 正在导出表 todolist.user 的数据:~0 rows (大约) 28 | DELETE FROM `user`; 29 | /*!40000 ALTER TABLE `user` DISABLE KEYS */; 30 | INSERT INTO `user` (`user_name`, `password`) VALUES 31 | ('molunerfinn', '$2a$10$x3f0Y2SNAmyAfqhKVAV.7uE7RHs3FDGuSYw.LlZhOFoyK7cjfZ.Q6'); 32 | INSERT INTO `user` (`user_name`, `password`) VALUES 33 | ('admin', '$2a$10$x3f0Y2SNAmyAfqhKVAV.7uE7RHs3FDGuSYw.LlZhOFoyK7cjfZ.Q6'); 34 | /*!40000 ALTER TABLE `user` ENABLE KEYS */; 35 | 36 | /*!40101 SET SQL_MODE=IFNULL(@OLD_SQL_MODE, '') */; 37 | /*!40014 SET FOREIGN_KEY_CHECKS=IF(@OLD_FOREIGN_KEY_CHECKS IS NULL, 1, @OLD_FOREIGN_KEY_CHECKS) */; 38 | /*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; 39 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | 14 | 24 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Molunerfinn/vue-koa-demo/03a2d602e3a79ad7732e93ec2cf8882f4d94089a/src/assets/logo.png -------------------------------------------------------------------------------- /src/components/Hello.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 33 | 34 | 35 | 54 | -------------------------------------------------------------------------------- /src/components/Login.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 62 | 63 | 74 | -------------------------------------------------------------------------------- /src/components/TodoList.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 181 | 182 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | // The Vue build version to load with the `import` command 2 | // (runtime-only or standalone) has been set in webpack.base.conf with an alias. 3 | import Vue from 'vue' 4 | import App from './App' 5 | import ElementUI from 'element-ui' 6 | import 'element-ui/lib/theme-default/index.css' 7 | import VueRouter from 'vue-router' 8 | import axios from 'axios' 9 | import Login from './components/Login' 10 | import TodoList from './components/TodoList' 11 | 12 | Vue.prototype.$http = axios // 类似于vue-resource的调用方法 13 | 14 | Vue.use(ElementUI) 15 | Vue.use(VueRouter) 16 | 17 | const router = new VueRouter({ 18 | mode: 'history', 19 | base: __dirname, 20 | routes: [ 21 | { 22 | path: '/', 23 | component: Login 24 | }, 25 | { 26 | path: '/todolist', 27 | component: TodoList 28 | }, 29 | { 30 | path: '*', 31 | redirect: '/' 32 | } 33 | ] 34 | }) 35 | 36 | router.beforeEach((to, from, next) => { 37 | const token = sessionStorage.getItem('demo-token') 38 | if (to.path === '/') { // 如果是跳转到登录页的 39 | if (token !== 'null' && token !== null) { 40 | next('/todolist') // 如果有token就转向todolist不返回登录页 41 | } 42 | next() // 否则跳转回登录页 43 | } else { 44 | if (token !== 'null' && token !== null) { 45 | Vue.prototype.$http.defaults.headers.common['Authorization'] = 'Bearer ' + token // 注意Bearer后有个空格 46 | next() // 如果有token就正常转向 47 | } else { 48 | next('/') // 否则跳转回登录页 49 | } 50 | } 51 | }) 52 | 53 | /* eslint-disable no-new */ 54 | new Vue({ 55 | router: router, 56 | render: h => h(App) 57 | }).$mount('#app') 58 | -------------------------------------------------------------------------------- /static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Molunerfinn/vue-koa-demo/03a2d602e3a79ad7732e93ec2cf8882f4d94089a/static/.gitkeep -------------------------------------------------------------------------------- /test/client/__snapshots__/login.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Should have the expected html structure 1`] = ` 4 |
7 |
10 | 13 | 14 | 欢迎登录 15 | 16 | 17 | 18 |
21 |
24 | 25 | 26 | 32 | 33 | 34 |
35 | 36 |
39 | 40 | 41 | 47 | 48 | 49 |
50 | 51 | 61 |
62 |
63 |
64 | `; 65 | -------------------------------------------------------------------------------- /test/client/__snapshots__/todolist.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Should have the expected html structure 1`] = ` 4 |
7 |
10 | 11 | 12 | 欢迎:Molunerfinn!你的待办事项是: 13 | 14 | 15 | 16 |
19 | 20 | 21 | 27 | 28 | 29 |
30 | 31 |
34 |
37 |
40 |
43 |
46 |
50 |
53 | 待办事项 54 |
55 |
58 | 已完成事项 59 |
60 |
61 |
62 |
65 |
68 |
71 |
72 |
75 |
76 |
77 |
78 |
81 |
84 |
87 |
88 | 89 | 暂无待办事项 90 | 91 |
92 |
93 |
94 | 95 | 105 |
106 |
107 |
108 |
109 | `; 110 | -------------------------------------------------------------------------------- /test/client/login.spec.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import elementUI from 'element-ui' 3 | import { mount } from '@vue/test-utils' 4 | import Login from '../../src/components/Login.vue' 5 | import axios from 'axios' 6 | 7 | Vue.use(elementUI) 8 | 9 | jest.mock('axios', () => ({ 10 | post: jest.fn() 11 | .mockImplementationOnce(() => Promise.resolve({ 12 | data: { 13 | success: false, 14 | info: '用户不存在!' 15 | } 16 | })) 17 | .mockImplementationOnce(() => Promise.resolve({ 18 | data: { 19 | success: false, 20 | info: '密码错误!' 21 | } 22 | })) 23 | .mockImplementationOnce(() => Promise.resolve({ 24 | data: { 25 | success: true, 26 | token: 'xxx' 27 | } 28 | })) 29 | })) 30 | 31 | Vue.prototype.$http = axios 32 | 33 | let wrapper 34 | 35 | const $router = { 36 | push: jest.fn() 37 | } 38 | 39 | beforeEach(() => { 40 | wrapper = mount(Login, { 41 | mocks: { 42 | $router 43 | } 44 | }) 45 | }) 46 | 47 | test('Should have two input & one button', () => { 48 | const inputs = wrapper.findAll('.el-input') 49 | const loginButton = wrapper.contains('.el-button') 50 | expect(inputs.length).toBe(2) 51 | expect(loginButton).toBeTruthy() 52 | }) 53 | 54 | test('Should have the expected html structure', () => { 55 | expect(wrapper.element).toMatchSnapshot() 56 | }) 57 | 58 | test('loginToDo should be called after clicking the button', () => { 59 | const stub = jest.fn() 60 | wrapper.setMethods({ loginToDo: stub }) 61 | wrapper.find('.el-button').trigger('click') 62 | expect(stub).toBeCalled() 63 | }) 64 | 65 | test('Failed to login if not typing the username & password', async () => { 66 | const result = await wrapper.vm.loginToDo() 67 | expect(result.data.success).toBe(false) 68 | expect(result.data.info).toBe('用户不存在!') 69 | }) 70 | 71 | test('Failed to login if not typing the correct password', async () => { 72 | wrapper.setData({ 73 | account: 'molunerfinn', 74 | password: '1234' 75 | }) 76 | const result = await wrapper.vm.loginToDo() 77 | expect(result.data.success).toBe(false) 78 | expect(result.data.info).toBe('密码错误!') 79 | }) 80 | 81 | test('Succeeded to login if typing the correct account & password', async () => { 82 | wrapper.setData({ 83 | account: 'molunerfinn', 84 | password: '123' 85 | }) 86 | const result = await wrapper.vm.loginToDo() 87 | expect(result.data.success).toBe(true) 88 | }) 89 | -------------------------------------------------------------------------------- /test/client/todolist.spec.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import elementUI from 'element-ui' 3 | import { mount } from '@vue/test-utils' 4 | import Todolist from '../../src/components/Todolist.vue' 5 | import axios from 'axios' 6 | 7 | Vue.use(elementUI) 8 | 9 | jest.mock('axios', () => ({ 10 | post: jest.fn() 11 | // for test 2 12 | .mockImplementationOnce(() => Promise.resolve({ 13 | status: 200 14 | })) 15 | // for test 3 16 | .mockImplementationOnce(() => Promise.resolve({ 17 | status: 200 18 | })), 19 | get: jest.fn() 20 | // for test 1 21 | .mockImplementationOnce(() => Promise.resolve({ 22 | status: 200, 23 | data: { 24 | result: [] 25 | } 26 | })) 27 | // for test 2 28 | .mockImplementationOnce(() => Promise.resolve({ 29 | status: 200, 30 | data: { 31 | result: [] 32 | } 33 | })) 34 | // for test 3 35 | .mockImplementationOnce(() => Promise.resolve({ 36 | status: 200, 37 | data: { 38 | result: [] 39 | } 40 | })) 41 | // for test 3 42 | .mockImplementationOnce(() => Promise.resolve({ 43 | status: 200, 44 | data: { 45 | result: [ 46 | { 47 | status: '0', 48 | content: 'Test', 49 | id: 1 50 | } 51 | ] 52 | } 53 | })) 54 | // for test 4 55 | .mockImplementationOnce(() => Promise.resolve({ 56 | status: 200, 57 | data: { 58 | result: [ 59 | { 60 | status: 1, 61 | content: 'Test1', 62 | id: 1 63 | } 64 | ] 65 | } 66 | })) 67 | // for test 5 68 | .mockImplementationOnce(() => Promise.resolve({ 69 | status: 200, 70 | data: { 71 | result: [ 72 | { 73 | status: '0', 74 | content: 'Test1', 75 | id: 1 76 | } 77 | ] 78 | } 79 | })) 80 | // for test 6 81 | .mockImplementationOnce(() => Promise.resolve({ 82 | status: 200, 83 | data: { 84 | result: [ 85 | { 86 | status: '0', 87 | content: 'Test1', 88 | id: 1 89 | } 90 | ] 91 | } 92 | })) 93 | // for test 7 94 | .mockImplementationOnce(() => Promise.resolve({ status: 200, 95 | data: { 96 | result: [] 97 | } 98 | })), 99 | put: jest.fn() 100 | // for test 4 101 | .mockImplementationOnce(() => Promise.resolve({ 102 | status: 200 103 | })) 104 | // for test 6 105 | .mockImplementationOnce(() => Promise.resolve({ 106 | status: 200 107 | })), 108 | delete: jest.fn() 109 | // for test 5 110 | .mockImplementationOnce(() => Promise.resolve({ 111 | status: 200 112 | })) 113 | })) 114 | 115 | Vue.prototype.$http = axios 116 | 117 | let wrapper 118 | 119 | beforeEach(() => { 120 | wrapper = mount(Todolist) 121 | wrapper.setData({ 122 | name: 'Molunerfinn', 123 | id: 2 124 | }) 125 | }) 126 | 127 | // test 1 128 | test('Should get the right username & id', () => { 129 | expect(wrapper.vm.name).toBe('Molunerfinn') 130 | expect(wrapper.vm.id).toBe(2) 131 | }) 132 | 133 | // test 2 134 | test('Should trigger addTodos when typing the enter key', () => { 135 | const stub = jest.fn() 136 | wrapper.setMethods({ 137 | addTodos: stub 138 | }) 139 | const input = wrapper.find('.el-input') 140 | input.trigger('keyup.enter') 141 | expect(stub).toBeCalled() 142 | }) 143 | 144 | // test 3 145 | test('Should add a todo if handle in the right way', async () => { 146 | wrapper.setData({ 147 | todos: 'Test', 148 | stauts: '0', 149 | id: 1 150 | }) 151 | 152 | await wrapper.vm.addTodos() 153 | await wrapper.update() 154 | expect(wrapper.vm.list).toEqual([ 155 | { 156 | status: '0', 157 | content: 'Test', 158 | id: 1 159 | } 160 | ]) 161 | }) 162 | 163 | // test 4 164 | test('Should restore a todo if click the restore button', async () => { 165 | wrapper.setData({ 166 | activeName: 'second' // 切换到第二个选项卡 167 | }) 168 | wrapper.setMethods({ 169 | getTodolist: jest.fn(() => { 170 | wrapper.setData({ 171 | list: [ 172 | { 173 | status: '0', 174 | content: 'Test1', 175 | id: 1 176 | } 177 | ] 178 | }) 179 | }) 180 | }) 181 | expect(wrapper.contains('.finished')).toBeTruthy() 182 | wrapper.find('.restore-item').trigger('click') 183 | wrapper.find('.el-tabs__item').trigger('click') 184 | await wrapper.update() 185 | expect(wrapper.contains('.no-finished')).toBeTruthy() 186 | }) 187 | 188 | // test 5 189 | test('Should remove a todo if click the remove button', async () => { 190 | wrapper.setMethods({ 191 | getTodolist: jest.fn(() => { 192 | wrapper.setData({ 193 | list: [] 194 | }) 195 | }) 196 | }) 197 | expect(wrapper.contains('.no-finished')).toBeTruthy() 198 | wrapper.find('.remove-item').trigger('click') 199 | await wrapper.update() 200 | expect(wrapper.contains('.no-finished')).not.toBeTruthy() 201 | }) 202 | 203 | // test 6 204 | test('Should finish a todo if click the finish button', async () => { 205 | wrapper.setMethods({ 206 | getTodolist: jest.fn(() => { 207 | wrapper.setData({ 208 | list: [ 209 | { 210 | status: 1, 211 | content: 'Test1', 212 | id: 1 213 | } 214 | ] 215 | }) 216 | }) 217 | }) 218 | expect(wrapper.contains('.no-finished')).toBeTruthy() 219 | wrapper.find('.finish-item').trigger('click') 220 | await wrapper.update() 221 | expect(wrapper.contains('.no-finished')).not.toBeTruthy() 222 | }) 223 | 224 | // test 7 225 | test('Should have the expected html structure', () => { 226 | expect(wrapper.element).toMatchSnapshot() 227 | }) 228 | -------------------------------------------------------------------------------- /test/sever/todolist.spec.js: -------------------------------------------------------------------------------- 1 | import server from '../../app.js' 2 | import request from 'supertest' 3 | 4 | afterEach(() => { 5 | server.close() 6 | }) 7 | 8 | let todoId = null 9 | 10 | const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoibW9sdW5lcmZpbm4iLCJpZCI6MiwiaWF0IjoxNTA5ODAwNTg2fQ.JHHqSDNUgg9YAFGWtD0m3mYc9-XR3Gpw9gkZQXPSavM' 11 | 12 | test('Getting todolist should return 401 if not set the JWT', async () => { 13 | const response = await request(server) 14 | .get('/api/todolist/2') 15 | expect(response.status).toBe(401) 16 | }) 17 | 18 | test('Get todolist -> [] if the user is not exist', async () => { 19 | const response = await request(server) 20 | .get('/api/todolist/3') 21 | .set('Authorization', 'Bearer ' + token) 22 | expect(response.body.result).toEqual([]) 23 | }) 24 | 25 | test('Post todolist successfully if set the JWT & correct user', async () => { 26 | const response = await request(server) 27 | .post('/api/todolist') 28 | .send({ 29 | status: false, 30 | content: '来自测试', 31 | id: 2 32 | }) 33 | .set('Authorization', 'Bearer ' + token) 34 | expect(response.body.success).toBe(true) 35 | }) 36 | 37 | test('Get todolist successfully if set the JWT & correct user', async () => { 38 | const response = await request(server) 39 | .get('/api/todolist/2') 40 | .set('Authorization', 'Bearer ' + token) 41 | response.body.result.forEach((item, index) => { 42 | if (item.content === '来自测试') todoId = item.id 43 | }) 44 | expect(response.body.success).toBe(true) 45 | }) 46 | 47 | test('Failed to update todolist if not update the status of todolist', async () => { 48 | const response = await request(server) 49 | .put(`/api/todolist/2/${todoId}/1`) 50 | .set('Authorization', 'Bearer ' + token) 51 | expect(response.body.success).toBe(false) 52 | }) 53 | 54 | test('Update todolist successfully if set the JWT & correct todoId', async () => { 55 | const response = await request(server) 56 | .put(`/api/todolist/2/${todoId}/0`) 57 | .set('Authorization', 'Bearer ' + token) 58 | expect(response.body.success).toBe(true) 59 | }) 60 | 61 | test('Remove todolist successfully if set the JWT & correct todoId', async () => { 62 | const response = await request(server) 63 | .delete(`/api/todolist/2/${todoId}`) 64 | .set('Authorization', 'Bearer ' + token) 65 | expect(response.body.success).toBe(true) 66 | }) 67 | 68 | test('Failed to post todolist if not give the params', async () => { 69 | const response = await request(server) 70 | .post('/api/todolist') 71 | .set('Authorization', 'Bearer ' + token) 72 | expect(response.status).toBe(500) 73 | }) 74 | -------------------------------------------------------------------------------- /test/sever/user.spec.js: -------------------------------------------------------------------------------- 1 | import server from '../../app.js' 2 | import request from 'supertest' 3 | 4 | afterEach(() => { 5 | server.close() 6 | }) 7 | 8 | test('Failed to login if typing Molunerfinn & 1234', async () => { 9 | const response = await request(server) 10 | .post('/auth/user') 11 | .send({ 12 | name: 'Molunerfinn', 13 | password: '1234' 14 | }) 15 | expect(response.body.success).toBe(false) 16 | }) 17 | 18 | test('Successed to login if typing Molunerfinn & 123', async () => { 19 | const response = await request(server) 20 | .post('/auth/user') 21 | .send({ 22 | name: 'Molunerfinn', 23 | password: '123' 24 | }) 25 | expect(response.body.success).toBe(true) 26 | }) 27 | 28 | test('Failed to login if typing MARK & 123', async () => { 29 | const response = await request(server) 30 | .post('/auth/user') 31 | .send({ 32 | name: 'MARK', 33 | password: '123' 34 | }) 35 | expect(response.body.info).toBe('用户不存在!') 36 | }) 37 | 38 | test('Getting the user info is null if the url is /auth/user/10', async () => { 39 | const response = await request(server) 40 | .get('/auth/user/10') 41 | expect(response.body).toEqual({}) 42 | }) 43 | 44 | test('Getting user info successfully if the url is /auth/user/2', async () => { 45 | const response = await request(server) 46 | .get('/auth/user/2') 47 | expect(response.body.user_name).toBe('molunerfinn') 48 | }) 49 | --------------------------------------------------------------------------------