工作中接手他人的项目,看到一些 axios 封装很是复杂,难用,现在来总结一下 axios 封装 xhr 的问题。

混乱的实现

在 vue 项目中使用,希望达到下面的效果:

  • 引用方便,在组件中,通过 this.$http[method] 使用;

  • 兼容 REST 风格封装,使用 JSON 进行交互,提供常用的四种方法;

  • 不同请求方法,参数格式一致,this.$http.get(url,params)this.$http.post(url,params)

  • 可进行二次确认,this.$http.delete(url,params,{content:'删除后不可恢复!',type:'danger'})

  • 统一处理错误,同时提供抛出错误的方法,比如 this.$http.getWithError(url),使用时可在函数里捕获错误;

  • 错误时,控制台有特别的日志输出,要是公司有条件,可提交到服务器,方便排查问题;

  • 重复请求,可取消;

  • 用户切换路径,取消请求;

  • 文件下载;

额外的功能

有 mock 服务的,可指定是否走 mock 服务,似乎很多公司都没有这个。

能想到的就是以上了,根据上面的需求,封装 GET 和 POST

封装实现#

基本封装#

import axios from 'axios'
import { logInfo, redLog, blackLog } from '@/utils'
// 创建一个实例,此后都在此实例上改造
const http = axios.create({
  // timeout: 1000 * 4,
  withCredentials: true,
})
// 请求拦截器
const beforeRequest = config => {
  // 设置 token
  const token = localStorage.getItem('token')
  token && (config.headers.Authorization = token)
  // NOTE  添加自定义头部
  config.headers['my-header'] = 'jack'
  return config
}
 
http.interceptors.request.use(beforeRequest)
 
// 响应拦截器
const responseSuccess = response => {
  // eslint-disable-next-line yoda
  // 这里没有必要进行判断,axios 内部已经判断
  // const isOk = 200 <= response.status && response.status < 300
  return Promise.resolve(response.data)
}
 
const responseFailed = error => {
  const { response } = error
  if (response) {
    // handleError(response)
    logInfo(response)
    // cons error = new Error(response.data.msg)
    return Promise.reject()
  } else if (!window.navigator.onLine) {
    redLog('没有网络')
    return Promise.reject(new Error('请检查网络连接'))
  }
  return Promise.reject(error)
}
http.interceptors.response.use(responseSuccess, responseFailed)
 
export default {
  get,
}

关于请求头的指定

axios 根据参数格式,自动采设置content-type:传递对象,设置为 json 提交,传递字符串时,资源设置为 application/x-www-form-urlencoded;charset=UTF-8

希望只传递对象,且不想 axios 自动设置,就手动设置 content-type 为 json

const http = axios.create({
  withCredentials: true,
  headers: {
    'content-type': 'application/json;charset=UTF-8',
  },
})

关于拦截器

可在请求发送之前和返回之后,在拦截器里做一些你想要的操作,比如转化格式,添加自定义的请求头,处理错误,都是在拦截器里操作。这正是我们使用它的理由。

拦截器接收两个函数,第一个 onResolved,第二个为onRejected,他们的作用和 promise.then 的参数一致。

希望在请求发送之前执行某些操作,自请求拦截器里操作。

希望在响应返回 JS 代码之前,执行操作在响应拦截器里操作。

axios 不知道返回我们希望的数据,需要在响应拦截器里处理一下:

const responseSuccess = response => {
  // eslint-disable-next-line yoda
  // 这里没有必要进行判断,axios 内部已经判断
  // const isOk = 200 <= response.status && response.status < 300
  return Promise.resolve(response.data)
}

错误处理,很关键

在响应拦截器里统一处理常见的错误,比如404403、断网等。

争论

只有 status 为 200 --- 299 才是成功吗?

REST 风格主张在接口设计中充分利用 http 语义,使用 http 状态码来表示接口状态,我也喜欢这种方式。有几个好处:

  1. 通用:http 是通用协议,没有额外的沟通成本;
  2. 自带文档:充分利用 http 的语义写 REST 风格的 API,可不写文档。理由:它们已被很多人了解。
  3. 好理解:由于上面的原因,接口好理解。
  4. 方便调试:基于 1、2 的原因,联调很方便。
  5. 对新人友好:新成员加入团队,没有规范文档,没有项目说明,基于前面三点,能让新人快速投入。

需要额外定义错误状态吗?

遵循 REST 风格的 API,额外指定错误状态码,违背了充分利用 http 语义的原则,增加了沟通成本,是有害的。

不额外指定错误状态码,如何指定错误信息?

利用 http 状态码和返回消息表示错误,可减少联调难度,减少沟通。

有些公司路径错误、参数格式不对,状态码都是 200,而且返回的错误信息不具体,随着接口参数字段的增加,调试难度成倍增加,文档还写得垃圾。最后进度延期,就让前端背锅。

http 状态码本身对 http 请求结果进行了分类,而且浏览器会显示错误的请求,外加自定义的信息,可大大降低调试成本。

比如参数不符合预期,就设置状态码为400,在消息里面给出正确的参数提示和用户用户的信息:

{
  msg:'输入不符合要求,请检查',// 给用户看的
  content:'id 必须为数字;date 必需' // 给开发看的
}

额外指定错误状态码,是非常不好的实践。

给出具体的错误信息会带来安全风险?

有人说,给出具体的接口错误信息,有安全风险。这属于后台代码问题,而不是接口设计问题。

好的接口设计应该是易用的、能提高团队协作效率的。

如何在组件种中更方便的使用#

在 main.js 挂载到 vue 的原型上。

Vue.prototype.$http = http // 组件中 使用 this.$http 使用

如何优雅得封装 POST#

优雅是关键词。有些团队使用 GET 或者 POST 来执行一些危险操作,比如删除,再执行前,往往需要用户二次确认,通常的做法:每个需要确认的接口,都写一遍二次确认的代码,这是很不优雅的写法,会导致代码难以理解和维护。

优雅的写法:在接口中传递参数,指定是否需要二次确认。

以 Element UI 的确认框为例子:

import { MessageBox } from 'element-ui'
export const post = (url, params, confirm = false) => {
  return new Promise((resolve, reject) => {
    if (confirm || confirm.confirm) {
      MessageBox.confirm(confirm.confirm || '确认操作吗', '提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning',
      })
        .then(() => {
          // NOTE 不要使用 {} 包裹 params
          resolve(http.post(url, params))
        })
        .catch(_ => {
          console.log('取消请求')
        })
    } else {
      resolve(http.post(url, params))
    }
  })
}

用户点击取消时,执行 catch,执行 reject ,会报错错误,但是这不是错误,所以不做处理。

这些调用:

async postHttp() {
      try {
        const res = await this.$http.post('admin/test', { name: 'jack', age: 24 }, { confirm: '确定发送吗?' })
        console.log(res)
      } catch (error) {
        this.$message(error.msg)
      }
    }

如何处理错误#

当接口出错时,往往需要提示用户,通常的做法是在每个接口调用的 catch 函数里写,比如上面的 this.$message(error.msg)

可以统一处理吗?

可以,在响应拦截器中。

import { Message } from 'element-ui'
 
const message = ({ data }) => {
  Message({ message: data.msg, type: 'error' })
}
const responseFailed = error => {
  const { response } = error
  if (response) {
    // handleError(response)
    message(response)
    logInfo(response)
    // cons error = new Error(response.data.msg)
    return Promise.reject()
  } else if (!window.navigator.onLine) {
    redLog('没有网络')
    return Promise.reject(new Error('请检查网络连接'))
  }
  return Promise.reject(error)
}
 
http.interceptors.response.use(responseSuccess, responseFailed)

message 是错误提示框。

logInfo(response) 是错误输出,方便调试,输出如下:

还有哪些可能的需求?

  1. 提交错误日志到服务器,方便监控状态和复现问题;
  2. 针对不同的错误,进行不同的处理。

401 时,跳转到注册页面,用户注册后跳转回来;

403 时,提示用户用登录,登录后再跳转回来;

断网时,显示断网组件等。

需要再 rejected 错误吗?

如果已经给可能出现的错误,统一处理了,可以不再 reject ,而是resolve()

缺点:

  1. 接口不遵循 RESTful 理念,会增加额外的处理成本。

比如 404 也返回 200,在消息体里使用 code:404 表示没有资源,对 axios 而言,其实是成功的,还需要在响应成功拦截器里编写处理函数。

  1. 使用者会困惑

都 resolve 了,还报错?还没数据?

优点:不用额外处理错误了,在 then 里拿数据即可。

postHttp2() {
      this.$http.post('admin/test', `{ name: 'jack', age: 24 }`)
      .then(res => {
        console.log(res)
      })
    }

可见,还是 reject 好。

其实还可再提供一个 resolve 的接口,外部这样调用。

this.$http.getNoError().then()

如何取消请求#

axios 可取消请求,我使用new CancelToken来取消。

<template>
  <div>
    <button @click="axiosGet">axiosGet 请求</button>
    <button @click="cancelAxiosGet">取消 axiosGet 请求</button>
  </div>
</template>
 
<script>
  import axios from 'axios'
  const CancelToken = axios.CancelToken
  export default {
    name: 'MyRouter',
    data() {
      return {
        cancel: '',
      }
    },
    methods: {
      axiosGet() {
        axios
          .get('admin/test', {
            cancelToken: new CancelToken(c => {
              this.cancel = c
            }),
          })
          .then(res => {
            console.log(res.data)
          })
          .catch(error => {
            if (axios.isCancel(error)) {
              console.log('取消请求')
            } else {
              console.log(error)
            }
          })
          .finally(() => {
            this.cancel = null
          })
      },
      // 取消请求
      cancelAxiosGet() {
        this.cancel && this.cancel('取消请求')
      },
    },
  }
</script>

取消的原理#

new CancelToken(c => {
  this.cancel = c
})

调用 CancelToken,得到一个取消配置,配置有 promise 实例。CancelToken 参数是一个函数,axios 调用该函数时,又传递一个函数,就是取消函数 c, c 又调用取消配置的 promise.resolve,在请求适配器内,检测到 promise 变化,在 then 中执行 xhr 的 reject 方法。

function CancelToken(fn) {
  if (typeof fn !== 'function') {
    throw new TypeError('executor must be a function.')
  }
 
  var resolvePromise
  // NOTE 添加一个 promise 实例
  this.promise = new Promise(function promiseExecutor(resolve) {
    // 函数
    resolvePromise = resolve
  })
 
  var token = this
  fn(function cancel(message) {
    if (token.reason) {
      // Cancellation has already been requested
      return
    }
    token.reason = new Cancel(message)
    resolvePromise(token.reason)
  })
}

可这样调用:

let resole = null
const cancel = new CancelToken(onResoled => {
  resole = onResoled
})
 
resolve()
// 等同一执行
cancel.promise.then(message => {
  //
})

比如:

let myPromise = null
function myXHR() {
  const promise = new Promise(resolve => {
    resole()
  })
  myPromise = promise
  return promise
}
 
// 可以这样执行
myXHR().then(res => {})
// 也可以
myPromise.then()

CancelToken 有一个 promise 属性,可以调用这样调用:

config.cancelToken.promise.then(message => {
  reject(message) // reject 是 xhr 的,而 message 是 cancel 函数传入的。
})

关键源码:

function xhrAdapter(config) {
  return new Promise(function dispatchXhrRequest(resolve, reject) {
    let request = new XMLHttpRequest()
    if (config.cancelToken) {
      config.cancelToken.promise.then(function onCanceled(cancel) {
        // 取消请求
        request.abort()
        reject(cancel)
        // Clean up request
        request = null
      })
    }
  })
}

组件的取消请求,需要用户手动点击按钮,要是能自动判断请求是否重复,取消重复请求才是期望的。

如何取消重复请求#

既然取消的标准还是保存在请求配置里,就可以在拦截器里判断是否重复,然后取消重复。

三种取消重复请求方案:

  1. 只允许最新的请求发送,老的请求都取消

实现关键:在请求拦截器里记录请求时,先判断是否已经存在,存在取消,再添加,保证记录都不同。

优点:多次请求,保证了最新的请求发出,用户拿到的一定是最新的数据,类似防抖

缺点:

①. 由于只有一个请求发出,可能失败,同时,网络慢的话,用户会等待更久。

②. 同时可能后台已经处理请求到一半了,然后请求被取消了,马上又进来一个请求,可能会造成效率不会太高。

只允许最新的请求发出

允许最新的请求发出


  1. 只允许最老的请求发送,新的请求都取消

实现关键:在请求拦截器里记录请求和取消请求,在响应拦截器重置记录,让用户可以再次发出。

优点:多次请求,保证了最老的请求发出,服务器压力小。

缺点:用户拿到的可能不是最新的数据。

发出 14 个,后来的都取消了,第一个成功返回。


  1. 允许多个请求发送,有一个请求成功,取消其他还在处理的请求

实现关键:在请求拦截器记录每个请求,在响应成功拦截器中取消其他请求。

优点:能保证请求至少返回一个,尤其是当单个失败的可能性比较大,允许发出多个,那么用户频繁操作,成功的概率就更大。

缺点:

①. 用户拿到的数据不能保证是最新的。

②. 能否成功取消,由网络速度和这这部分代码执行速度决定,网络比代码快,比如网络 10 毫秒内返回,可能无法请求,原因是第一个网络成功了,第二个请求还没发出,即网络返回了,判断重复的函数还没执行。

重复的请求都发出

重复的请求都发出

有一个请求成功后,其他正在进行的请求取消

有一个请求成功,其他的请求取消

取消重复请求的具体实现#

当 url、method、参数一样,就是相同请求。

第三种方案成功拿到数据的可能性大,它对用户更加友好,就讲它如何实现。

在请求拦截器里记录请求:

// NOTE 用于记录重复的请求
let repeatRequests = {}
 
function generateRequestKey(config) {
  const { method, url, params, data } = config
  const split = '---'
  const array = [`url:`, url, `${split}method:`, method]
  params && array.push(`${split}params:`, qs.stringify(params))
  data && array.push(`${split}data:`, typeof data === 'object' ? JSON.stringify(data) : data)
  return array.join('')
}
 
function addPendingRequest(config) {
  const requestKey = generateRequestKey(config)
  config.cancelToken = new axios.CancelToken(cancel => {
    !repeatRequests[requestKey] && (repeatRequests[requestKey] = [])
    repeatRequests[requestKey].push(cancel)
  })
  return config
}
// 请求拦截器
const beforeRequest = config => {
  // NOTE 记录请求
  addPendingRequest(config)
  return config
}
 
http.interceptors.request.use(beforeRequest)

在响应拦截器的成功方法中取消请求:

function removePendingRequest(config) {
  const requestKey = generateRequestKey(config)
  const needCancel = repeatRequests[requestKey]?.length > 1
  if (needCancel) {
    // 不重复,不取消
    repeatRequests[requestKey].forEach(cancel => {
      cancel(requestKey)
    })
  }
  needCancel ? (repeatRequests[requestKey] = []) : (repeatRequests = {})
}
// 响应拦截器
const responseSuccess = response => {
  removePendingRequest(response.config)
  return Promise.resolve(response.data)
}

高亮部分为关键代码。

如何判断重复请求

对于 post、put 请求, data 的格式在请求时被 axios 根据content-type修改了,因为在请求拦截器和响应拦截器里都要获取请求标识, 都调用了 generateRequestKey, 需要把 data 处理成相同的格式。

所以有:

typeof data === 'object' ? JSON.stringify(data) : data

总结#

优雅地封装 axios 的关键:

  1. 提供快捷方法;

  2. 统一处理错误;

  3. 能二次确认;

  4. 能取消重复请求;

  5. 合理使用拦截器。

实现了关键功能,其他功能可根据需要添加。