Vue 封装axios的思路解析

209 阅读6分钟

引子

axios是一个基于promise的HTTP库,可以使用在浏览器或node.js中。

axios提供两个http请求适配器,XHR和HTTP。XHR的核心是浏览器端的XMLHttpRequest对象; HTTP的核心是node的http.request()方法

axios有那些特性

  • 浏览器中创建XMLHttpRequests
  • node.js创建http请求
  • 支持Promise API
  • 拦截请求和响应
  • 转换请求数据和响应数据
  • 取消请求
  • 自动转换JSON数据
  • 客户端支持防御XSRF

为啥要封装axios

  • 环境区分
  • 请求头信息
  • 请求类型
  • 请求超过时间
    • timeout:3000
  • 允许携带cookie
    • withCredentials:true
  • 响应结果处理
    • 登录校验失败
    • 无权限
    • 成功
环境区分

利用node环境变量及webpack代理作判断,用来区分开发环境、测试环境、生产环境。配置nginx代理


const targetApi = 
  process.env.NODE_ENV === 'development' 
  ? 'http://www.kaifa.com' 
  :(process.env.NODE_ENV === 'prod' ? 'http://www.prod.com':'http://www.ceshi.com'// 本地调试时,需要在vue.config.js文件中配置
// devServer实现代理,解决跨域问题
module.exports = {
   devServer:{
      proxy:{
         '/api':{   // 代理地址
           target:targetApi,
           changeOrigin:true,
           pathRewrite:{
             '/api':''
           }
         }
      }
   }
}

请求头

常见有三种

  • application/json 参数会直接放在请求体中,以JSON格式的发送到后端。

截屏2022-02-23 08.32.13.png

  • applocation/x-www-form-urlencoded 请求体中的数据会以普通表单形式(键值对)发送到后端

截屏2022-02-23 08.33.51.png

  • multipart/form-data 参数会在请求体中,以标签为单元,用于分隔符分开。通常被用来上传文件的格式

截屏2022-02-23 08.35.08.png

// src/utils/https.js
import axios from 'axios'
import qs from 'qs'

const contentTypes = {
  json:'application/json;charset=utf-8',
  urlencoded:'application/x-www-form-urlencoded;charset=utf-8',
  multipart:'multipart/from-data',
}
const defaultOptions = { 
  headers: { 
    Accept: 'application/json', 
    'Content-Type': contentTypes.json, 
  } 
} 
export const callApi = ({ 
   url, 
   data = {}, 
   options = {}, 
   contentType = 'json', // json || urlencoded || multipart 
   prefixUrl = 'api'
}) => { 
   
  const newOptions = { 
   ...defaultOptions, 
   ...options, 
   headers: { 
     'Content-Type': options.headers && options.headers['Content-Type'] ||     contentTypes[contentType], 
   }, 
}

const { method } = newOptions 

if (method !== 'get' && method !== 'head') { 
   if (data instanceof FormData) { 
      newOptions.data = data 
      newOptions.headers = { 
         'x-requested-with': 'XMLHttpRequest', 
         'cache-control': 'no-cache', 
   } 
} else if (options.headers['Content-Type'] === contentTypes.urlencoded) {       newOptions.data = qs.stringify(data) 
} else { 
   Object.keys(data).forEach((item) => { 
         if ( 
             data[item] === null || 
             data[item] === undefined || 
             data[item] === '' 
          ) { 
             delete data[item] 
   } 
})
// 没有必要,因为axios会将JavaScript对象序列化为JSON 
// newOptions.data = JSON.stringify(data); 
} 
} 
return axios({ 
    url: fullUrl, 
    ...newOptions, 
}) 
}

请求类型

如上

请求超时时间

// src/utils/https.js

const defaultOptions = {
   timeout:3000,
}

允许携带cookie

//  src/utils/https.js
const defaultOptions = {
   withCredentials:true,
}

响应结果处理

通过.then、.catch()处理。需要根服务端约定接口响应全局码,统一处理登录校验失败、无权限、成功等结果。

// src/utils/http.js

import axios from 'axios'
import {Message} from 'element-ui'

export const callApi = ({
   ...
})=>{
   ...
   return axios ({
      url:fullUrl,
      ...newOptions,
   })
   .then((response)=>{
      const {data} = response
      if(data.code === 'xxx'){
        // 登录校验失败
      }else if(data.code === 'xxx'){
        // 无权限
        router.replace({path:'/403'})
      }else if(data.code === 'xxx'){
        return Promise.resolve(data)
      }else {
        const {message} = data
        if(!errorMsgObj[message]){
          errorMsgObj[message] = message
        }
        setTimeout(debounce(toastMsg,1000,true),1000)
        return Promise.reject(data)
      }
   })
   .catch((error)=>{
       if(error.response){
         const {data} = error.response
         const resCode = data.status
         const resMsg = data.message || '服务异常'
         if(!errorMsgObj[resMsg]){
            errorMsgObj[resMsg] = resMsg
         }
         setTimeout(debounce(toastMsg,1000,true),1000)
         const err = {code:resCode,resMsg:resMsg}
         return Promise.reject(err)
       }else {
       const err = {type:'canceled',resMsg:'数据请求超时'}
       return Promise.reject(err)
       }
   })
}

axios-ajax完整封装

// auth.js (存储token)

const TOKEN_KEY = '_TOKEN';
export function getTokenAUTH(){
   return localStorage.getItem(TOKEN_KEY)
}

// src/utils/https.js

import axios from 'axios'
import qs from 'qs'
import {getTokenAUTH} from './auth.js'
import { debounce } from './debounce'

const contentTypes = {
  json: 'application/json; charset=utf-8',
  urlencoded: 'application/x-www-form-urlencoded; charset=utf-8',
  multipart: 'multipart/form-data',
}

function toastMsg() {
  Object.keys(errorMsgObj).map((item) => {
    Message.error(item)
    delete errorMsgObj[item]
  })
}

let errorMsgObj = {}

const defaultOptions = {
  withCredentials: true, // 允许把cookie传递到后台
  headers: {
    Accept: 'application/json',
    'Content-Type': contentTypes.json,
  },
  timeout: 15000,
}

export const callApi = ({
  url,
  data = {},
  method = 'get',
  options = {},
  contentType = 'json', // json || urlencoded || multipart
  prefixUrl = 'api',
}) => {
  if (!url) {
    const error = new Error('请传入url')
    return Promise.reject(error)
  }
  const fullUrl = `/${prefixUrl}/${url}`

  const newOptions = {
    ...defaultOptions,
    ...options,
    headers: {
      'Content-Type':
        (options.headers && options.headers['Content-Type']) ||
        contentTypes[contentType],
    },
    method,
  }
  if (method === 'get') {
    newOptions.params = data
  }

  if (method !== 'get' && method !== 'head') {
    newOptions.data = data
    if (data instanceof FormData) {
      newOptions.headers = {
        'x-requested-with': 'XMLHttpRequest',
        'cache-control': 'no-cache',
      }
    } else if (newOptions.headers['Content-Type'] === contentTypes.urlencoded) {
      newOptions.data = qs.stringify(data)
    } else {
      Object.keys(data).forEach((item) => {
        if (
          data[item] === null ||
          data[item] === undefined ||
          data[item] === ''
        ) {
          delete data[item]
        }
      })
      // 没有必要,因为axios会将JavaScript对象序列化为JSON
      // newOptions.data = JSON.stringify(data);
    }
  }
    /*
    *请求拦截
    */ 
  axios.interceptors.request.use((request) => {
  
  if(getTokenAUTH() && typeof window !== 'undefined'){
    request.headers.Authorization = getTokenAUTH()
  }
    // 移除起始部分 / 所有请求url走相对路径
    request.url = request.url.replace(/^\//, '')
    return request
  })

    /*
    * 响应拦截
    */
  return axios({
    url: fullUrl,
    ...newOptions,
  })
    .then((response) => {
      const { data } = response
      if (data.code === 'xxx') {
        // 与服务端约定
        // 登录校验失败
      } else if (data.code === 'xxx') {
        // 与服务端约定
        // 无权限
        router.replace({ path: '/403' })
      } else if (data.code === 'xxx') {
        // 与服务端约定
        return Promise.resolve(data)
      } else {
        const { message } = data
        if (!errorMsgObj[message]) {
          errorMsgObj[message] = message
        }
        setTimeout(debounce(toastMsg, 1000, true), 1000)
        return Promise.reject(data)
      }
    })
    .catch((error) => {
      if (error.response) {
        const { data } = error.response
        const resCode = data.status
        const resMsg = data.message || '服务异常'
        // if (resCode === 401) { // 与服务端约定
        //     // 登录校验失败
        // } else if (data.code === 403) { // 与服务端约定
        //     // 无权限
        //     router.replace({ path: '/403' })
        // }
        if (!errorMsgObj[resMsg]) {
          errorMsgObj[resMsg] = resMsg
        }
        setTimeout(debounce(toastMsg, 1000, true), 1000)
        const err = { code: resCode, respMsg: resMsg }
        return Promise.reject(err)
      } else {
        const err = { type: 'canceled', respMsg: '数据请求超时' }
        return Promise.reject(err)
      }
    })
}
// src/utils/debounce.js(防抖)
export const debounce = (func, timeout, immediate) => {
  let timer

  return function () {
    let context = this
    let args = arguments

    if (timer) clearTimeout(timer)
    if (immediate) {
      var callNow = !timer
      timer = setTimeout(() => {
        timer = null
      }, timeout)
      if (callNow) func.apply(context, args)
    } else {
      timer = setTimeout(function () {
        func.apply(context, args)
      }, timeout)
    }
  }
}

具体使用

API管理文件在目录src/service下,index.js文件暴露其他模块,其他文件按功能模块划分文件

get请求带参数

截屏2022-02-23 10.37.21.png

自定义前缀代理不同服务

截屏2022-02-23 10.37.52.png

文件类型处理

截屏2022-02-23 10.38.11.png

额外的axios完整例子

// auth.js
const TOKEN_KEY = '__TOKEN'; 
export function getTokenAUTH() { 
   return localStorage.getItem(TOKEN_KEY); 
}


// axios.js
import axios from 'axios'
import { ElLoading, ElMessage } from 'element-plus'
import { getTokenAUTH } from '@/utils/auth.js'

const pendingMap = new Map()

const LoadingInstance = {
  _target: null,
  _count: 0,
}

function myAxios(axiosConfig, customOptions, loadingOptions) {
  const service = axios.create({
    baseURL: 'http://localhost:8888', // 设置统一的请求前缀
    timeout: 10000, // 设置统一的超时时长
  })

  // 自定义配置
  let custom_options = Object.assign(
    {
      repeat_request_cancel: true, // 是否开启取消重复请求, 默认为 true
      loading: false, // 是否开启loading层效果, 默认为false
      reduct_data_format: true, // 是否开启简洁的数据结构响应, 默认为true
      error_message_show: true, // 是否开启接口错误信息展示,默认为true
      code_message_show: false, // 是否开启code不为0时的信息提示, 默认为false
    },
    customOptions
  )

  // 请求拦截
  service.interceptors.request.use(
    (config) => {
      removePending(config)
      custom_options.repeat_request_cancel && addPending(config)
      // 创建loading实例
      if (custom_options.loading) {
        LoadingInstance._count++
        if (LoadingInstance._count === 1) {
          LoadingInstance._target = ElLoading.service(loadingOptions)
        }
      }
      // 自动携带token
      if (getTokenAUTH() && typeof window !== 'undefined') {
        config.headers.Authorization = getTokenAUTH()
      }

      return config
    },
    (error) => {
      return Promise.reject(error)
    }
  )

  // 响应拦截
  service.interceptors.response.use(
    (response) => {
      removePending(response.config)
      custom_options.loading && closeLoading(custom_options) // 关闭loading

      if (
        custom_options.code_message_show &&
        response.data &&
        response.data.code !== 0
      ) {
        ElMessage({
          type: 'error',
          message: response.data.message,
        })
        return Promise.reject(response.data) // code不等于0, 页面具体逻辑就不执行了
      }

      return custom_options.reduct_data_format ? response.data : response
    },
    (error) => {
      error.config && removePending(error.config)
      custom_options.loading && closeLoading(custom_options) // 关闭loading
      custom_options.error_message_show && httpErrorStatusHandle(error) // 处理错误状态码
      return Promise.reject(error) // 错误继续返回给到具体页面
    }
  )

  return service(axiosConfig)
}

export default myAxios

/**
 * 处理异常
 * @param {*} error
 */
function httpErrorStatusHandle(error) {
  // 处理被取消的请求
  if (axios.isCancel(error))
    return console.error('请求的重复请求:' + error.message)
  let message = ''
  if (error && error.response) {
    switch (error.response.status) {
      case 302:
        message = '接口重定向了!'
        break
      case 400:
        message = '参数不正确!'
        break
      case 401:
        message = '您未登录,或者登录已经超时,请先登录!'
        break
      case 403:
        message = '您没有权限操作!'
        break
      case 404:
        message = `请求地址出错: ${error.response.config.url}`
        break // 在正确域名下
      case 408:
        message = '请求超时!'
        break
      case 409:
        message = '系统已存在相同数据!'
        break
      case 500:
        message = '服务器内部错误!'
        break
      case 501:
        message = '服务未实现!'
        break
      case 502:
        message = '网关错误!'
        break
      case 503:
        message = '服务不可用!'
        break
      case 504:
        message = '服务暂时无法访问,请稍后再试!'
        break
      case 505:
        message = 'HTTP版本不受支持!'
        break
      default:
        message = '异常问题,请联系管理员!'
        break
    }
  }
  if (error.message.includes('timeout')) message = '网络请求超时!'
  if (error.message.includes('Network'))
    message = window.navigator.onLine ? '服务端异常!' : '您断网了!'

  ElMessage({
    type: 'error',
    message,
  })
}

/**
 * 关闭Loading层实例
 * @param {*} _options
 */
function closeLoading(_options) {
  if (_options.loading && LoadingInstance._count > 0) LoadingInstance._count--
  if (LoadingInstance._count === 0) {
    LoadingInstance._target.close()
    LoadingInstance._target = null
  }
}

/**
 * 储存每个请求的唯一cancel回调, 以此为标识
 * @param {*} config
 */
function addPending(config) {
  const pendingKey = getPendingKey(config)
  config.cancelToken =
    config.cancelToken ||
    new axios.CancelToken((cancel) => {
      if (!pendingMap.has(pendingKey)) {
        pendingMap.set(pendingKey, cancel)
      }
    })
}

/**
 * 删除重复的请求
 * @param {*} config
 */
function removePending(config) {
  const pendingKey = getPendingKey(config)
  if (pendingMap.has(pendingKey)) {
    const cancelToken = pendingMap.get(pendingKey)
    cancelToken(pendingKey)
    pendingMap.delete(pendingKey)
  }
}

/**
 * 生成唯一的每个请求的唯一key
 * @param {*} config
 * @returns
 */
function getPendingKey(config) {
  let { url, method, params, data } = config
  if (typeof data === 'string') data = JSON.parse(data) // response里面返回的config.data是个字符串对象
  return [url, method, JSON.stringify(params), JSON.stringify(data)].join('&')
}