簡(jiǎn)介
參考博客: 全棧開發(fā)實(shí)戰(zhàn):用Vue2+Koa1開發(fā)完整的前后端項(xiàng)目(更新Koa2)
前置技能: 具備Vue和Koa基礎(chǔ)知識(shí),了解Javascript基礎(chǔ)語法(和混亂),了解Nodejs(npm)常用操作
本文以新手視角,從零開始逐步構(gòu)建一個(gè)Vue+Koa2的web應(yīng)用,項(xiàng)目主要包括以下內(nèi)容:
- 基于
Vue組件構(gòu)建單頁面應(yīng)用,包含登錄、用戶、管理員視圖,由Vue Router控制頁面跳轉(zhuǎn) - 使用
Koa及相關(guān)插件提供API接口 -
Sequelize數(shù)據(jù)庫訪問 - 基于
json web token的登錄驗(yàn)證 - 配置本地運(yùn)行、打包docker鏡像部署
為了簡(jiǎn)化構(gòu)建(因?yàn)椴?,前端部分使用了Vue Cli,Cli的本質(zhì)依舊是使用Webpack打包,但提供了一系列針對(duì)Vue的配置,使構(gòu)建過程開箱即用;另外Login.vue使用了“參考博客”的源碼。項(xiàng)目在一些階段會(huì)打tag,并附上源碼地址
由于以前從未接觸過nodejs后臺(tái)開發(fā),本文可能存在一些局限和錯(cuò)誤,歡迎指正
創(chuàng)建項(xiàng)目
安裝Nodejs,建議更換淘寶源,鏡像地址,指令:
npm config set registry https://registry.npm.taobao.org
安裝Vue和Vue Cli
npm install vue
npm install -g @vue/cli
# 若使用vue serve和vue build命令需要安裝全局?jǐn)U展
npm install -g @vue/cli-service-global
創(chuàng)建項(xiàng)目
vue create vue-koa
新建server目錄,作為koa代碼目錄,在目錄下創(chuàng)建app.js作為入口文件,整體目錄結(jié)構(gòu)如下:
.
├── README.md
├── babel.config.js
├── node_modules
│ └── ...
├── package-lock.json
├── package.json
├── public
│ ├── favicon.ico
│ └── index.html
├── server # 后端源碼目錄
│ └── app.js # 后端入口
└── src # 前端源碼目錄
├── App.vue # vue根組件,main.js中將該組件掛載到index.html中
├── assets
│ └── logo.png
├── components
│ └── HelloWorld.vue
└── main.js # 前端入口
本節(jié)源碼:GitHub Tag V0.0
接口定義
項(xiàng)目使用jwt token做登錄驗(yàn)證,用戶登錄點(diǎn)擊登錄時(shí),前端調(diào)用獲取token接口,使用用戶名和密碼認(rèn)證,接口返回經(jīng)jwt加密的token;隨后,前端發(fā)送所有請(qǐng)求均攜帶該token作為已登錄憑證
按照標(biāo)準(zhǔn),token類型為Bearer,對(duì)需要權(quán)限認(rèn)證的接口,request header設(shè)置字段{Authorization: 'Bearer <token>'};對(duì)于認(rèn)證失敗的請(qǐng)求,服務(wù)器應(yīng)當(dāng)返回401,response header設(shè)置字段{'WWW-Authenticate': 'Bearer'}
后端服務(wù)運(yùn)行在3000端口,提供兩個(gè)接口:
獲取token
請(qǐng)求參數(shù)
Method: POST
Api: /api/auth
Body: {username: un, password: pw}
返回值
{
"code": 2000,
"token": "eyqk"
}
獲取當(dāng)前用戶信息
接口需要攜帶token,請(qǐng)求參數(shù)
Method: GET
Api: /api/user
返回值
{
"username": "艾廣威",
"roles": [
"user"
],
"iat": 1567656871,
"exp": 1567660471
}
前端頁面構(gòu)建
這一節(jié),將創(chuàng)建一個(gè)具有兩級(jí)導(dǎo)航結(jié)構(gòu)的頁面,頁面頂部導(dǎo)航欄為一級(jí)導(dǎo)航,側(cè)邊導(dǎo)航菜單為二級(jí)導(dǎo)航。點(diǎn)擊頂部導(dǎo)航的菜單項(xiàng),切換側(cè)邊導(dǎo)航菜單;點(diǎn)擊側(cè)邊導(dǎo)航菜單,切換頁面主體內(nèi)容
項(xiàng)目使用Vue Cli構(gòu)建,在根目錄下創(chuàng)建vue.config.js,該文件會(huì)自動(dòng)被Vue Cli識(shí)別。由于沒有對(duì)babel做額外調(diào)整,可將babel.config.js文件刪除
引入U(xiǎn)I等組件
引入element ui組件庫,簡(jiǎn)化頁面排版
安裝方式:
npm i element-ui -S
這里使用全局使用方式,實(shí)際項(xiàng)目中建議按需引入,參考文檔
/* /src/main.js */
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
Vue.use(ElementUI);
引入Vuejs Logger, 方便打印log
安裝方式:
npm install vuejs-logger --save-exact
/* /src/main.js */
import vueLogger from 'vuejs-logger'
Vue.use(vueLogger)
引入Vue Router 建立二級(jí)路由
安裝Vue Router,指令:
npm install vue-router
在/src下建立如下目錄結(jié)構(gòu):
.
├── App.vue # Vue根組件,包含頂部導(dǎo)航欄和一級(jí) router-view 標(biāo)簽
├── assets
│ └── logo.png
├── components
│ ├── pages
│ │ ├── Admin.vue # 管理員視圖,包含管理員側(cè)邊導(dǎo)航菜單元數(shù)據(jù)
│ │ ├── Login.vue
│ │ ├── Logout.vue
│ │ ├── User.vue # 用戶視圖
│ │ ├── admin
│ │ │ └── AC.vue
│ │ └── user
│ │ └── UC.vue
│ └── parts # 公用頁面組件
│ ├── PageFooter.vue
│ └── SideMenuContent.vue # 側(cè)邊導(dǎo)航+主內(nèi)容(二級(jí) router-view 標(biāo)簽)
├── main.js
├── router.js # 前端路由配置
└── utils.js
頁面結(jié)構(gòu)分析:
-
/App.vue:頁面的根組件,定義頂部導(dǎo)航欄(一級(jí)導(dǎo)航)、底部頁腳。中部是router-view標(biāo)簽,提供一級(jí)路由切換,如:點(diǎn)擊導(dǎo)航欄的“管理員”,導(dǎo)航到/admin,router-view標(biāo)簽渲染為/components/pages/Admin.vue -
/components/pages/Admin.vue(User.vue類似):該組件data的menus屬性是一個(gè)列表對(duì)象,定義了側(cè)邊導(dǎo)航菜單的內(nèi)容;使用SideMenuContent.vue模板渲染menus,支持二級(jí)菜單 -
/components/pages/parts/SideMenuContent.vue:左側(cè)為側(cè)邊導(dǎo)航(二級(jí)導(dǎo)航),右側(cè)包含一個(gè)二級(jí)router-view標(biāo)簽,用作二級(jí)路由渲染 -
/components/pages/AC.vue(UC.vue類似):頁面主內(nèi)容,由SideMenuContent內(nèi)的router-view渲染 - 更多細(xì)節(jié)查看本節(jié)結(jié)束給出的源碼
接下來配置Vue Router:
/* /src/router.js */
import VueRouter from 'vue-router'
import Logout from './components/pages/Logout.vue'
import Login from './components/pages/Login.vue'
import User from './components/pages/User.vue'
import UC from './components/pages/user/UC.vue'
import Admin from './components/pages/Admin.vue'
import AC from './components/pages/admin/AC.vue'
const routes = [
{
path: '/user', component: User,
children: [
{
path: 'info', component: UC
}
]
},{
path: '/admin', component: Admin,
children: [
{
path: 'info', component: AC
}
]
},{
path: '/login', component: Login
},
{
path: '/logout', component: Logout
}
];
const router = new VueRouter({
mode: 'history', //使用history模式,避免url的host和uri之間顯示很丑的"#"
routes: routes
});
export default router
在main.js中引入router:
import VueRouter from 'vue-router'
import router from './router.js'
new Vue({
router: router,
render: h => h(App),
}).$mount('#app')
由于我在
/src/components/parts/SideMenuContent.vue動(dòng)態(tài)創(chuàng)建了新的組件,需要啟用運(yùn)行時(shí)編譯
配置啟用運(yùn)行時(shí)編譯:
/* /vue.config.js */
module.exports = {
runtimeCompiler: true
}
此時(shí)運(yùn)行npm run serve,訪問8080端口,可以看到如下界面
本節(jié)源碼:GitHub Tag V0.1
后端服務(wù)搭建
安裝
koa,指令:npm install koa
后端服務(wù)需要實(shí)現(xiàn)以下功能:
- 數(shù)據(jù)庫訪問
- 一個(gè)路由組件,提供接口定義章節(jié)定義的兩個(gè)接口,以及接口的訪問權(quán)限控制
- 一組中間件,負(fù)責(zé)請(qǐng)求的預(yù)處理和后處理
后端目錄結(jié)構(gòu)如下:
.
├── app.js
├── config.js
├── const.js
├── controller
│ ├── auth-controller.js
│ └── user-controller.js
├── middlewares
│ ├── auth
│ │ ├── auth-maker.js
│ │ └── jwt-resolver.js
│ └── error-handler.js
├── router.js # 路由,從controller導(dǎo)入
├── schema # 數(shù)據(jù)庫初始化,及各表定義
│ ├── db.js
│ ├── role.js
│ └── user.js
├── secrets # 敏感信息,應(yīng)加入.gitignore
│ ├── db.json # 數(shù)據(jù)庫配置
│ └── jwt-key.txt # jwt密鑰,純文本字符串
├── service
│ └── user-service.js
└── utils.js
配置運(yùn)行環(huán)境
應(yīng)確保刪除了
/babel.config.js,否則會(huì)默認(rèn)被babel加載導(dǎo)致啟動(dòng)失敗
由于node不支持es6的import語法,這里需要使用babel做簡(jiǎn)單的語法轉(zhuǎn)換。開發(fā)環(huán)境下使用@babel/register運(yùn)行時(shí)轉(zhuǎn)換即可(生產(chǎn)環(huán)境會(huì)在之后的章節(jié)解釋),首先安裝@babel/register
npm install --save-dev @babel/core @babel/cli @babel/preset-env
npm install --save-dev babel-preset-env
npm install @babel/register --save-dev
在根目錄下添加server.dev.js文件,代碼如下:
/* /server.dev.js */
require('@babel/register')({
'presets': [
['env', {
'targets': {
'node': true
}
}]
]
})
require('./server/app.js')
在package.json中添加啟動(dòng)腳本
{
"scripts": {
"serve-koa": "node server.dev.js"
}
}
稍后就可以使用npm run serve-koa啟動(dòng)后端服務(wù)
定義通用中間件
安裝koa-json和koa-bodyparser
npm install koa-json
npm install koa-bodyparser
-
koa-json:用于自動(dòng)序列化ctx.body中的Object對(duì)象 -
koa-bodyparser:用于將ctx中的formData解析到ctx.request.body中
在main.js中引入兩個(gè)中間件,另外簡(jiǎn)單定義一個(gè)打印hello world的中間件,代碼如下:
import Koa from 'koa'
import koaBodyParser from 'koa-bodyparser'
import json from 'koa-json'
import path from 'path'
const app = new Koa();
app.use(koaBodyParser());
app.use(json());
app.use(async (ctx, next) => {
ctx.body = { msg: "Hello World", path: ctx.path, method: ctx.method };
await next();
});
app.listen(3000);
使用npm run serve-koa啟動(dòng)服務(wù),使用Postman測(cè)試一下:

連接數(shù)據(jù)庫
如果使用8.0以上版本的mysql,sequelize可能會(huì)報(bào)錯(cuò),stackoverflow相關(guān)鏈接
解決方式:ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'password'
項(xiàng)目使用mysql數(shù)據(jù)庫存儲(chǔ)用戶數(shù)據(jù),在數(shù)據(jù)庫中創(chuàng)建兩張表,user和role;使用sequelize框架進(jìn)行數(shù)據(jù)庫操作,首先安裝sequelize和數(shù)據(jù)庫驅(qū)動(dòng):
npm install --save sequelize
npm install --save mysql2
數(shù)據(jù)庫配置信息以json文件格式存放在/server/secrets/db.json中,格式如下:
{
"host": "10.143.53.100",
"port": 3306,
"schema": "vueDemo",
"username": "root",
"password": "root"
}
在/server/secrets/config.js中加載配置文件(同時(shí)也加載了jwt的密鑰,這樣做是為了方便后期部署時(shí),將secrets目錄下的文件存儲(chǔ)到docker中):
/* /server/config.js */
import fs from "fs";
import path from 'path'
let secretPath = 'secrets'
export default {
SECRET: fs.readFileSync(path.resolve(__dirname, secretPath, 'jwt-key.txt')),
EXP_TIME: '1h',
DATA_BASE: JSON.parse(fs.readFileSync(path.resolve(__dirname, secretPath, 'db.json')))
}
接下來配置sequelize并導(dǎo)出數(shù)據(jù)庫上下文對(duì)象
/* /server/schema/db.js */
import Sequelize from 'sequelize'
import config from '../config.js'
const dbConfig = config.DATA_BASE;
const sequelize = new Sequelize(`mysql://${dbConfig.username}:${dbConfig.password}@${dbConfig.host}:${dbConfig.port}/${dbConfig.schema}`,
{
pool: { //數(shù)據(jù)庫連接池
max: 5,
min: 1,
acquire: 30000,
idle: 10000
}
})
export default sequelize
安裝
uuid用作自增主鍵,指令:npm install uuid
然后創(chuàng)建user表對(duì)應(yīng)的對(duì)象(role表類似)
/* /server/schema/user.js */
import Sequelize from 'sequelize'
import sequelize from './db.js'
import uuid from 'uuid'
const Model = Sequelize.Model;
class User extends Model { }
User.init({
id: {
type: Sequelize.UUID,
defaultValue: uuid(), // id為空時(shí),使用uuid自動(dòng)生成主鍵
primaryKey: true
},
name: {
type: Sequelize.STRING,
allowNull: false
},
passwd: {
type: Sequelize.STRING,
allowNull: false
}
}, {
sequelize,
modelName: 'user'
})
User.sync().then(() => { console.log('== Table: User init!') }); //初始化數(shù)據(jù)庫,如果表不存在則自動(dòng)創(chuàng)建
export default User
接下來就可以方便地使用user和Role進(jìn)行數(shù)據(jù)庫訪問
實(shí)現(xiàn)接口
按照接口定義章節(jié)的描述,需要實(shí)現(xiàn)兩個(gè)接口,其中/api/user需要鑒權(quán),/api/auth可在未登錄狀態(tài)訪問
整體思路及代碼結(jié)構(gòu)如下:
/server/middlewares/auth目錄存放權(quán)限驗(yàn)證相關(guān)代碼,jwt-resolver.js解析請(qǐng)求的Header,解密authorization屬性得到User對(duì)象(包括id、name和roles屬性),將對(duì)象綁定到Header的currentUser屬性。auth-maker.js導(dǎo)出一個(gè)check方法,接受ctx對(duì)象和一個(gè)requireRole屬性,當(dāng)ctx.request.currentUser不具備requireRole時(shí)拋出異常;可以將該方法放在需要權(quán)限驗(yàn)證的controller代碼開始處/server/controller下的兩個(gè)controller對(duì)應(yīng)兩個(gè)接口/server/middlewares/error-handler.js攔截所有異常,并為statusCode為401的請(qǐng)求設(shè)置response header->{ 'WWW-Authenticate': 'Bearer' }
User service
在user-service.js中添加以下方法,后面會(huì)用到:
/* /server/service/user-service.js */
import User from '../schema/user'
import Role from '../schema/role';
import { ROLE_USER } from '../const';
export default {
getUser: async (id) => {}, //返回id對(duì)應(yīng)的User對(duì)象,如果不存在返回null
checkUser: async (name, passwd) => {}, //返回name和passwd符合的User對(duì)象,不存在則返回null
getRoles: async uid => { //返回該uid對(duì)應(yīng)user具有的roles,不存在則返回ROLE_USER并更新數(shù)據(jù)庫
let rolesModel = await Role.findAll({ where: { uid: uid } });
if (rolesModel.length <= 0) ... //省略更新邏輯
const roles = [];
rolesModel.forEach(r => roles.push(r.role))
return roles
}
}
權(quán)限中間件
安裝
jsonwebtoken,指令:npm install jsonwebtoken
jwt-resolver.js解密Header的authorization字段,得到user對(duì)象,代碼如下:
/* /server/middlewares/auth/jwt-resolver.js */
import jwt from 'jsonwebtoken'
import config from '../../config.js'
import userService from '../../service/user-service.js'
export default async (ctx, next) => {
let token;
let authHeader = ctx.header.authorization; //從header中取出token
if (authHeader) {
let [authType, jwtToken] = authHeader.split(' ');
if (authType.toLowerCase() === 'bearer') {
try {
token = jwt.verify(jwtToken, config.SECRET); //使用jwt解密token
ctx.header.currentUser = token; //將解析得到的user對(duì)象綁定到currentUser
} catch (e) {
console.log('Unresolved jwt token', e)
}
}
}
await next();
// 省略自動(dòng)更新token相關(guān)代碼
}
auth-maker.js導(dǎo)出check方法,代碼如下:
/* /server/middlewares/auth/auth-maker.js */
export default {
check: (ctx, requiredRole) => {
let user = ctx.header.currentUser;
if(!user){
ctx.throw(401, "4010::Unauthorized"); // 未登錄(提供token)
}
if(!user.roles.includes(requiredRole)){
ctx.throw(401, "4011::PmissionDenied"); // 權(quán)限不足,如:roles=['user'], requiredRole='admin'
}
}
}
配置路由
auth-controller.js實(shí)現(xiàn)了/api/auth接口,訪問數(shù)據(jù)庫檢驗(yàn)name和passwd是否合法:
/* /server/controller/auth-controller.js */
import jwt from 'jsonwebtoken'
import config from '../config.js'
import userService from '../service/user-service.js'
import userService from '../service/user-service.js'
export default {
getAuth: async (ctx, next) => {
const auth = ctx.request.body;
const user = await userService.checkUser(auth.name, auth.passwd);
if(!user){ // name和passwd錯(cuò)誤時(shí),拋出異常
ctx.throw(401, "4010::Username or password error!")
}
const roles = await userService.getRoles(user.id) // 獲取用戶具有的role
const token = {
id: user.id,
name: user.name,
passwd: user.passwd,
roles: roles
}
ctx.body = { code: 2000, token: jwt.sign(token, config.SECRET, { expiresIn: config.EXP_TIME }) }; // 簽名token,返回
}
}
user-controller.js與上面類似,只是在入口處進(jìn)行權(quán)限驗(yàn)證:
/* /server/controller/user-controller.js */
import authMaker from '../middlewares/auth/auth-maker.js'
import { ROLE_USER } from '../const.js';
export default {
getUser: async ctx => {
authMaker.check(ctx, ROLE_USER) //檢驗(yàn)用戶是否具有ROLE_USER權(quán)限,不滿足時(shí)拋異常
ctx.body = ctx.header.currentUser
}
}
安裝
koa-router, 指令:npm install koa-router
接下來在router.js中引入以上兩個(gè)controller,并指定對(duì)應(yīng)的接口:
/* /server/router.js */
import koaRouter from 'koa-router'
import auth from './controller/auth-controller.js'
import user from './controller/user-controller.js'
const router = koaRouter();
router.prefix('/api'); //對(duì)所有路由添加'/api'前綴
router.post('/auth', auth.getAuth); // 指定訪問'/api/auth'的請(qǐng)求由auth.getAuth方法處理
router.get('/user', user.getUser);
export default router
異常捕獲
在error-handler.js中捕獲由中間件或controller拋出的異常并處理
/* /server/middlewares/error-handler.js */
import utils from '../utils.js'
export default async (ctx, next) => {
try {
await next();
} catch (e) {
ctx.status = e.statusCode || e.status || 500; //捕獲異常并設(shè)置statusCode,默認(rèn)500
// '4010::Unauthorized' -> 業(yè)務(wù)錯(cuò)誤代碼:4010;錯(cuò)誤信息:Unauthorized
let [code, msg] = e.message.split('::');
ctx.body = utils.errMsg(Number(code), msg);
switch (ctx.status) {
case 401: // 對(duì)401權(quán)限錯(cuò)誤設(shè)置指示“系統(tǒng)接受認(rèn)證方式”的header
ctx.set({ 'WWW-Authenticate': 'Bearer' });
break;
}
}
}
引入以上組件
將以上的組件添加到app.js中,此時(shí)代碼看起來應(yīng)該是這樣:
/* /server/app.js */
import Koa from 'koa'
import koaBodyParser from 'koa-bodyparser'
import json from 'koa-json'
import path from 'path'
import errorHandler from './middlewares/error-handler.js'
import jwtResolver from './middlewares/auth/jwt-resolver.js'
import router from './router.js'
const app = new Koa();
app.use(errorHandler)
app.use(koaBodyParser());
app.use(json());
app.use(jwtResolver);
app.use(async (ctx, next) => {
ctx.body = { msg: "Hello World", path: ctx.path, method: ctx.method };
await next();
});
app.use(router.routes());
app.listen(3000);
需要提前創(chuàng)建數(shù)據(jù)庫,但不需要提前創(chuàng)建表
運(yùn)行npm run serve-koa,啟動(dòng)服務(wù),控制臺(tái)打印:
> vue-koa@0.1.0 serve-koa D:\pcode\vue-koa
> node server.dev.js
Executing (default): CREATE TABLE IF NOT EXISTS `users` (`id` CHAR(36) BINARY DEFAULT 'fc0870f8-faf7-4f23-9eee-65f869bff791' , `name` VARCHAR(255) NOT NULL, `passwd` VARCHAR(255) NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB;
Executing (default): CREATE TABLE IF NOT EXISTS `roles` (`id` CHAR(36) BINARY DEFAULT 'df463adb-be07-4c6c-9db7-46be31fbf725' , `uid` VARCHAR(255) NOT NULL, `role` VARCHAR(255) NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB;
Executing (default): SHOW INDEX FROM `roles`
== Table: Role init!
Executing (default): SHOW INDEX FROM `users`
== Table: User init!
向數(shù)據(jù)庫生成的user表中插入一條記錄:
INSERT INTO `vueDemo`.`users` (`name`, `passwd`) VALUES ('root', 'root');
接下來使用postman進(jìn)行接口測(cè)試:
/api/auth

/api/user
將上一個(gè)接口測(cè)試返回的token添加到請(qǐng)求Header,測(cè)試結(jié)果如圖:

本節(jié)源碼:GitHub Tag V0.2
前后端對(duì)接
在前端頁面構(gòu)建章節(jié)中,我們實(shí)現(xiàn)了基本的頁面跳轉(zhuǎn)邏輯;本節(jié)將在此基礎(chǔ)上,對(duì)接后端服務(wù),實(shí)現(xiàn)登錄驗(yàn)證
前端登錄流程:
- 用戶在登錄界面點(diǎn)擊登錄,前端將用戶名和密碼發(fā)送給
/api/auth接口獲取token - 將獲取到的token存儲(chǔ)到瀏覽器的
sessionStorage - 前端訪問后端接口的請(qǐng)求都攜帶該token
- 設(shè)置路由守衛(wèi),跳轉(zhuǎn)到受保護(hù)路由時(shí)檢測(cè)
sessinStroge,若token無效則跳轉(zhuǎn)到登錄界面
項(xiàng)目使用fetch發(fā)送http請(qǐng)求,為了確保所有請(qǐng)求均攜帶token,并能響應(yīng)token過期、無效等情況,可以對(duì)fetch做簡(jiǎn)單的封裝放到utils.js中
另外,前后端分離會(huì)導(dǎo)致跨域問題,簡(jiǎn)單來說:假設(shè)前端服務(wù)運(yùn)行在localhost:8080,后端服務(wù)運(yùn)行在localhost:3000端口,由于瀏覽器中的頁面是由8080端口的前端服務(wù)返回,那么頁面的js代碼只能發(fā)送到localhost:8080的請(qǐng)求,在頁面中調(diào)用3000端口的api屬于跨域。解決這個(gè)問題的方式總體有三種:
- 聲明允許跨域
- 使用反向代理代理轉(zhuǎn)發(fā),如使用
nginx將發(fā)送到8080端口,/api前綴的請(qǐng)求轉(zhuǎn)發(fā)到3000端口,使得在瀏覽器“看來”請(qǐng)求并沒有跨域 - 消除跨域,將前端代碼打包成靜態(tài)文件,掛到后端服務(wù)下
這里采用第二種,在前端定義一個(gè)代理,轉(zhuǎn)發(fā)/api前綴的請(qǐng)求,在vue.config.js中添加:
/* /vue.config.js */
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'http://localhost:3000/',
changeOrigin: true
}
}
}
}
登錄驗(yàn)證
登錄驗(yàn)證邏輯在Login.vue中,部分代碼如下:
/* /src/components/pages/Login.vue */
import utils from "../../utils.js"
export default {
data() {...}, //定義account, password, targetUrl(=this.$route.query.targetUrl)
methods: {
login() {
let auth = { //綁定到form表單的數(shù)據(jù)
name: this.account,
passwd: this.password
};
fetch("/api/auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(auth)
})
.then(res => res.json())
.then(res => {
if (res.code === 2000) {
utils.saveToken(res.token); //將token保存到sessionStroge
this.onAuthSuccess();
} else {
this.onAuthFail();
}
});
},
onAuthSuccess() {
if (this.targetUrl) { //判斷用戶是直接訪問登錄頁還是被重定向登錄頁
this.$router.push({ path: this.targetUrl });
} else {
this.$router.push({ path: "/" });
}
},
onAuthFail() {...}
}
};
注銷登陸非常簡(jiǎn)單,只需清空sessionStroge即可
路由守衛(wèi)
在router.js中,添加路由守衛(wèi),在路由跳轉(zhuǎn)前判斷路由是否受保護(hù),以及sessionStroge中是否存儲(chǔ)了有效token
/* /src/router.js */
import utils from './utils.js'
router.beforeEach((to, from, next) => {
if (to.path === '/login' || utils.vaildToken()) {
next();
} else {
// targetUrl記錄當(dāng)前url,以便登錄成功后跳轉(zhuǎn)會(huì)當(dāng)前頁面
next({path: '/login', query: {targetUrl: to.fullPath}})
}
});
/* /src/utils.js */
function vaildToken() {
const token = sessionStorage.getItem('access-token');
const exp = sessionStorage.getItem('exp');
return token && (Date.now() < exp * 1000) ? true : false;
}
export default {
vaildToken: vaildToken
}
封裝fetch
這部分主要做三件事:發(fā)送請(qǐng)求前將token設(shè)置到header;收到響應(yīng)后判斷是否需要更新本地token;若請(qǐng)求失敗,生成錯(cuò)誤提示。wrappedFetch部分代碼如下:
/* /src/utils */
async function wrappedFetch(resource, init) {
let token = getToken();
if (token) {
init.headers.Authorization = 'Bearer ' + token; //添加header
}
let res = await fetch(resource, init);
let r = await res.clone().json();
if (res.ok) {
if (r.ut) { //如果ut(updateToken)字段非空,則更新本地token
saveToken(r.ut);
}
return res;
} else {...} //處理請(qǐng)求失敗的情況
}
export default {
wrappedFetch: wrappedFetch
}
在AC.vue中,當(dāng)點(diǎn)擊refresh按鈕時(shí),使用wrappedFetch訪問/api/auth接口,刷新user數(shù)據(jù):
/* /src/components/pages/admin */
export default {
methods: {
refreshUser() {
utils
.wrappedFetch("/api/user", { methods: "GET" })
.then(res => res.json())
.then(res => {
this.user = res;
})
.catch(e => this.$log.info("Server error", e));
}
}
};
登錄并訪問http://localhost:8080/admin/info,點(diǎn)擊refresh,測(cè)試結(jié)果如下:

點(diǎn)擊“用戶中心”->“退出登陸”確認(rèn)功能正常
本節(jié)源碼:GitHub Tag V0.3
打包部署
開發(fā)環(huán)境下,分別為前后端啟動(dòng)服務(wù),可以方便地使用模塊熱重載特性(vue cli默認(rèn)支持,koa可以使用nodemon實(shí)現(xiàn)),有助于快速開發(fā)。生產(chǎn)環(huán)境下,將前端構(gòu)建成靜態(tài)文件,掛載到被babel轉(zhuǎn)換過的后端代碼下,可以提供更好的性能。
項(xiàng)目構(gòu)建
配置vue.config.js,設(shè)置vue cli構(gòu)建參數(shù):
/* /vue.config.js */
module.exports = {
outputDir: 'dist/dist', // 構(gòu)建輸出目錄,將后端轉(zhuǎn)換后的代碼放在dist下,將前端構(gòu)建后的代碼放在后端的dist下
assetsDir: 'assets' // 提取asset到單獨(dú)文件夾
}
在項(xiàng)目下根目錄下添加server.babelrc,作為后端babel轉(zhuǎn)換的配置文件,轉(zhuǎn)換目標(biāo)為node:
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": true
}
}
]
]
}
配置package.json中的啟動(dòng)腳本:
-
serve-vue開發(fā)環(huán)境啟動(dòng)前端 -
serve-koa開發(fā)環(huán)境啟動(dòng)后端 -
build構(gòu)建前端 -
compile轉(zhuǎn)換后端 -
buildAll構(gòu)建前后端 -
start生產(chǎn)環(huán)境啟動(dòng)項(xiàng)目
{
"scripts": {
"serve-vue": "vue-cli-service serve --port 80",
"serve-koa": "node server.dev.js",
"build": "vue-cli-service build",
"compile": "babel server -d dist --config-file ./server.babelrc --copy-files",
"buildAll": "npm run compile && npm run build",
"start": "cd dist && node app.js",
"lint": "vue-cli-service lint"
}
}
安裝
koa static,指令:npm install koa-static
還需要在后端代碼中使用koa-static配置靜態(tài)資源服務(wù)器,當(dāng)所有路由匹配失敗時(shí)嘗試加載靜態(tài)資源
安裝
histroy api fallback,指令:npm install koa2-history-api-fallback
另外由于前端使用了Vue Router的history路由模式,形如/login的請(qǐng)求(hash模式下對(duì)應(yīng)為/index.html# /login)是無法找到對(duì)應(yīng)的靜態(tài)資源的。該請(qǐng)求的本質(zhì)是請(qǐng)求/index.html頁面,然后執(zhí)行前端路由router.push('/login')。所以需要添加historyApiFallback,將所有未匹配到后端路由的(前端)路由映射到index.html
代碼如下:
/* /server/app.js */
...
app.use(router.routes());
// 一定放在router之后
app.use(historyApiFallback());
app.use(serve(path.resolve('dist')));
app.listen(3000);
至此,全部配置就完成了,然后我們運(yùn)行npm run buildAll && npm run start,訪問localhost:3000/login,不出意外會(huì)看到以下界面:

這是因?yàn)椋?code>Koa的默認(rèn)返回Content-Type是application/json,而koa static未能正確設(shè)置該屬性。我們可以使用mime-types識(shí)別資源類型,手動(dòng)設(shè)置Content-Type
安裝
mime-types,指令:npm install mime-types
在app.js中添加一個(gè)中間件:
/* /server/app.js */
app.use(historyApiFallback());
app.use(async (ctx, next)=>{
await next();
ctx.set('content-type', mime.lookup(path.join('dist', ctx.path)) || 'text/html');
})
app.use(serve(path.resolve('dist')));
重新構(gòu)建并運(yùn)行,即可看到正確的頁面
docker構(gòu)建
/server/secrets下存儲(chǔ)了數(shù)據(jù)庫和jwt密鑰等敏感信息,應(yīng)當(dāng)添加到.gitignore中,避免上傳到github;同時(shí)我們不希望打包好的docker鏡像中包含這些信息,而是從docker secret中加載。
項(xiàng)目的依賴可以分為運(yùn)行時(shí)依賴和開發(fā)環(huán)境依賴,為了使最終的鏡像只包含運(yùn)行時(shí)依賴,以及避免每次構(gòu)建重新安裝依賴,我們需要分別打包構(gòu)建環(huán)境、運(yùn)行時(shí)環(huán)境鏡像,并使用兩階段構(gòu)建生成最終鏡像
存儲(chǔ)敏感信息
首先在項(xiàng)目部署的docker服務(wù)器上,使用docker secret存儲(chǔ)敏感信息??梢允褂?code>docker secret create命令,參見docker文檔,或使用Portainer等工具。
以Portainer為例(截圖只做演示,文件名參考上文):

secret會(huì)以文件的形式保存,在docker-compose.yml中指定使用后,會(huì)掛載到容器的/run/secrets下。接下來,修改后端的config.js,當(dāng)運(yùn)行環(huán)境為docker時(shí),從docker secrets中加載這些配置:
//
let secretPath = 'secrets'
if (process.env.ENV === 'docker') {
secretPath = '/run/secrets'
}
export default {
SECRET: fs.readFileSync(path.resolve(__dirname, secretPath, 'jwt-key.txt')),
...
}
打包docker鏡像
為了防止copy命令拷貝
dist、node_modules等目錄下的文件,添加.dockerignore文件
- 將構(gòu)建環(huán)境打包為單獨(dú)的鏡像,指令及
build.Dockerfile:
docker build -t vue-koa-build-env:latest -f ./dockerfiles/build.Dockerfile .
FROM node:lts-alpine
WORKDIR /build
COPY package*.json ./
RUN npm install
- 將運(yùn)行環(huán)境打包為單獨(dú)的鏡像,指令及
runtime.Dockerfile:
docker build -t vue-koa-runtime-env:latest -f ./dockerfiles/runtime.Dockerfile .
FROM node:lts-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
- 打包項(xiàng)目鏡像,指令及
Dockerfile:
docker build -t vue-koa:latest -f ./dockerfiles/Dockerfile .
FROM vue-koa-build-env:latest # stage0: 基于構(gòu)建環(huán)境,構(gòu)建項(xiàng)目
WORKDIR /build
COPY . .
RUN npm run buildAll
FROM vue-koa-runtime-env:latest # stage1: 基于運(yùn)行環(huán)境,拷貝stage0的構(gòu)建結(jié)果
WORKDIR /app
COPY --from=0 /build/dist ./dist
ENV ENV="docker" # 設(shè)置環(huán)境變量
EXPOSE 3000
CMD ["npm", "run", "start"] # 啟動(dòng)
- 添加
docker-compose.yml,配置加載的secrets,部分配置如下:
services:
vue_koa:
secrets:
- db.json
- jwt-key.txt
secrets:
db.json:
external: true
jwt-key.txt:
external: true
最后,在compose文件所在目錄,執(zhí)行docker-compose up -d即可啟動(dòng)服務(wù)
本節(jié)源碼:GitHub Tag V0.4
寫在最后
之前剛完成的一個(gè)項(xiàng)目,使用了Flask+Jinja2+JQuery的技術(shù)棧,寫的很不開心:模板渲染+ajax混用導(dǎo)致代碼有些凌亂;缺失ioc&aop;Flask沒有異步非阻塞……于是下一個(gè)項(xiàng)目選型的時(shí)候,我打算用SpringBoot+Vue,但方案被領(lǐng)導(dǎo)駁回,要求使用nodejs,于是就有了這篇新手向博客
驀然想起當(dāng)初面試百度的時(shí)候面試官的一句話:“語言不重要,重要的是...”,這句話的潛臺(tái)詞是“都得會(huì)!”。當(dāng)然無論是用Java、Python還是JavaScript寫Web,思想都是相通的,無非是不同語言提供了不同的特性
但是啊,曾經(jīng)滄海難為水,當(dāng)年用Spring那一套時(shí)其實(shí)沒有覺得哪里便捷了,面試問起來也無非就會(huì)說個(gè)AOP、IOC,至于好在哪里,始終一知半解,直到有一天離開了這生態(tài)。之前在知乎吐槽Js沒有大型成熟的后端框架,有回復(fù)“Nestjs了解一下”,我還真的去了解了一下,IOC怎么看怎么怪,AOP完善程度被Spring按在地上摩擦……加上ts的語法……怎么說呢,ts是我見過最詭異最反直覺的語法,比golang都嚴(yán)重
還用就是鴨子型語言用多了,真的挺懷念Java的……可能也就懷念下,萬一回去了,大概又不習(xí)慣冗長(zhǎng)的語法了,誰知道呢
說到底,語言不過是個(gè)工具……
博客發(fā)表于:Ghamster Blog
轉(zhuǎn)載請(qǐng)注明出處