专栏首页一Li小麦那些年初级前后端一起撕过的逼

那些年初级前后端一起撕过的逼

一个项目一开始总是出于还不错愿景,但做着做着,就越来越乱了。万丈高楼平地起,有些基础的问题解决好,后面改需求就不会那么痛苦了。

在笔者之前的工作经历中,遇到用户上传(跨域+鉴权+上传)的扯皮多了去了。现在就尝试用标准的姿态,更加前端的角度去回答这几个问题。

写了好多天原理,现在就来实战一下吧。这是我个人项目中的一个商城,基于以下技术栈:

- vue
- vant
- router
- vuex
- axios

后端沿用用上篇文章的egg+mongo。

虽然笔者主要使用的是react,但作为一手得来的经验,文章内容比很多使用vue的初级工程师要深入的多。

跨域

[前端]vue配置跨域

前端配置跨域,在根目录新建 vue.config.js

module.exports = {
  devServer: {
    proxy: 'http://localhost:7001'
  }
}

以上是研发环境配置跨域。

[后端]egg配置跨域

后端沿袭上一篇的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(token生成校验)

在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(接口)

在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()
  }
});

[前端]请求方法的封装(lib/http,api)

我们要封装一个请求方法,实现以下目的:

  • 把token带到header里去!
  • 对路由状态进行异常判断和处理;
  • 足够的业务覆盖面;
  • 很好地获取。

简单说就是一个具有路由拦截器功能的请求库。

在header里带上她的token

先来实现第一个目标:

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过期的判断。(在此不妨把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

接下来看第三个目标:封装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就结束了。

挂载到vm

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留的问题

解决了上述问题之后,上传的坑主要在前端。

vant-ui框架upload组件有个钩子是这么写的:

和大多数UI框架不一样,这里需要自己写上传方法。需要注意以下问题:

  • content-type必须是 multipart/*(本质上是 multipart/formData
  • 必须带上token
// 依样画葫芦:
<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
        });

本文分享自微信公众号 - 一Li小麦(gh_c88159ec1309),作者:一li小麦

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2019-07-24

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 基于koa定制属于自己的企业级框架

    笔者前公司用的是think.js作为后端框架,初次使用,深感业务场景的傻瓜式。它就是一个基于koa二次开发。一个显著的特点就是可以在对应文件夹下直接书写接口。比...

    一粒小麦
  • 前后端权限机制

    本项目使用vue全家桶,axios和cube-ui cube-ui文档地址:https://didi.github.io/cube-ui/#/zh-CN/doc...

    一粒小麦
  • JavaScript设计模式之组合模式

    一个公司,可能分为很多个事业部,然后事业部又分为不同的部门。每个部门可能又分为不同的方向,每个方向又由不同的项目组组成。在程序设计中,也有一些和“事物是由相似的...

    一粒小麦
  • 基于koa定制属于自己的企业级框架

    笔者前公司用的是think.js作为后端框架,初次使用,深感业务场景的傻瓜式。它就是一个基于koa二次开发。一个显著的特点就是可以在对应文件夹下直接书写接口。比...

    一粒小麦
  • JWT的TOKEN续期功能

    JWT里有一个关键的东东,就是续期TOKEN,即TOKEN快过期时,刷新一个新的TOKEN给客户端. 办法如下: 1.后端生成TOKEN

    星痕
  • [译] 用 NodeJS/JWT/Vue 实现基于角色的授权

    在本教程中,我们将完成一个关于如何在 Node.js 中 使用 JavaScript ,并结合 JWT 认证,实现基于角色(role based)授权/访问的简...

    江米小枣
  • 高精度(正整数的加、减、乘法)

    饶文津
  • 单细胞转录组的肿瘤研究3大应用方向等你来攻克

    这些年陆陆续续阅读了近百篇该领域的CNS文献,所以我大概总结了单细胞转录组技术肿瘤研究3大应用方向

    生信技能树
  • 小白博客 sqlmap之POST登陆框注入方式二【自动搜索表单的方式】

    sqlmap.py -u "http://192.168.160.1/sqltest/post.php" --forms 它会有几次消息提示: ? ...

    奶糖味的代言
  • [教程] 使用 Embark 开发投票 DApp

    前面我们基于Embark Demo[1] 介绍了 Embark 框架,今天使用 Embark 来实实在在开发一个 DApp:从零开发开发一个投票DApp。

    Tiny熊

扫码关注云+社区

领取腾讯云代金券