10

[toc]

前言

功能点

此文主要是基于vuecli3项目中axios封装及api管理的实践记录及过程中的踩坑收获,功能基本都是根据工作中需求实现。需求背景是,在同一套请求配置下,实现以下主要功能:

  • [x] 自定义请求配置
  • [x] 设置全局拦截器
  • [x] 响应成功及异常的全局拦截统一处理
  • [x] 防止重复请求(取消当前重复的请求)
  • [x] 路由切换取消当前所有pending状态的请求(可配置白名单)
  • [x] 单独取消发出的某个请求
  • [x] api统一管理

axios一些特性

在开始之前,首先明确一些axios的特性,这些特性会影响到某些功能的实现方式:

  1. 通过axios.create()方法创建的实例对象只有常见的数据请求方法,没有取消请求、并发请求等方法。可通过Object.keys()将所有的key打印出来对比得知。
  2. axios拦截器是可以累加的,每添加一个拦截器,就会返回一个对应的拦截器id,也就是无法通过新增拦截的方式覆盖或者改变已有拦截器的配置。但可以利用拦截器id通过axios.interceptors.request.eject(InterceptorId)方法移除指定拦截器。
  3. 对于同一个axios对象,如果全局拦截器中设置了CancelToken属性,就无法在单独的请求中再通过此属性取消请求。移除全局拦截器可以解决这个问题,但又会有另一个问题,拦截器移除后就永远失效了,影响是全局的。
  4. axios中以别名的形式(axios.get、axios.post)发请求,不同的请求方式参数的写法是不一样的,主要是put/post/patch三种方法与其他不太一样

自定义请求配置

根目录下新建plugins/axios/index.js文件,自定义axios的请求配置。

这里process.env.VUE_APP_BASEURL是一个定义好的变量,值为"/webapi";

设置超时时间timeout为10s。如下:

import axios from 'axios'

axios.defaults.baseURL = process.env.VUE_APP_BASEURL
axios.defaults.timeout = 10000
axios.defaults.headers['custom-defined-header-key'] = 'custom-defined-header-value'
// 自定义请求头:对所有请求方法生效
axios.defaults.headers.common['common-defined-key-b'] = 'custom value: for all methods'
// 自定义请求头:只对post方法生效
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded';
// 自定义请求头:只对get方法生效
axios.defaults.headers.get['get-custom-key'] = 'custom value: only for get method';

export default axios

"main.js"文件:

import request from '@/plugins/axios/index.js'

Vue.prototype.$request = request

这样在组件内就可以通过this.$request(options)或者this.$request.get(options)的方法来请求数据了。

对常见的响应情况统一处理

这里主要是在"响应拦截器"中,对于一些常见的请求状态码和跟后端约定好的请求返回码做统一的前置处理。

新建axios.handleResponse.js文件,用于处理常见的正常响应:

# axios.handleResponse.js
// 处理响应错误码
export default (response) => {
    const status = response.status
    // 如果http响应状态码response.status正常,则直接返回数据
    if ((status >= 200 && status <= 300) || status === 304) {
        return response
    }
    // status不正常的话,根据与后端约定好的code,做出对应的提示与处理
    // 返回一个带有code和message属性的对象
    else {
        const code = parseInt(response.data && response.data.code)
        // msg为服务端返回的错误信息,字段名自定义,此处以msg为例
        let message = (response.data || {}).msg

        switch (code) {
            case 400:
                break
            case 4001:
                if (process.server) return
                message = message || '登录设备数量超出限制'
                // store.commit('savehttpResult', { res: response.data })
                break
            case 403:
                message = message || '未登录'
                break
            case 404:
                message = message || '请求地址错误'
                break
            case 412:
                message = message || '未找到有效session'
                break
            default:
                // message = message || err.response.data.msg
                break
        }
        return {
            code,
            message
        }
    }
}

新建plugins/axios/axios.handleError.js文件,用于处理常见的异常响应:

#plugins/axios/axios.handleError.js文件
export default (err) => {
    const { response } = err

    if (!response.status) {
        err.code = ''
        err.message = '有response但没有response.status的情况'
    }
    err.code = response.status
    switch (response.status) {
        case 200:
            err.message = '错误响应也会有状态码为200的情况'
            break
        case 400:
            err.message = '请求错误(400)'
            break
        case 401:
            err.message = '未授权,请重新登录(401)'
            break
        case 403:
            err.message = '拒绝访问(403)'
            break
        case 404:
            err.message = '请求出错(404)'
            break
        case 408:
            err.message = '请求超时(408)'
            break
        case 500:
            err.message = '服务器错误(500)'
            break
        case 501:
            err.message = '服务未实现(501)'
            break
        case 502:
            err.message = '网络错误(502)'
            break
        case 503:
            err.message = '服务不可用(503)'
            break
        case 504:
            err.message = '网络超时(504)'
            break
        case 505:
            err.message = 'HTTP版本不受支持(505)'
            break
        default:
            err.message = `连接出错,状态码:(${err.response.status})!`
    }
    return err
}

plugins/axios/index.js文件中引入并在拦截器中配置:

  • 如果请求被取消,会进入到响应拦截器的第二个参数err处理中
#plugins/axios/index.js文件
import axios from 'axios'
import handleResponse from '@/plugins/axios/axios.handleResponse.js'
import handleError from '@/plugins/axios/axios.handleError.js'
import { Message } from 'element-ui'
const showTip = (tip)=>{
    Message({
        type: 'warning',
        message: tip || '请求出错啦',
        duration: 1500
    })
}
/**
 * 请求拦截
 */
axios.interceptors.request.use(
    (config) => {
        // 在发送请求之前做些什么,例如把用户的登录信息放在请求头上
        // config.headers.common['cookie-id'] = cookieId
        return config
    },
    (err) => {
        // 对请求错误做些什么
        Promise.reject(err)
    }
)
/**
 * 响应拦截
 */
axios.interceptors.response.use(
    (response) => {
        showTip(err.message)
        return Promise.resolve(handleResponse(response)),
    }
    // 对异常响应处理
    (err) => {
        if (!err) return Promise.reject(err)

        if (err.response) {
            err = handleError(err)
        }
        // 没有response(没有状态码)的情况
        // eg: 超时;断网;请求重复被取消;主动取消请求;
        else {
            // 错误信息err传入isCancel方法,可以判断请求是否被取消
            if (axios.isCancel(err)) {
                throw new axios.Cancel(err.message || `请求'${request.config.url}'被取消`)
            } else if (err.stack && err.stack.includes('timeout')) {
                err.message = '请求超时!'
            } else {
                err.message = '连接服务器失败!'
            }
        }
        showTip(err.message)
        return Promise.reject(err)
    }
)

到这里对于一些常见的响应,例如断网、未登录、登录信息失效、超时等,我们可以请求拦截器中通过showTip做出统一的ui提示,就不用每次请求之后再重复得处理这些逻辑了。

接下来就是在"请求拦截器"中配置实现防止重复请求的功能。

防止重复请求

axios提供了两种取消请求的方法:

image-20201005182556755

我们的防止重复请求思路:

在请求拦截器中,通过第二个种方法给每个请求定义cancelToken属性,同时声明一个变量pendingPool,用于并保存pending状态的请求及对应的cancelFn

在响应拦截器中,无论请求成功了还是失败了,都通过api地址将这个请求从pendignPool中删除。

然后每次发起请求前做一个判断,如果pendingPool中没有这个请求,正常发出;如果已存在说明当前请求还是pending状态,那么执行cancelFn取消当前重复的请求。

pendingPool声明为Map类型的数据结构,可以方便得通过set/has/delete等进行判断、删除等操作。key值为api地址,value值为一个对象,保存cancelFnglobalglobal用于后面的路由切换取消所有请求,可以暂时忽略)。pendingPool的大概结构:

Map {

'/home/banner' => { cancelFn: [Function: c], global: false },
  '/login' => { cancelFn: [Function: c], global: false }

}

请求拦截器中可以拿到每个请求的配置信息config,添加cancelToken属性:

#plugins/axios/index.js文件
import axios from 'axios'

// 请求中的api
let pendingPool = new Map()

/**
 * 请求拦截
 */
axios.interceptors.request.use(
    (config) => {
        // 对于异常的响应也需要在pendingPool中将其删除,但响应拦截器中的异常响应有些获取不到请求信息,这里将其保存在实例上
        request.config = Object.assign({}, config)
        // 在发送请求之前做些什么,例如把用户的登录信息放在请求头上
        // config.headers.common['cookie-id'] = cookieId
        config.cancelToken = new axios.CancelToken((cancelFn) => {
            pendingPool.has(config.url) ? cancelFn(`${config.url}请求重复`) : pendingPool.set(config.url, { cancelFn, global: config.global })
        })
        return config
    },
    (err) => {
        console.log('请求拦截err:', err)
        // 对请求错误做些什么
        Promise.reject(err)
    }
)

响应拦截器中对有结果(正常及异常)的请求进行删除:

#plugins/axios/index.js文件

axios.interceptors.response.use(
    // 处理正常响应
    (response) => {
        // 删除
        const { config } = response
        pendingPool.delete(config.url)

        showTip(err.message)
        return Promise.resolve(handleResponse(response))
    },
    // 处理异常响应
    (err) => {
        const { config } = request
        // 异常响应删除需要加一个判断:是否为请求被取消的异常,如果不是才会将这个请求从pendingPool中删除。
        // 否则会出现一种情况:网速非常慢的情况下,在网速非常慢的情况下多次重复发送同一个请求,第一个请求还在pending状态中,
        // 第二个请求发不出去会直接被cancel掉进入到异常响应,然后从pendignPool中删除,第三次请求发出的时候就无法正确判断这个请求是否还是pending状态会正常发出
        if (!axios.isCancel(err)) pendingPool.delete(config.url)

        if (!err) return Promise.reject(err)

        if (err.response) {
            err = handleError(err)
        }
        // 没有response(没有状态码)的情况
        // eg: 超时;断网;请求重复被取消;主动取消请求;
        else {
            // 错误信息err传入isCancel方法,可以判断请求是否被取消
            if (axios.isCancel(err)) {
                throw new axios.Cancel(err.message || `请求'${request.config.url}'被取消`)
            } else if (err.stack && err.stack.includes('timeout')) {
                err.message = '请求超时!'
            } else {
                err.message = '连接服务器失败!'
            }
        }

        // showTip(err.message)
        return Promise.reject(err)
    }
)

到这里就实现了防止重复请求的功能。如果同时发出多个相同的请求,前面请求还在pending状态的情况下,后面发出的请求都会被自动取消并reject到请求的catch处理中。

单独取消指定请求

但实际应用中还有一种情况:需要手动取消指定的某个请求,例如终止文件上传。根据文章开头提到的特性[3]得知,此时我们是无法单独取消某个特定请求的。

又由于特性[1]我们知道,想要单独取消指定请求,这个axios对象需要满足两个条件:1.请求拦截器中不能配置cancelToken 2. 这个axiso对象不能通过axios.create()方法实例化生成。

所以解决思路的大概要点是:

  • 需要两个axios对象分别处理请求防重和单独取消特定请求
  • 两个axiso对象的请求配置像baseURL等请求头信息需要是一样的
  • 直接用axios对象(除了请求头信息,不做任何其他配置)发需要单独取消的请求,这里声明为intactRequest
  • 通过axios.create()方法实例化生成一个新axios对象,做最完善的配置(防止重复请求、清除所有pending状态请求等、响应拦截等),作为主要发请求的对象,这里声明为request

根目录下新建plugins/axios/axios.setConfig.js文件,导出一个自定义axios默认配置的方法:


#"axios.setConfig.js"文件

/**
 * @param {axios} axios实例
 * @param {config} 自定义配置对象,可覆盖掉默认的自定义配置
 */
export default (axios, config = {}) => {

    const defaultConfig = {
        baseURL: process.env.VUE_APP_BASEURL,
        timeout: 10000,
        headers: {
            'Content-Type': 'application/json;charset=UTF-8',
            'custom-defined-header-key': 'custom-defined-header-value',
            // 自定义请求头:对所有请求方法生效
            common: {
                'common-defined-key-b': 'custom value: for all methods'
            },
            // 自定义请求头:只对post方法生效
            post: {
                'post-custom-key': 'custom value: only for post method'
            },
            // 自定义请求头:只对get方法生效
            get: {
                'get-custom-key': 'custom value: only for get method'
            }
        }
    }

    Object.assign(axios.defaults, defaultConfig, config)
    return axios
}

修改plugins/axiso/index.js文件,通过setConfig方法生成两个具有相同请求头信息的axios对象,并且对request对象做请求防重、响应封装处理:

import axios from 'axios'
import setConfig from '@/plugins/axios/axios.setConfig.js'

/**
 * intactRequest是只在axios基础上更改了请求配置。
 * 而request是基于axios创建的实例,实例只有常见的数据请求方法,没有axios.isCancel/ axios.CancelToken等方法,
 * 也就是没有**取消请求**和**批量请求**的方法。
 * 所以如果需要在实例中调用取消某个请求的方法(例如取消上传),请用intactRequest。
 */
let intactRequest = setConfig(axios)
let request = setConfig(intactRequest.create())

// 请求中的api
let pendingPool = new Map()

/**
 * 请求拦截
 */
request.interceptors.request.use(
    //...
)
/**
 * 响应拦截
 */
request.interceptors.response.use(
    // ...
)

export { intactRequest, request }

修改main.js文件,把两个对象都挂载到Vue示实例上:

import Vue from 'vue'
import * as requests from '@/plugins/axios/index'

Vue.prototype.$request = requests.request
Vue.prototype.$intactRequest = requests.intactRequest

这样就实现了通过this.$requese发出的重复请求可以自动被取消掉,并且统一处理一些常见的响应;通过this.$intactRequest发出的请求可以通过在请求中给config.cancelToken设置”cancel token“来手动取消。

一键清除所有pending状态请求

在路由切换时可以取消当前仍在pending状态的请求从而优化性能、节约资源。

上面的pendingPool已经保存了所有pending状态的请求,封装一个方法,拿到其中每个请求然后执行cancelFn,然后每次路由切换的时候执行这个方法即可。但不排除有些api请求是全局的不能被取消。所以这个方法基础上新增白名单和请求的global参数。

plugins/axios/index.js中新增clearPendingPool方法,


/**
 * 清除所有pending状态的请求
 * @param {Array} whiteList 白名单,里面的请求不会被取消
 * 返回值 被取消了的api请求
 */
function clearPendingPool(whiteList = []) {
    if (!pendingPool.size) return

    // const pendingUrlList = [...pendingPool.keys()].filter((url) => !whiteList.includes(url))
    const pendingUrlList = Array.from(pendingPool.keys()).filter((url) => !whiteList.includes(url))
    if (!pendingUrlList.length) return

    pendingUrlList.forEach((pendingUrl) => {
        // 清除掉所有非全局的pending状态下的请求
        if (!pendingPool.get(pendingUrl).global) {
            pendingPool.get(pendingUrl).cancelFn()
            pendingPool.delete(pendingUrl)
        }
    })

    return pendingUrlList
}
request.clearPendingPool = clearPendingPool

在路由的配置文件src/router/idnex.js中,引入request,并在路由全局前置守卫中执行clearPendingPool方法:


import { request } from '@/plugins/axios/index'

// 路由全局前置守卫
router.beforeEach((to, from, next) => {
    // 路由变化时取消当前所有非全局的pending状态的请求
    request.clearPendingPool()
    next()
})

到这里就实现了路由切换取消pending状态的请求。可以通过两种方式指定某些api不被取消:

  1. 执行clearPendingPool时传入一个白名单列表:
const globalApi = [
    '/global/banner',
    '/global/activity'
]
request.clearPendingPool(globalApi)
  1. 发起请求的时候携带global参数,默认为false:
this.$request.get('/global/banner',{
    params:{page: 1},
    global: true
})

this.$request.post('/user/login',{
    name: 'xxx',
    pwd:'123456'
},{
    global:  true
})

移除拦截器

根据特性[2]知道拦截器是可以累加也可以移除的。封装两个移除的全局拦截的方法并挂载到request对象上。目前看来还没有实际应用场景,只是做一下记录。

plugins/axios/index.js文件中新增两个方法:


/**
 * 请求拦截
 */
const requestInterceptorId = request.interceptors.request.use(
    // ...    
)
/**
 * 响应拦截
 */
const responseInterceptorId = request.interceptors.response.use(
    // ...    
)
    
// 移除全局的请求拦截器
function removeRequestInterceptors() {
    request.interceptors.request.eject(requestInterceptorId)
}

// 移除全局的响应拦截器
function removeResponseInterceptors() {
    request.interceptors.response.eject(responseInterceptorId)
}

request.removeRequestInterceptors = removeRequestInterceptors
request.removeResponseInterceptors = removeResponseInterceptors

通过this.$request.removeRequestInterceptorsthis.$request.removeResponseInterceptors调用即可。

api管理

这里我们把所有的api地址及对应的请求方式放在一起管理,然后组件中通过别名直接调用即可。

先具体了解一下特性[3]提到aixos不同的请求方式参数的写法问题:主要由于postputpatch三种方法相比其他方法多一个data属性,也就是需要在请求体中携带的数据,其他方法会自动忽略data属性;而params属性是所有方法都有的,与请求一起发送的 URL 参数。

axios发起请求大概有两种写法:

  1. 直接通过axios:axios(config)的形式
  2. 通过axios别名的形式:axiso[method]()

通过别名的形式发起请求,postputpatch三种方法需要接收三个参数:axios[method](api, data, headersConfig),第二个参数data就是需要在请求体中携带的参数;而且他方法只接收两个参数,不需要第二个参数data

dfsdfsf

根目录下新建一个src/api/index.js文件:

/**api管理页面
 * apiMap: 统一管理所有api地址、对应的请求方式及自定义别名
 * 导出一个对象requestMap,属性名为apiMap中定义的别名,也就是调用时的名称,值为实际请求方法
 * 方法接收两个对象参数,第一个为需要传递的数据,第二个为请求头的配置信息。
 * 语法: api[alias](paramsOrData, headersConfig)
 * 第一个参数:如果为put/post/patch方法中的一种,会被转化为data属性;其余则是params
 * 第二个参数:请求头信息
 *
 * let xx = await this.$api.getBanner({ account: '18038018084', psw: '2' })
 * let vv = await this.$api.login({ account: '18038018084', psw: '2' })
 *
 * 如果类似post的方法需要通过url后缀形式传递参数,在第二个参数config加上params属性即可:
 * let vv = await this.$api.login({ account: '18038018084', psw: '2' },{ params: {} })
 *
 * 自定义请求头信息:
 * let xx = await this.$api.getBanner({}, {timeout: 1000, headers:{ aaa: 111 }})
 */
import { request } from '@/plugins/axios/index'
// import qs from 'qs'
// console.log('qs:', qs)

const apiMap = {
    getBanner: { method: 'get', url: '/home/banner' },
    login: { method: 'post', url: '/login' }
}

function injectRequest(apiObj) {
    const requestMap = {}
    Object.keys(apiObj).forEach((alias) => {
        let { method, url, config } = apiObj[alias]
        method = method.toUpperCase()
        requestMap[alias] = (dataOrParams = {}, instanceConf = {}) => {
            const keyName = ['PUT', 'POST', 'PATCH'].includes(method) ? 'data' : 'params'
            return request({
                method,
                url,
                // [keyName]: method === 'POST' ? qs.stringify(dataOrParams) : dataOrParams,
                [keyName]: dataOrParams,
                ...Object.assign(config || {}, instanceConf)
            })
        }
    })
    return requestMap
}

export default injectRequest(apiMap)

mains.js中引入并挂载到vue实例:

import Vue from 'vue'
import api from '@/api/index.js'

Vue.prototype.$api = api

调用示例:


// 请求头信息
const headersConfig = {
    timeout: 5000,
    global: true,
    headers:{
        aaa: 'vvv'
    }
}

// get请求
this.$api.getBanner({
    page: 1,
})
// get请求,自定义请求头信息
this.$api.getBanner({
    page: 1,
}, headersConfig)

// post请求
this.$api.login({
    account: 'laowang',
    pwd: 'xxxx'
})

// post请求,自定义请求头信息
this.$api.login({
    account: 'laowang',
    pwd: 'xxxx'
}, headersConfig)

总结

最终项目中相关文件的目录结构:

├── CHANGELOG.md
├── README.md
├── package.json
├── src
│   ├── api
│   │   └── index.js  // api管理
│   ├── plugins
│   │   ├── axios  // axios封装
│   │   │   ├── axios.handleError.js
│   │   │   ├── axios.handleResponse.js
│   │   │   ├── axios.setConfig.js
│   │   │   └── index.js
│   ├── router
│   │   ├── index.js
└── yarn.lock

src/plugins/axios/index.js文件最终:

import axios from 'axios'
import setConfig from '@/plugins/axios/axios.setConfig.js'
import handleResponse from '@/plugins/axios/axios.handleResponse.js'
import handleError from '@/plugins/axios/axios.handleError.js'
// import store from '@/store/index'
// import router from '@/router/index.js'
import { Message } from 'element-ui'
const showTip = (tip)=>{
    Message({
        type: 'warning',
        message: tip || '请求出错啦',
        duration: 1500
    })
}

/**
 * intactRequest是只在axios基础上更改了请求配置。
 * 而request是基于axios创建的实例,实例只有常见的数据请求方法,没有axios.isCancel/ axios.CancelToken等方法,
 * 也就是没有**取消请求**和**批量请求**的方法。
 * 所以如果需要在实例中调用取消某个请求的方法(例如取消上传),请用intactRequest。
 */
let intactRequest = setConfig(axios)
let request = setConfig(intactRequest.create())

// 请求中的api
let pendingPool = new Map()

/**
 * 请求拦截
 */
const requestInterceptorId = request.interceptors.request.use(
    (config) => {
        // 对于异常的响应也需要在pendingPool中将其删除,但响应拦截器中的异常响应有些获取不到请求信息,这里将其保存在实例上
        request.config = Object.assign({}, config)
        // 在发送请求之前做些什么
        // config.headers.common['cookie-id'] = cookieId
        config.cancelToken = new axios.CancelToken((cancelFn) => {
            pendingPool.has(config.url) ? cancelFn(`${config.url}请求重复`) : pendingPool.set(config.url, { cancelFn, global: config.global })
        })
        return config
    },
    (err) => {
        console.log('请求拦截err:', err)
        // 对请求错误做些什么
        return Promise.reject(err)
    }
)
/**
 * 响应拦截
 */
const responseInterceptorId = request.interceptors.response.use(
    (response) => {
        const { config } = response
        pendingPool.delete(config.url)

        // console.log('响应response suc:', response)
        showTip(err.message)
        return Promise.resolve(handleResponse(response))
    },
    // 对异常响应处理
    (err) => {
        const { config } = request
        if (!axios.isCancel(err)) pendingPool.delete(config.url)

        if (!err) return Promise.reject(err)

        if (err.response) {
            err = handleError(err)
        }
        // 没有response(没有状态码)的情况
        // eg: 超时;断网;请求重复被取消;主动取消请求;
        else {
            // 错误信息err传入isCancel方法,可以判断请求是否被取消
            if (axios.isCancel(err)) {
                throw new axios.Cancel(err.message || `请求'${request.config.url}'被取消`)
            } else if (err.stack && err.stack.includes('timeout')) {
                err.message = '请求超时!'
            } else {
                err.message = '连接服务器失败!'
            }
        }

        showTip(err.message)
        return Promise.reject(err)
    }
)

// 移除全局的请求拦截器
function removeRequestInterceptor() {
    request.interceptors.request.eject(requestInterceptorId)
}

// 移除全局的响应拦截器
function removeResponseInterceptor() {
    request.interceptors.response.eject(responseInterceptorId)
}

/**
 * 清除所有pending状态的请求
 * @param {Array} whiteList 白名单,里面的请求不会被取消
 * 返回值 被取消了的api请求
 */
function clearPendingPool(whiteList = []) {
    if (!pendingPool.size) return

    // const pendingUrlList = [...pendingPool.keys()].filter((url) => !whiteList.includes(url))
    const pendingUrlList = Array.from(pendingPool.keys()).filter((url) => !whiteList.includes(url))
    if (!pendingUrlList.length) return

    pendingUrlList.forEach((pendingUrl) => {
        // 清除掉所有非全局的pending状态下的请求
        if (!pendingPool.get(pendingUrl).global) {
            pendingPool.get(pendingUrl).cancelFn()
            pendingPool.delete(pendingUrl)
        }
    })

    return pendingUrlList
}

request.removeRequestInterceptor = removeRequestInterceptor
request.removeResponseInterceptor = removeResponseInterceptor
request.clearPendingPool = clearPendingPool


export { intactRequest, request }
限时秒杀阿里云服务器ECS、云数据库MySQL、对象存储OSS等多种代金券

cxd


崔小叨
169 声望7 粉丝

No Bug