1.前言

Web开发中,我们最常用的HTTP库就是Axios了,它的底层基于Ajax进行封装,在浏览器端和服务端都可以使用。如果大家对一些API不是很熟悉可以参考官方地址,或者中文社区。虽然现在网上已经有很多基于Axios封装的文章了,但是不妨碍我来接受社区大佬的“棒打”。

2.涉及到的业务场景

  • 多环境:开发、测试、生产环境。
  • 统一错误处理:401、404、500等错误。
  • 断网、请求超时处理。
  • 请求取消/请求拦截:防止重复请求发送到服务端,造成服务端压力。
  • 请求权限:某些接口必须要有登录状态才可以访问。

当然涉及到的场景肯定不会只有这些,实际需要根据业务场景进行更好地配置。

3.如何封装

这里为了方便,我使用了ES6中的Class特性来抽象出一个HttpRequest“类”(JS没有真正的类),它的属性有:

  • baseURL:当前请求的根路径
  • pending: 一个对象,它使用请求API的URL来作为key来存储cancelToken

方法有:

  • getDefaultConfig: 存放默认配置
  • createAxiosInstance: 创建Axios实例
  • interceptors: 配置拦截器
  • cancelKeyManager: cancelKey管理器
  • handleRequestCancel: 处理请求拦截和请求取消
  • removeRequest: 移除请求
  • mergeOptions: 工具函数,合并对象
  • get: get请求方法
  • post: post请求方法

接下来我们开始封装吧~

3.1 统一baseURL管理

其实这一块有很多方法来设置baseURL,比如在process.env定义NODE_ENV,使用webpack.DefinePlugin设置运行时的全局变量(这一部分Vue CLI 已经内置了),也可以使用Vue中的.env.[mode]文件来设置(具体可以参考这里)。

我选择是前一种,在src目录下创建一个config文件夹存放项目运行时所需要的配置(编译时的配置在vue.config.js配置)。

src/config/index.js

export default {
  baseURL: {
    dev: 'http://localhost:3000',
    test: 'http://ip1:port1',
    prod: 'http://ip2:port2'
  }
}

这样我们只需要通过process.env.NODE_ENV来应用不用的变量就可以啦。

src/utils/axios.js

import config from '@/config'
import HttpRequest from './HttpRequest'

// 根据当前环境获取API根目录
const baseURL = process.env.NODE_ENV === 'production' ? config.baseURL.prod : config.baseURL.dev
// 创建一个HtpRequest对象实例
const axios = new HttpRequest(baseURL)

export default axios

上面中的HttpRequest是对axios的封装,接下来完善HttpRequest这个“类”。

3.2 定义数据属性

class HttpRequest {
  // * 设置默认值为空方便使用devServer代理
  constructor (baseURL = '') {
    this.baseURL = baseURL
    this.pending = {} // 存储每次请求的cancel函数
  }
}

上面我给了baseURL一个默认空字符串,这是因为我们在开发阶段可能会用到代理,如果baseURL不为空,那么代理设置的请求路径就会失效。

3.2 设置默认配置

/**
 *  axios默认配置
 *
 * @return {Object} axios默认配置
 * @memberof HttpRequest
 */
getDefaultConfig () {
  return {
    baseURL: this.baseURL, // API根路径
    headers: {
      // 每次请求带上token
      common: {
        Authorization: `Bearer ${store.state.token}` || ''
      },
      post: {
        'Content-Type': 'application/json; charset=utf-8'
      }
    },
    timeout: 10 * 1000 // 请求超时时间:10s后请求失败
  }
}

上面是Axios的默认请求配置,这里我配置了项目常用的配置,这里说一下Authorization,这个HTTP请求头的作用主要是鉴权,告诉服务器当前请求是否有权限。我这里使用了JWT鉴权机制,所以需要携带token,token放在vuexlocalstorage中存储,所以需要引入Vuex

import store from '@/store'

此外,我还设置了post默认实体请求头为application/json,编码utf-8,这是我们在Web开发中最常用的数据格式了,当然如果是表单类型的提交需要在实际请求中修改实体请求头为:'multipart/form-data'或者'application/x-www-form-urlencoded',同时前者在请求时需要提交一个FormData对象(data:new FormData),而后者可以引入qs库把对象转为这种格式:key1=1value1&key2=value2

3.3 创建Axios实例

有了前面的准备,接下来我们需要借助Axios的create方法来创建一个Axios实例,传入配置然后配置拦截器。

/**
   *
   * 创建Axios实例
   * @param {Object} options 用户传进来的配置
   * @return {Axios} 返回axios实例
   * @memberof HttpRequest
   */
  createAxiosInstance (options) {
    const axiosInstance = axios.create()
    // 将默认配置和用户设置的配置进行合并
    const newOptions = this.mergeOptions(this.getDefaultConfig(), options)
    // 调用拦截器
    this.interceptors(axiosInstance)
    // 返回实例
    return axiosInstance(newOptions)
  }

createAxiosInstance这个方法中,我主要创建了一个实例,并且将默认配置和用户传进来的配置进行了合并,然后调用拦截器,对请求和响应拦截进行配置。mergeOptions这个方法的作用主要是用来合并两个对象:

/**
   * 合并配置
   *
   * @param {Object} default 已有配置
   * @param {Object} source 传入的配置
   * @return {Object} 返回新配置
   * @memberof HttpRequest
   */
  mergeOptions (default, source) {
    if (typeof source !== 'object' || source == null) {
      return default
    }
    return Object.assign(default, source)
  }

3.4 配置拦截器

拦截器主要是在请求前或响应前进行的一些操作,比如请求前可以进行判断是否为重复请求,也可以对所有请求统一加上格式化参数,我觉得这是Axios这个库十分强大的功能。在拦截器中我主要是干一件事情:取消或拦截重复请求。

我们可以设想一个场景:

一个首页列表,有不同分类,有页码,用户可以点击分类来筛选显示不同数据,或者点击分页跳到下一页。如果用户频繁切换分类或者说短时间频繁点击下一页,那么此时发起了很多get请求,它们虽然参数不一样,但其实请求了服务器的同一个接口,那这时候可能会给服务器带来压力(假设服务器支持的并发数有限),而且在网络不好的情况下,如果后发送的请求比先发送的请求先响应,那么本应该展示是后发送请求的数据被之前的数据覆盖了。我们假设请求是这样的:

/list?catalog=front&page=0
/list?catalog=backend&page=1
/list?catalog=backend&page=2
...

那此时我们希望的是切换分类的时候之前的请求如果还没响应就取消掉,这就叫请求取消

我们再来来看另一个场景:

假设用户在填写表单,网页一直没显示“提交成功”的相关提示,他以为没提交过去就疯狂点击提交(可能网络不好),结果就是发起了大量的重复的请求(参数一致),假设服务器也没处理,那么数据库短时间内会出现很多重复数据。我们假设请求(post)是这样的:

/submit

// payload: application/json格式
{
  phone: 13312432576,
  isHandSome: true,
  city: 'Beijing'
}

那么这种请求我们需要拦截后发送的请求,等待处理完前一个请求再去发送下一个请求。这种称为请求拦截

我们可以使用Axios提供的CancelToken函数并配合拦截器来处理这两种情况。这个函数接收一个executor执行器函参,这个executor是内部定义的,而executor又接收一个cancel函参,我们可以借助它来对请求进行拦截或取消。下面是它的用法(之所以没有用source来创建,是因为下面这种方式可以为每个请求创建属于自己的cancel函数):

const CancelToken = axios.CancelToken;
let cancel;

axios.get('/user/12345', {
  cancelToken: new CancelToken(function executor(c) {
    // executor 函数接收一个 cancel 函数作为参数
    cancel = c;
  })
});

// cancel the request
cancel();

回到我们的需求,我们可以在HttpRequest这个“类”中定义一个pending对象存储每一次的请求,将请求的URL作为key,值就是cancel函数,而我们需要针对请求取消请求拦截两种情况设置不同的key,这里我定义了一个cancelKeyManager方法管理不同类型的key:

/**
   * cancelKey管理器
   *
   * @return {Object} 返回一个对象,对象暴露两个方法,一个可以获取本次请求的key,一个是设置本次请求的key
   * @memberof HttpRequest
   */
  cancelKeyManager () {
    const expose = {}
    expose.setKey = function setKey (config, isUniqueUrl = false) {
      const { method, url, params, data } = config
      expose.key = `${method}&${url}`
      // 主要针对用户频繁切换分类、请求下一页的情况,拦截已经发出去的请求
      if (!isUniqueUrl) {
        return
      }
      // 主要针对同一个请求,比如请求验证码、提交表单,主要用来取消当前请求,等上一次请求完事再发送
      expose.key = method === 'get' ? `${expose.key}&${qs.stringify(params)}` : `${expose.key}&${qs.stringify(data)}`
    }
    expose.getKey = function getKey () {
      return expose.key
    }
    return expose
  }

再强调一下下:“请求取消”取消的是相同的请求,参数不一样,“请求拦截”拦截的是完全一样的请求。在上述的setKey方法中,我通过isUniqueUrl来判断当前是“请求取消”还是“请求拦截”。

有了key后,我们需要根据情况来决定是否来拦截或取消本次请求。

对于“请求取消”,那现在的逻辑是,如果本次请求的key已经存在pending中,那么我们需要移除pendig中的key和value,同时设置本次请求的key和value,请求响应后清除key和value。

对于“请求拦截”,那现在的逻辑是,如果本次请求的key已经存在pending中,那么我们需要调用本次请求的cancel函数把本次请求拦截,请求响应后清除key和value。

下面是拦截器相关代码:

/**
   * 拦截器
   *
   * @param {Axios} instance
   * @memberof HttpRequest
   */
  interceptors (instance) {
    // 添加请求拦截器
    instance.interceptors.request.use((config) => {
      // 对请求进行拦截
      // 如果post请求格式是application/x-www-form-urlencoded,则序列化数据
      if (
        config.headers.post['Content-Type'].startsWith('application/x-www-form-urlencoded') &&
          config.method === 'post'
      ) {
        config.data = qs.stringify(config.data)
      }
      // 处理请求拦截和请求取消两种情况, 默认情况是请求取消,即取消已经发出去的请求
      // 传入第二个参数为false为拦截本次请求,等上一次请求响应后再发送
      config = this.handleRequestCancel(config)
      // 根据后台的要求可以返回格式化的json
      config.url = `${config.url}?pretty`
      return config
    }, (error) => {
      // 对请求错误做些什么
      errorHandle(error)
      return Promise.reject(error)
    })

    // 响应拦截器
    instance.interceptors.response.use((res) => {
      // 获取本次请求的key
      const manager = this.cancelKeyManager()
      const key = manager.getKey()
      // 清除pending中保存的key,来表明这个请求已经响应
      this.removeRequest(key, false)
      // axios正常响应
      if (res.status === 200) {
        return Promise.resolve(res.data)
      }
      return Promise.reject(res)
    }, (error) => {
      // 对响应错误做点什么
      errorHandle(error)
      return Promise.reject(error)
    })
  }

注意请求拦截器中我还对请求做了两个操作:如果post请求头中的Content-Typeapplication/x-www-form-urlencoded,那么序列化参数;对所有请求再加上pretty参数,这样返回的数据都是格式化后的JSON数据。

设置“请求拦截”和“请求取消”的key和cancelToken:

/**
   *处理请求拦截和请求取消
   *
   * @param {object} config axios配置对象
   * @param {boolean} [isCancel=true] 标识是请求取消还是拦截请求
   * @return {object} 返回axios配置对象
   * @memberof HttpRequest
   */
  handleRequestCancel (config, isCancel = true) {
    // 设置本次请求的key
    const { setKey, getKey } = this.cancelKeyManager()
    setKey(config, true)
    const key = getKey()
    const CancelToken = axios.CancelToken
    // 取消已经发出去的请求
    if (isCancel) {
      this.removeRequest(key, true)
      // 设置本次请求的cancelToken
      config.cancelToken = new CancelToken(c => {
        this.pending[key] = c
      })
    } else {
      // 拦截本次请求
      config.cancelToken = new CancelToken(c => {
        // 将本次的cancel函数传进去
        this.removeRequest(key, true, c)
      })
    }

    return config
  }

移除请求的操作:

/**
   * 移除请求
   *
   * @param {string} key 标识请求的key
   * @param {boolean} [isRequest=false] 标识当前函数在请求拦截器调用还是响应拦截器调用
   * @param {function} c cancel函数
   * @memberof HttpRequest
   */
  removeRequest (key, isRequest = false, c) {
    // 请求前先判断当前请求是否在pending中,如果存在有两种情况:
    // 1. 上次请求还未响应,本次的请求被判为重复请求,则调用cancel方法拦截本次重复请求或者取消上一个请求
    // 2. 上次请求已经响应,在response中被调用,清除key
    if (this.pending[key]) {
      if (isRequest) {
        const msg = '您的操作过于频繁,请您稍后再试'
        c ? c(msg) : this.pending[key](msg)
      } else {
        // 上一次请求在成功响应后调用cancel函数删除key
        delete this.pending[key]
      }
    }
  }

3.6 统一错误处理

到这里我们的拦截器已经配置完成了,里面还有一个统一错误处理的操作,统一错误处理分为两种情况,请求响应了,但是会出现4xx、5xx等HTTP状态码的错误,另外一个就是断网、请求超时等请求没有发出去的情况,接下来我们看看如何处理错误:

src/utils/errorHandle.js

import MessageBox from '@/components/messageBox/src/local'
/**
 * axios统一错误处理主要针对HTTP状态码错误
 * @param {Object} err
 */
function errorHandle (err) {
  // 判断服务器是否响应了
  if (err.response) {
    switch (err.response.status) {
      // 用户无权限访问接口
      case 401:
        MessageBox.$alert('请您先登录~')
        break
      // 请求的资源不存在
      case 404:
        // 处理404
        MessageBox.$alert('您请求的资源不存在呢~')
        break
      // 服务器500错误
      case 500:
        MessageBox.$alert('服务器异常,请稍后再试哦~')
        break
    }
  } else if (
    err.code === 'ECONNABORTED' ||
    err.message === 'Network Error' ||
    err.message.includes('timeout') ||
    !window.navigator.onLine
  ) {
    // 处理超时和断网
    MessageBox.$alert('网络已断开,请查看你的网络连接~')
  } else {
    // 进行其他处理 
    console.log(err.stack)
  }
}

export default errorHandle

上面的MessageBox是我自定义的弹唱组件,提示给用户的消息,大家也可以使用UI框架的组件。最后还有一种错误是请求响应了,而且HTTP状态码为2xx的请求,这种通常为参数错误或者其他客户端错误,那么我们需要和后端商量好字段,再进行处理。

3.7 配置请求(GET、POST)

有了前面的配置后,接下来我对我们常用的GETPOST请求方法来应用上面的配置:

/**
   * get方法封装
   *
   * @param {String} url 请求地址
   * @param {Object} query 请求参数
   * @param {Object} config 请求配置
   * @return {Promise} 返回一个Promise
   * @memberof HttpRequest
   */
  get (url, query, config = {}) {
    return this.createAxiosInstance({
      url,
      method: 'get',
      params: query,
      ...config
    })
  }

  /**
   * post方法封装
   *
   * @param {String} url 请求地址
   * @param {Object} data 请求体数据
   * @param {Object} config 请求配置
   * @return {Promise} 返回一个Promise
   * @memberof HttpRequest
   */
  post (url, data, config = {}) {
    return this.createAxiosInstance({
      url,
      method: 'post',
      data,
      ...config
    })
  }
}

其他请求方法也是类似的,都可以根据上面的格式来写。

3.8 API统一管理

配置完后,我们需要统一管理API,在src目录下创建一个api目录来管理API,然后按模块来管理,通过index.js把API暴露出去:

src/api/login.js

import axios from '@/utils/axios'

// 注册
const reg = (data) => axios.post('/api/reg', data)

export default {
  reg
}

src/api/content.js

import axios from '@/utils/axios'


// 获取列表
const getList = (query, config) => axios.get('/api/list', query, config)

export default {
  getList
}

src/api/index.js

import login from './login'
import content from './content'

export default {
  login,
  content
}

最后我们把这个对象挂载到Vue原型上,以后在其他组件直接通过this来调用,这就十分方便了:

import api from '@/api' // 导入全局接口
Vue.prototype.$api = api

到这里一个很简陋的Axios封装就完成啦~

4.总结

本文主要从一些基础业务出发,对Axios进行了二次封装,从而让我们更好地管理项目,把时间更多地放在业务开发上,节来省更多的时间,希望我的拙见对您有所帮助。本人从网上学习到自己改进和总结这个过程中学习到了很多东西,Axios这个库是真的强大,我目前只看过CancelToken的源码,将来我会把源码通读一遍,好好学习它的代码设计。