一个项目一开始总是出于还不错愿景,但做着做着,就越来越乱了。万丈高楼平地起,有些基础的问题解决好,后面改需求就不会那么痛苦了。
在笔者之前的工作经历中,遇到用户上传(跨域+鉴权+上传)的扯皮多了去了。现在就尝试用标准的姿态,更加前端的角度去回答这几个问题。
写了好多天原理,现在就来实战一下吧。这是我个人项目中的一个商城,基于以下技术栈:
- vue
- vant
- router
- vuex
- axios
后端沿用用上篇文章的egg+mongo。
虽然笔者主要使用的是react,但作为一手得来的经验,文章内容比很多使用vue的初级工程师要深入的多。
前端配置跨域,在根目录新建 vue.config.js
module.exports = {
devServer: {
proxy: 'http://localhost:7001'
}
}
以上是研发环境配置跨域。
后端沿袭上一篇的egg框架。在后端设置跨域:
// 步骤一:下载 egg-cors 包
npm i egg-cors -S
// 步骤二:plugin.js中设置开启cors
cors : {
enable: true,
package: 'egg-cors',
};
// 步骤三:config.default.js中配置,注意配置覆盖的问题
config.security = {
// 关闭csrf
// csrf: {
// enable: false,
// ignoreJSON: true
// },
domainWhiteList: ['http://localhost:8080']
};
// 允许跨域,需要允许Options请求,并且允许携带cookie
config.cors = {
origin: 'http://localhost:8080',
credentials:true,
allowMethods: 'GET,HEAD,PUT,POST,DELETE,PATCH,OPTIONS'
};
因为前端header将携带token,对于预检请求,必须return true
。
首先是做手机号码登录。需要一套符合jwt规范的接口,包括用户登录请求token。
egg的插件生态相当丰富。可安装相应的jwt模块。
npm i egg-jwt -s
在插件和设置中引入:
// plugin.js
jwt: {
enable: true,
package: 'egg-jwt',
}
// config.default.js
config.jwt = {
secret: 'Great4-M',//jwt密钥
enable: true, // default is false
match: /^\/api/, // 所有需要鉴权的都用/api打头
}
在service下新建actionToken.js
// service/actionToken.js
const Service = require('egg').Service
class ActionTokenService extends Service {
async apply(_id) {
const { ctx } = this
// 签名校验
return ctx.app.jwt.sign({
data: {
_id: _id
},
// 保存7天!
exp: Math.floor(Date.now() / 1000) + (60 * 60 * 24 * 7)
}, ctx.app.config.jwt.secret)
}
}
module.exports = ActionTokenService
在service层下新建userAccess.js,在这写用户登录和注册方法:
// service/userAccess.js
const Service = require('egg').Service
class UserAccessService extends Service {
async login(payload) {
const { ctx, service } = this
const user = await service.user.findByMobile(payload.mobile)
// 查找用户
if (!user) {
ctx.throw(404, 'user not found')
}
// 校验密码
let verifyPsw = await ctx.compare(payload.password, user.password)
if (!verifyPsw) {
ctx.throw(401, 'user password is error')
}
// 生成Token令牌
return { token: await service.actionToken.apply(user._id) }
}
// 当前用户信息
async current() {
const { ctx, service } = this
// ctx.state.user 可以提取到JWT编码的data
const _id = ctx.state.user.data._id
const user = await service.user.find(_id)
if (!user) {
ctx.throw(404, 'user is not found')
}
user.password = 'How old are you?'
return user
}
}
module.exports = UserAccessService
在contract下新建userAccess的测试用例
// app/contract/userAccess.js
module.exports = {
loginRequest: {
mobile: { type: 'string', required: true, description: '手机号', example: '13800138000', format: /^1[34578]\d{9}$/, },
password: { type: 'string', required: true, description: '密码', example: '123456', },
},
createUserRequest:{
mobile:{type:'string',required:true,descption:'手机号码',example:'15626189507',format:/^1[34578]\d{9}$/ },
password:{type:'string',required:true,descption:'密码',example:'123456'},
realName:{type:'string',required:true,descption:'姓名',example:'djtao'},
}
}
在controller下新建userAccess控制器(接口):
// controller/userAccess.js
'use strict'
const Controller = require('egg').Controller
/**
* @Controller 用户鉴权
*/
class UserAccessController extends Controller {
constructor(ctx) {
super(ctx)
}
/**
* @summary 用户登入
* @description 用户登入
* @router post /auth/jwt/login
* @request body loginRequest *body
* @response 200 baseResponse 创建成功 */
async login() {
const { ctx, service } = this
// 校验参数
ctx.validate(ctx.rule.loginRequest);
// 组装参数
const payload = ctx.request.body || {}
// 调用 Service 进行业务处理
const token = await service.userAccess.login(payload)
const user = await service.user.findByMobile(payload.mobile)
const res={...token,user}
// const res2=await service.user.show()
// 设置响应内容和响应状态码
ctx.helper.success({ ctx, res })
}
/**
* @summary 用户注册
* @description 注册
* @router post /user/register
* @request body createUserRequest *body
* @response 200 baseResponse 创建成功 */
async register(){
const { ctx, service } = this
// 校验参数
ctx.validate(ctx.rule.createUserRequest)
// 组装参数
const payload = ctx.request.body || {}
// 调用 Service 进行业务处理
const res = await service.user.create(payload)
console.log(111111,res)
// 设置响应内容和响应状态码
ctx.helper.success({ ctx, res })
}
}
module.exports = UserAccessController
登录请求的登录态保存在store中
// ..拿到数据后
if (res.data.code == 0) {
this.$store.commit("login");
// 保存到vuex
localStorage.setItem("mzUser", JSON.stringify(res.data.data));
Toast.success({
message: res.data.msg,
duration: 400,
onClose: () => {
this.$router.push({
path: "/mall/me"
});
}
});
}
同理,登出(注销)也可以用 this.$store.commit(logout)
因此在store下的写法是:
// store/user.js
// 获取token作为初始登录态。
let token=window.localStorage.getItem("mzUser") ?
JSON.parse(window.localStorage.getItem("mzUser")).token : ''
export default {
state:{
isLogin:!!token
},
mutations:{
login(state){
state.isLogin=true;
},
logout(state){
state.isLogin=false;
}
},
actions:{
},
}
这里的登录态将作为路由守卫的的依据。
路由守卫的包括所有登录之后的界面。
先给所有路由加一个flag.
// router.js
// 商城内页
{
path:'/mall',
name:'mall',
component:Mall,
meta:{auth:true},
children:[
{
path:'me',
component:Me,
meta:{auth:true},
},
{
path:'home',
component:UserList,
meta:{auth:true},
},
]
},
然后根据这个flag确定这些页面需要做守卫。
router.beforeEach((to,from,next)=>{
if(to.meta.auth){
// 从store中获取登录态
const isLogin=store.state.user.isLogin;
if(isLogin){
next();
}else{
next({
path:'/login',
query:{
redirect:to.path
}
})
}
}else{
next()
}
});
我们要封装一个请求方法,实现以下目的:
简单说就是一个具有路由拦截器功能的请求库。
先来实现第一个目标:
import axios from 'axios'
import qs from 'qs'
import store from '../store'
axios.defaults.timeout = 5000;//响应时间
//配置请求头
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
//配置接口地址
axios.defaults.baseURL = 'http://localhost:7001';
axios.defaults.withCredentials = true;
axios.interceptors.request.use(
config => {
// 获取token
const token = window.localStorage.getItem("mzUser") ?
JSON.parse(window.localStorage.getItem("mzUser")).token : '';
if (token) {
// 判断是否存在token,如果存在的话,则每个http header都加上token
// Bearer是JWT的认证头部信息
config.headers.common["Authorization"] = "Bearer " + token;
}
return config;
},
err => {
return Promise.reject(err);
}
)
然后,需要做token过期的判断。(在此不妨把token有效期改为3s用以测试)
//返回状态判断(添加响应拦截器)
axios.interceptors.response.use((res) => {
store.commit('change', false);
//对响应数据做些事
if (!res.data.success) {
return Promise.resolve(res);
}
return res;
}, (error) => {
// 登录过期判断
if(error.response.status=401){
// 你还可以在这里清理token。
store.commit('logout');
window.location.href='/login';
}else{
console.log('网络异常');
return Promise.reject(error);
}
});
同理,你还可以写一个请求拦截器
//POST传参序列化(添加请求拦截器)
axios.interceptors.request.use((config) => {
//在发送请求之前做某件事
store.commit('change', true);
if (config.method === 'post') {
config.data = qs.stringify(config.data);
}
return config;
}, (error) => {
console.log('错误的传参')
return Promise.reject(error);
});
接下来看第三个目标:封装post和get请求。其实根据业务场景,还可以封装put和delete。
//返回一个Promise(发送post请求)
export function post(url, params) {
return new Promise((resolve, reject) => {
console.log(params)
axios.post(url, params)
.then(response => {
resolve(response);
}, err => {
reject(err);
})
.catch((error) => {
reject(error)
})
})
}
//返回一个Promise(发送get请求)
export function get(url, param) {
return new Promise((resolve, reject) => {
axios.get(url, { params: param })
.then(response => {
resolve(response)
}, err => {
reject(err)
})
.catch((error) => {
reject(error)
})
})
}
export default {
post,
get,
}
http.js就结束了。
api.js主要是前端管理接口的文件。结构示例如下:
在main.js下,引入http.js和api.js,然后挂在到 Vue
的原型链上,就可以很方便地使用了。
// 引入http
import http from './lib/http.js'
Vue.prototype.$http=http;
Vue.prototype.$axios = axios;
// 引入api
import api from './lib/api'
Vue.prototype.$api=api;
使用示例:
const api=this.$api;
const http=this.$http;
http.post(api.login,{mobile:'13800138000',password:'123456'}).then(res=>{
// balabala...
})
上传遇到的问题在在于
上传业务很简单。当前端请求成功后,发回地址即可。
先安装插件;
npm i await-stream-ready stream-wormhole image-downloader -s
然后在controller层写逻辑:
// app/controller/upload.js
const fs = require('fs')
const path = require('path')
const Controller = require('egg').Controller
const awaitWriteStream = require('await-stream-ready').write
const sendToWormhole = require('stream-wormhole')
const download = require('image-downloader')
/**
* @Controller 上传
*/
class UploadController extends Controller {
constructor(ctx) {
super(ctx)
}
// 上传单个文件
/**
* @summary 修改头像
* @description 上传单个文件
* @router post /api/upload/single
*/
async create() {
const { ctx } = this
// 要通过 ctx.getFileStream 便捷的获取到用户上传的文件,需要满足两个条件:
// 只支持上传一个文件。
// 上传文件必须在所有其他的 fields 后面,否则在拿到文件流时可能还获取不到 fields。
const stream = await ctx.getFileStream()
// 所有表单字段都能通过 `stream.fields` 获取到
const filename = path.basename(stream.filename) // 文件名称
const extname = path.extname(stream.filename).toLowerCase() // 文件扩展名称
const uuid = (Math.random() * 999999).toFixed()
// 组装参数 stream
const target = path.join(this.config.baseDir, 'app/public/uploads',
`${uuid}${extname}`)
const writeStream = fs.createWriteStream(target)
// 文件处理,上传到云存储等等
try {
await awaitWriteStream(stream.pipe(writeStream))
// 组装地址
const res={imgUrl:`http://localhost:7001/public/uploads/${uuid}${extname}`}
// 调用 Service 进行业务处理
// 设置响应内容和响应状态码
ctx.helper.success({ctx,res});
return res
} catch (err) {
// 必须将上传的文件流消费掉,要不然浏览器响应会卡死
await sendToWormhole(stream)
throw err
}
}
}
module.exports = UploadController
那么上传接口就完成啦。
解决了上述问题之后,上传的坑主要在前端。
vant-ui框架upload组件有个钩子是这么写的:
和大多数UI框架不一样,这里需要自己写上传方法。需要注意以下问题:
multipart/*
(本质上是 multipart/formData
)// 依样画葫芦:
<van-uploader v-model="fileList" :max-count="1" :after-read="upload">
upload方法可以是:
upload(e) {
this.isLoading=true;
let params = { file: e.file };
// 组装formdata
let formData = new FormData();
for (let i in params) {
formData.append(i, params[i]);
}
// 允许携带cookie
const instance = axios.create({
withCredentials: true
});
//请求拦截器
instance.interceptors.request.use(
config => {
const userinfo=JSON.parse(window.localStorage.getItem("mzUser"));
const token=userinfo.token;
if (token) {
// 判断是否存在token,如果存在的话,则每个http header都加上token
// Bearer是JWT的认证头部信息
config.headers.common["Authorization"] = "Bearer " + token;
}
return config;
},
err => {
return Promise.reject(err);
this.isLoading=false
}
);
// 。。。。
}
接下来就可以用instance来发请求了。
另外一方面:上传进度条怎么处理?也是前端的问题。
上传进度是xhr的一个属性,原生js可以这么拿到:
xhr.upload.addEventListener("progress", (e)=>{
console.log(e)
})
vant提供了onUploadProgress方法,你可以直接拿到
instance
.post(this.$api.uploadSingle, formData, {
headers: { "Contnent-type": "multipart/*" },
onUploadProgress: progressEvent => {
var complete = (progressEvent.loaded / progressEvent.total * 100 | 0)
this.progress = complete
}
})
.then(res => {
// balabala
});