简介
原文发表于个人博客:https://ghamster0.github.io 转载请注明出处
参考博客: 全栈开发实战:用Vue2+Koa1开发完整的前后端项目(更新Koa2)
前置技能: 具备Vue和Koa基础知识,了解Javascript基础语法(和混乱),了解Nodejs(npm)常用操作
本文以新手视角,从零开始逐步构建一个Vue+Koa2的web应用,项目主要包括以下内容:
- 基于
Vue组件构建单页面应用,包含登录、用户、管理员视图,由Vue Router控制页面跳转 - 使用
Koa及相关插件提供API接口 Sequelize数据库访问- 基于
json web token的登录验证 - 配置本地运行、打包docker镜像部署
为了简化构建(因为菜),前端部分使用了Vue Cli,Cli的本质依旧是使用Webpack打包,但提供了一系列针对Vue的配置,使构建过程开箱即用;另外Login.vue使用了“参考博客”的源码。项目在一些阶段会打tag,并附上源码地址
由于以前从未接触过nodejs后台开发,本文可能存在一些局限和错误,欢迎指正
创建项目
安装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命令需要安装全局扩展
npm install -g @vue/cli-service-global
创建项目
vue create vue-koa
新建server目录,作为koa代码目录,在目录下创建app.js作为入口文件,整体目录结构如下:
.
├── 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 # 前端入口
本节源码:GitHub Tag V0.0
接口定义
项目使用jwt token做登录验证,用户登录点击登录时,前端调用获取token接口,使用用户名和密码认证,接口返回经jwt加密的token;随后,前端发送所有请求均携带该token作为已登录凭证
按照标准,token类型为Bearer,对需要权限认证的接口,request header设置字段{Authorization: 'Bearer <token>'};对于认证失败的请求,服务器应当返回401,response header设置字段{'WWW-Authenticate': 'Bearer'}
后端服务运行在3000端口,提供两个接口:
获取token
请求参数
Method: POST
Api: /api/auth
Body: {username: un, password: pw}
返回值
{
"code": 2000,
"token": "eyqk"
}
获取当前用户信息
接口需要携带token,请求参数
Method: GET
Api: /api/user
返回值
{
"username": "艾广威",
"roles": [
"user"
],
"iat": 1567656871,
"exp": 1567660471
}
前端页面构建
这一节,将创建一个具有两级导航结构的页面,页面顶部导航栏为一级导航,侧边导航菜单为二级导航。点击顶部导航的菜单项,切换侧边导航菜单;点击侧边导航菜单,切换页面主体内容
项目使用Vue Cli构建,在根目录下创建vue.config.js,该文件会自动被Vue Cli识别。由于没有对babel做额外调整,可将babel.config.js文件删除
引入UI等组件
引入element ui组件库,简化页面排版
安装方式:
npm i element-ui -S
这里使用全局使用方式,实际项目中建议按需引入,参考文档
/* /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 建立二级路由
安装Vue Router,指令:
npm install vue-router
在/src下建立如下目录结构:
.
├── App.vue # Vue根组件,包含顶部导航栏和一级 router-view 标签
├── assets
│ └── logo.png
├── components
│ ├── pages
│ │ ├── Admin.vue # 管理员视图,包含管理员侧边导航菜单元数据
│ │ ├── Login.vue
│ │ ├── Logout.vue
│ │ ├── User.vue # 用户视图
│ │ ├── admin
│ │ │ └── AC.vue
│ │ └── user
│ │ └── UC.vue
│ └── parts # 公用页面组件
│ ├── PageFooter.vue
│ └── SideMenuContent.vue # 侧边导航+主内容(二级 router-view 标签)
├── main.js
├── router.js # 前端路由配置
└── utils.js
页面结构分析:
/App.vue:页面的根组件,定义顶部导航栏(一级导航)、底部页脚。中部是router-view标签,提供一级路由切换,如:点击导航栏的“管理员”,导航到/admin,router-view标签渲染为/components/pages/Admin.vue/components/pages/Admin.vue(User.vue类似):该组件data的menus属性是一个列表对象,定义了侧边导航菜单的内容;使用SideMenuContent.vue模板渲染menus,支持二级菜单/components/pages/parts/SideMenuContent.vue:左侧为侧边导航(二级导航),右侧包含一个二级router-view标签,用作二级路由渲染/components/pages/AC.vue(UC.vue类似):页面主内容,由SideMenuContent内的router-view渲染- 更多细节查看本节结束给出的源码
接下来配置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动态创建了新的组件,需要启用运行时编译
配置启用运行时编译:
/* /vue.config.js */
module.exports = {
runtimeCompiler: true
}
此时运行npm run serve,访问8080端口,可以看到如下界面
![基本页面]](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9naGFtc3Rlci5naXRlZS5pby9paHNlcnZpY2UvVnVlX0tvYSVFNSVCQyU4MCVFNSU4RiU5MSVFNSVBRSU5RSVFNiU4OCU5OC8lRTUlODklOEQlRTclQUIlQUYlRTklQTElQjUlRTklOUQlQTIucG5n?x-oss-process=image/format,png)
本节源码:GitHub Tag V0.1
后端服务搭建
安装
koa,指令:npm install koa
后端服务需要实现以下功能:
- 数据库访问
- 一个路由组件,提供接口定义章节定义的两个接口,以及接口的访问权限控制
- 一组中间件,负责请求的预处理和后处理
后端目录结构如下:
.
├── 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导入
├── schema # 数据库初始化,及各表定义
│ ├── db.js
│ ├── role.js
│ └── user.js
├── secrets # 敏感信息,应加入.gitignore
│ ├── db.json # 数据库配置
│ └── jwt-key.txt # jwt密钥,纯文本字符串
├── service
│ └── user-service.js
└── utils.js
配置运行环境
应确保删除了
/babel.config.js,否则会默认被babel加载导致启动失败
由于node不支持es6的import语法,这里需要使用babel做简单的语法转换。开发环境下使用@babel/register运行时转换即可(生产环境会在之后的章节解释),首先安装@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中添加启动脚本
{
"scripts": {
"serve-koa": "node server.dev.js"
}
}
稍后就可以使用npm run serve-koa启动后端服务
定义通用中间件
安装koa-json和koa-bodyparser
npm install koa-json
npm install koa-bodyparser
koa-json:用于自动序列化ctx.body中的Object对象koa-bodyparser:用于将ctx中的formData解析到ctx.request.body中
在main.js中引入两个中间件,另外简单定义一个打印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启动服务,使用Postman测试一下:

连接数据库
如果使用8.0以上版本的mysql,sequelize可能会报错,stackoverflow相关链接
解决方式:ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'password'
项目使用mysql数据库存储用户数据,在数据库中创建两张表,user和role;使用sequelize框架进行数据库操作,首先安装sequelize和数据库驱动:
npm install --save sequelize
npm install --save mysql2
数据库配置信息以json文件格式存放在/server/secrets/db.json中,格式如下:
{
"host": "10.143.53.100",
"port": 3306,
"schema": "vueDemo",
"username": "root",
"password": "root"
}
在/server/secrets/config.js中加载配置文件(同时也加载了jwt的密钥,这样做是为了方便后期部署时,将secrets目录下的文件存储到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并导出数据库上下文对象
/* /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: { //数据库连接池
max: 5,
min: 1,
acquire: 30000,
idle: 10000
}
})
export default sequelize
安装
uuid用作自增主键,指令:npm install uuid
然后创建user表对应的对象(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为空时,使用uuid自动生成主键
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!') }); //初始化数据库,如果表不存在则自动创建
export default User
接下来就可以方便地使用user和Role进行数据库访问
实现接口
按照接口定义章节的描述,需要实现两个接口,其中/api/user需要鉴权,/api/auth可在未登录状态访问
整体思路及代码结构如下:
-
/server/middlewares/auth目录存放权限验证相关代码,jwt-resolver.js解析请求的Header,解密authorization属性得到User对象(包括id、name和roles属性),将对象绑定到Header的currentUser属性。auth-maker.js导出一个check方法,接受ctx对象和一个requireRole属性,当ctx.request.currentUser不具备requireRole时抛出异常;可以将该方法放在需要权限验证的controller代码开始处 -
/server/controller下的两个controller对应两个接口 -
/server/middlewares/error-handler.js拦截所有异常,并为statusCode为401的请求设置response header->{ 'WWW-Authenticate': 'Bearer' }
User service
在user-service.js中添加以下方法,后面会用到:
/* /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对应的User对象,如果不存在返回null
checkUser: async (name, passwd) => {}, //返回name和passwd符合的User对象,不存在则返回null
getRoles: async uid => { //返回该uid对应user具有的roles,不存在则返回ROLE_USER并更新数据库
let rolesModel = await Role.findAll({ where: { uid: uid } });
if (rolesModel.length <= 0) ... //省略更新逻辑
const roles = [];
rolesModel.forEach(r => roles.push(r.role))
return roles
}
}
权限中间件
安装
jsonwebtoken,指令:npm install jsonwebtoken
jwt-resolver.js解密Header的authorization字段,得到user对象,代码如下:
/* /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对象绑定到currentUser
} catch (e) {
console.log('Unresolved jwt token', e)
}
}
}
await next();
// 省略自动更新token相关代码
}
auth-maker.js导出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"); // 权限不足,如:roles=['user'], requiredRole='admin'
}
}
}
配置路由
auth-controller.js实现了/api/auth接口,访问数据库检验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错误时,抛出异常
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与上面类似,只是在入口处进行权限验证:
/* /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) //检验用户是否具有ROLE_USER权限,不满足时抛异常
ctx.body = ctx.header.currentUser
}
}
安装
koa-router, 指令:npm install koa-router
接下来在router.js中引入以上两个controller,并指定对应的接口:
/* /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'); //对所有路由添加'/api'前缀
router.post('/auth', auth.getAuth); // 指定访问'/api/auth'的请求由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; //捕获异常并设置statusCode,默认500
// '4010::Unauthorized' -> 业务错误代码:4010;错误信息:Unauthorized
let [code, msg] = e.message.split('::');
ctx.body = utils.errMsg(Number(code), msg);
switch (ctx.status) {
case 401: // 对401权限错误设置指示“系统接受认证方式”的header
ctx.set({ 'WWW-Authenticate': 'Bearer' });
break;
}
}
}
引入以上组件
将以上的组件添加到app.js中,此时代码看起来应该是这样:
/* /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);
需要提前创建数据库,但不需要提前创建表
运行npm run serve-koa,启动服务,控制台打印:
> 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!
向数据库生成的user表中插入一条记录:
INSERT INTO `vueDemo`.`users` (`name`, `passwd`) VALUES ('root', 'root');
接下来使用postman进行接口测试:
/api/auth

/api/user
将上一个接口测试返回的token添加到请求Header,测试结果如图:

本节源码:GitHub Tag V0.2
前后端对接
在前端页面构建章节中,我们实现了基本的页面跳转逻辑;本节将在此基础上,对接后端服务,实现登录验证
前端登录流程:
- 用户在登录界面点击登录,前端将用户名和密码发送给
/api/auth接口获取token - 将获取到的token存储到浏览器的
sessionStorage - 前端访问后端接口的请求都携带该token
- 设置路由守卫,跳转到受保护路由时检测
sessinStroge,若token无效则跳转到登录界面
项目使用fetch发送http请求,为了确保所有请求均携带token,并能响应token过期、无效等情况,可以对fetch做简单的封装放到utils.js中
另外,前后端分离会导致跨域问题,简单来说:假设前端服务运行在localhost:8080,后端服务运行在localhost:3000端口,由于浏览器中的页面是由8080端口的前端服务返回,那么页面的js代码只能发送到localhost:8080的请求,在页面中调用3000端口的api属于跨域。解决这个问题的方式总体有三种:
- 声明允许跨域
- 使用反向代理代理转发,如使用
nginx将发送到8080端口,/api前缀的请求转发到3000端口,使得在浏览器“看来”请求并没有跨域 - 消除跨域,将前端代码打包成静态文件,挂到后端服务下
这里采用第二种,在前端定义一个代理,转发/api前缀的请求,在vue.config.js中添加:
/* /vue.config.js */
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'http://localhost:3000/',
changeOrigin: true
}
}
}
}
登录验证
登录验证逻辑在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表单的数据
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() {...}
}
};
注销登陆非常简单,只需清空sessionStroge即可
路由守卫
在router.js中,添加路由守卫,在路由跳转前判断路由是否受保护,以及sessionStroge中是否存储了有效token
/* /src/router.js */
import utils from './utils.js'
router.beforeEach((to, from, next) => {
if (to.path === '/login' || utils.vaildToken()) {
next();
} else {
// targetUrl记录当前url,以便登录成功后跳转会当前页面
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
这部分主要做三件事:发送请求前将token设置到header;收到响应后判断是否需要更新本地token;若请求失败,生成错误提示。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 {...} //处理请求失败的情况
}
export default {
wrappedFetch: wrappedFetch
}
在AC.vue中,当点击refresh按钮时,使用wrappedFetch访问/api/auth接口,刷新user数据:
/* /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,点击refresh,测试结果如下:

点击“用户中心”->“退出登陆”确认功能正常
本节源码:GitHub Tag V0.3
打包部署
开发环境下,分别为前后端启动服务,可以方便地使用模块热重载特性(vue cli默认支持,koa可以使用nodemon实现),有助于快速开发。生产环境下,将前端构建成静态文件,挂载到被babel转换过的后端代码下,可以提供更好的性能。
项目构建
配置vue.config.js,设置vue cli构建参数:
/* /vue.config.js */
module.exports = {
outputDir: 'dist/dist', // 构建输出目录,将后端转换后的代码放在dist下,将前端构建后的代码放在后端的dist下
assetsDir: 'assets' // 提取asset到单独文件夹
}
在项目下根目录下添加server.babelrc,作为后端babel转换的配置文件,转换目标为node:
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": true
}
}
]
]
}
配置package.json中的启动脚本:
serve-vue开发环境启动前端serve-koa开发环境启动后端build构建前端compile转换后端buildAll构建前后端start生产环境启动项目
{
"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配置静态资源服务器,当所有路由匹配失败时尝试加载静态资源
安装
histroy api fallback,指令:npm install koa2-history-api-fallback
另外由于前端使用了Vue Router的history路由模式,形如/login的请求(hash模式下对应为/index.html# /login)是无法找到对应的静态资源的。该请求的本质是请求/index.html页面,然后执行前端路由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);
至此,全部配置就完成了,然后我们运行npm run buildAll && npm run start,访问localhost:3000/login,不出意外会看到以下界面:

这是因为,Koa的默认返回Content-Type是application/json,而koa static未能正确设置该属性。我们可以使用mime-types识别资源类型,手动设置Content-Type
安装
mime-types,指令:npm install mime-types
在app.js中添加一个中间件:
/* /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')));
重新构建并运行,即可看到正确的页面
docker构建
/server/secrets下存储了数据库和jwt密钥等敏感信息,应当添加到.gitignore中,避免上传到github;同时我们不希望打包好的docker镜像中包含这些信息,而是从docker secret中加载。
项目的依赖可以分为运行时依赖和开发环境依赖,为了使最终的镜像只包含运行时依赖,以及避免每次构建重新安装依赖,我们需要分别打包构建环境、运行时环境镜像,并使用两阶段构建生成最终镜像
存储敏感信息
首先在项目部署的docker服务器上,使用docker secret存储敏感信息。可以使用docker secret create命令,参见docker文档,或使用Portainer等工具。
以Portainer为例(截图只做演示,文件名参考上文):

secret会以文件的形式保存,在docker-compose.yml中指定使用后,会挂载到容器的/run/secrets下。接下来,修改后端的config.js,当运行环境为docker时,从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文件
- 将构建环境打包为单独的镜像,指令及
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
- 将运行环境打包为单独的镜像,指令及
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
- 打包项目镜像,指令及
Dockerfile:
docker build -t vue-koa:latest -f ./dockerfiles/Dockerfile .
FROM vue-koa-build-env:latest # stage0: 基于构建环境,构建项目
WORKDIR /build
COPY . .
RUN npm run buildAll
FROM vue-koa-runtime-env:latest # stage1: 基于运行环境,拷贝stage0的构建结果
WORKDIR /app
COPY --from=0 /build/dist ./dist
ENV ENV="docker" # 设置环境变量
EXPOSE 3000
CMD ["npm", "run", "start"] # 启动
- 添加
docker-compose.yml,配置加载的secrets,部分配置如下:
services:
vue_koa:
secrets:
- db.json
- jwt-key.txt
secrets:
db.json:
external: true
jwt-key.txt:
external: true
最后,在compose文件所在目录,执行docker-compose up -d即可启动服务
本节源码:GitHub Tag V0.4
写在最后
之前刚完成的一个项目,使用了Flask+Jinja2+JQuery的技术栈,写的很不开心:模板渲染+ajax混用导致代码有些凌乱;缺失ioc&aop;Flask没有异步非阻塞……于是下一个项目选型的时候,我打算用SpringBoot+Vue,但方案被领导驳回,要求使用nodejs,于是就有了这篇新手向博客
蓦然想起当初面试百度的时候面试官的一句话:“语言不重要,重要的是…”,这句话的潜台词是“都得会!”。当然无论是用Java、Python还是JavaScript写Web,思想都是相通的,无非是不同语言提供了不同的特性
但是啊,曾经沧海难为水,当年用Spring那一套时其实没有觉得哪里便捷了,面试问起来也无非就会说个AOP、IOC,至于好在哪里,始终一知半解,直到有一天离开了这生态。之前在知乎吐槽Js没有大型成熟的后端框架,有回复“Nestjs了解一下”,我还真的去了解了一下,IOC怎么看怎么怪,AOP完善程度被Spring按在地上摩擦……加上ts的语法……怎么说呢,ts是我见过最诡异最反直觉的语法,比golang都严重
还用就是鸭子型语言用多了,真的挺怀念Java的……可能也就怀念下,万一回去了,大概又不习惯冗长的语法了,谁知道呢
说到底,语言不过是个工具……
1147





