实现一个axios(一)

1,101 阅读8分钟

介绍

axios是前端跟后端发送接口请求必备的一个库,掌握它的原理对使用这个库的时候更加能够游刃有余,所以就来手写一个axios,跟官方的API保持一致。包括我们常用的请求响应拦截器、取消ajax请求等。为了降低学习难度,会使用javascript来写。

为了阅读起来更加方便,首先说一下我的目录结构:

- src

    - core

    - helper

    - axios.js

    - index.js

  • core文件夹下面核心的模块
  • helper文件夹是一些帮助函数
  • axios.js 里面负责产生axios实例
  • index.js是总入口

基本功能

要实现基本的发送请求其实非常的简单,对 XMLHttpRequest 这个api稍微的封装一下就可以,这一小节看完后将实现下面这种方法的调用。

// get请求
axios({
  method: "get",
  url: "/xxx/get",
  headers:{
    //传递headers
  },
  params: {
    xx:1
  }
})
.then(res => console.log(res))

// post请求
axios({
  method: "post",
  url: "/xxx/post",
  headers:{
    //传递headers
  },
  data: {
    xx:1
  }
})
.then(res => console.log(res))

xhr的封装

用过axios的朋友都知道,在接收到响应的时候我们通过类似下面的代码来获取后端的数据:

axios({
  url: '/xxx'
})
.then(res => {
  console.log(res.data)
})

后端的数据都是放在res.data中的,那么res还有其他的属性吗?它完整的数据结构是这样的:

{
  data:{},//存放后端返回的数据
  status: 200//状态码
  statusText: 'OK'//状态文本
  headers: {}//响应headers
  config:{}//config配置
  request:{}//XMLHttpRequest对象
}

一定要谨记这个数据结构。

首先看看对XMLHttpRequest的封装,在src/core/xhr.js文件中:

import { parseResponseHeaders } from "../helper/headers"

// 参数config可以理解为在调用axios的时候传递的那个对象
export default function xhr(config) {
  return new Promise((resolve, reject) => {
    const { url, method = "get", data, headers, responseType } = config
    const request = new XMLHttpRequest()

    if (responseType) {
      request.responseType = responseType
    }

    request.open(method, url, true)
    // 必须在open之后调用setRequestHeader
    Object.keys(headers).forEach(headerName => {
      request.setRequestHeader(headerName, headers[headerName])
    })

    request.send(data)

    request.onreadystatechange = function() {
      if (request.readyState !== 4) return
      const responseHeaders = parseResponseHeaders(
        request.getAllResponseHeaders()
      )
      let responseData =
        request.responseType === "text"
          ? request.responseText
          : request.response
      const response = {
        data: responseData,
        status: request.status,
        statusText: request.statusText,
        headers: responseHeaders,
        config,
        request
      }
      resolve(response)
    }
  })
}

代码很容易理解,函数整体返回一个promise,当成功接收到请求时调用resolve,resolve的参数就是上面说的那个数据结构。注意这里的 xhr.responseType 判断,如果用户在调用axios传递了{ responseType:'json' },那么xhr.response就是一个对象,如果没有传responseType,xhr.response就是一个字符串(我们会在下面的介绍中把它自动转为一个json对象)。

由于 request.getAllResponseHeaders 返回的不是对象,而是字符串,我们要转化一下,在src/helper/header.js中,新增一个方法:

export function parseResponseHeaders(headers) {
  const parsed = {}
  if (!headers) return parsed
  headers.split("\r\n").forEach(line => {
    if (!line) return
    const [key, value] = line.split(":")
    parsed[key.trim()] = value.trim()
  })

  return parsed
}

getAllResponseHeaders返回的字符串中每个响应头是回车分割的,所以用\r\n来区分每个响应头

get请求

实现的时候有几点需要注意一下:

  • axios需要返回promise
  • url要特殊处理(hash自动去掉、url传参时要进行编码、url本身就有 ? 和没有 ?的兼容处理)
  • params参数要支持 对象形式,因为是get请求嘛,所以需要把对象形式转为字符串拼接到url后面
  • 如果params传递的对象中的value是数组或日期需要特殊处理

get请求传递参数之params

由于get请求是通过url后面跟问号的方式来传参,来看看怎么把params参数拼接到url后面的,在src/helpers/url.js文件中:

export default function handleURL(url, params) {
  if (!params) return url
  const parts = []
  Object.keys(params).forEach(key => {
    let val = params[key]
    if (val == null) return
    let values = []
    if (Array.isArray(val)) {
      key += "[]"
      values = val
    } else {
      values = [val]
    }
    values.forEach(value => {
      //日期特殊处理
      if (isDate(value)) {
        value = value.toISOString()
      } else if (isPlainObject(value)) {
        //对象特殊处理
        value = JSON.stringify(value)
      }
      parts.push(`${key}=${value}`)
    })
    // 去掉hash
    if (url.includes("#")) {
      url = url.slice(0, url.indexOf("#"))
    }
    // 处理url中有没有?的逻辑
    const queryStr = parts.join("&")
    if (url.includes("?")) {
      url += "&" + queryStr
    } else {
      url += "?" + queryStr
    }
  })
  return url
}

在src/core/dispatchRequest.js中真正的发送请求

import handleURL from "../helper/url"

function processConfig(config) {
  const { url } = config
  config.url = handleURL(url, params)
}

export default function dispatchRequest(config) {
  processConfig(config)
  return xhr(config)
}

dispathRequest是发送请求的入口,我们调用axios()传递的参数直接扔给dispatchRequest,对config.url处理以后丢给xhr方法。

请求头和数据的处理

实现的时候需要注意:

  • 默认在HTTP请求头中带有 Content-Type: application/json,要不然后端解析不到body里的数据
  • xhr.send()方法并不支持传递一个对象,而我们在调用axios的时候传递的data是一个对象,不能直接扔给send方法,怎么处理?
  • 调用后端接口返回的数据是json格式的,但是是字符串的json,需要自动转成json对象

有了上面的基础代码,实现这些请求就很简单啦。首先,如果用户在调用axios的时候传递了data字段,我们就默认添加Content-Type: application/json,在src/helpers/headers.js中:

function normalizeHeaders(headers, normalizeHeaderName) {
  Object.keys(headers).forEach(headerName => {
    if (
      headerName !== normalizeHeaderName &&
      headerName.toUpperCase() === normalizeHeaderName.toUpperCase()
    ) {
      headers[normalizeHeaderName] = headers[headerName]
      delete headers[headerName]
    }
  })

  return headers
}
export function processHeaders(headers, data) {
  headers = normalizeHeaders(headers, "Content-Type")
  if (!headers["Content-Type"] && data) {
    headers["Content-Type"] = "application/json;charset=utf-8"
  }

  return headers
}

normalizeHeaders这个函数为了兼容请求头中大小写的问题,因为可能用户传递了小写的content-type,所以这个时候我们统一转为大写的Content-Type来处理。

由于xhr.send不支持传递对象,比如xhr.send({name:'xxx'})是不支持的,因为send方法支持的数据类型为Blob、BufferSource、FormData、URLSearchParams、ReadableStream、USVString,所以要将其用JSON.stringify转化成USVString类型的。在src/helper/data.js中:

import { isPlainObject } from "./utils"

export default function transformRequest(data) {
  if (isPlainObject(data)) {
    return JSON.stringify(data)
  }
  return data
}

那么对于后端的结果返回的是json字符串的问题,用JSON.parse转化一下就可以,在src/helper/data.js中增加一个 transformResponse 方法,代码如下:

export function transformResponse(data) {
  try {
    data = JSON.parse(data)
  } catch (ex) {
    // todo
  }
  return data
}

在src/core/dispatchRequest.js文件中:

import xhr from "./xhr"
import { processHeaders } from "../helper/headers"
import handleURL from "../helper/url"
import { transformRequest, transformResponse } from "../helper/data"

function processConfig(config) {
  const { url, params, headers = {}, data } = config
  config.url = handleURL(url, params)
  // 处理headers
  config.headers = processHeaders(headers, data)
  // 处理data
  config.data = transformRequest(data)
}

export default function dispatchRequest(config) {
  processConfig(config)
  return xhr(config).then(res => {
    // json字符串转为json对象
    res.data = transformResponse(res.data)
    return res
  })
}

需要注意的是:对请求结果的处理是要放在接收到后端请求后的,因为xhr方法返回promise,所以在then里面把json字符串转为json对象

post、delete等请求

以上已经完成了我们发送请求的逻辑,接下来就看看如何组织代码暴露axios实例可以真正的调用方法来发请求。

在src/core/Axios.js中定义一个Axios类,可以让使用者方便的通过HTTP动词的方法发送请求:

import dispatchRequest from "./dispatchRequest"

export default class Axios {
  constructor() {}
  request(config) {
    return dispatchRequest(config)
  }
  get(url, config) {
    return this._requestWithoutData(url, "get", config)
  }
  options(url, config) {
    return this._requestWithoutData(url, "options", config)
  }
  head(url, config) {
    return this._requestWithoutData(url, "head", config)
  }
  post(url, data, config) {
    return this._requestWithData(url, "post", data, config)
  }
  put(url, data, config) {
    return this._requestWithData(url, "put", data, config)
  }
  patch(url, data, config) {
    return this._requestWithData(url, "patch", data, config)
  }
  delete(url, data, config) {
    return this._requestWithData(url, "delete", data, config)
  }
  _requestWithoutData(url, method, config) {
    return this.request({
      ...config,
      url,
      method
    })
  }
  _requestWithData(url, method, data, config) {
    return this.request({
      ...config,
      url,
      method,
      data
    })
  }
}

这个类的总入口就是request方法(方法内部调用dispatchRequest发送请求),其他的方法都是直接或间接的调用了reqeust来发送请求。

还有一个点就是,我们在使用axios的时候可以把axios当作函数来用,而不总是调用axios.get等这种方法。

在在src/axios.js中:(注意这里是在src下的axios文件,上面的是src/core下的)

import Axios from "./core/axios"

function extend(instance, axios) {
  const methods = Object.getOwnPropertyNames(Axios.prototype).filter(
    method => method !== "constructor"
  )
  methods.forEach(method => {
    instance[method] = axios[method]
  })
}

function createInstance() {
  const axios = new Axios()
  // instance是一个方法
  const instance = axios.request.bind(axios)
  // 把axios实例下的所有方法拷贝到instance上
  extend(instance, axios)
  return instance
}
const axios = createInstance()

export default axios

extend方法的作用就是把Axios实例类的方法全部拷贝到instance实例中。这样我们使用axios的时候可以把axios当作函数来用,也可以当作对象来使用(调用对应的方法)。

使用axios

最后我们把axios暴露给用户就行,在src/index.js中,一行代码:

import axios from "./axios"

export default axios

错误处理

错误处理是使用第三方库很重要的一部分,把错误暴露出来让用户自己去处理是一个很重要的机制。

在使用axios的时候,错误可以分为几下3种:

  1. 请求超时
  2. 网络错误(断网)
  3. 返回了4xx、5xx的错误码

为了让使用者拿到更加详细的错误信息,我们定义一个错误类来组织代码:

class AxiosError extends Error {
  // message是错误
  // config是请求时的config配置
  // code是错误码
  // request是请求的xmlhttprequest对象
  // response是响应
  constructor(message, config, code, request, response) {
    super(message)
    this.config = config
    this.code = code
    this.request = request
    this.response = response
    this.isAxiosError = true

    // 这句代码非常关键
    // 一个类继承了Error类,这个类的实例 instanceOf 这个类 应该返回true,没有这个代码会返回false
    // 这应该是js语言的一个bug
    Object.setPrototypeOf(this, AxiosError.prototype)
  }
}

export default function createError(message, config, code, request, response) {
  return new AxiosError(message, config, code, request, response)
}

通过一个工厂函数createError来创建一个错误实例,使用的时候就不用new了。

继续修改我们的代码,来处理上面3种错误情况,修改src/core/xhr.js,增加错误的逻辑。

请求超时

if (timeout) {
  request.timeout = timeout
}

request.ontimeout = function() {
  reject(
    new createError(
      `Timeout of ${config.timeout} ms exceeded`,
      config,
      "ECONNABORTED",
      request,
      null
    )
  )
}

网络请求错误

request.onerror = function() {
  reject(new createError("Network Error", config, null, request,null))
}

不管是超时错误还是网络请求错误 都 没有response,所以最后一个参数都是null

是4xx、5xx的错误

if (response.status >= 200 && response.status < 300) {
  resolve(response)
} else {
  reject(
    createError(
      `Request failed with status code ${response.status}`,
      config,
      null,
      request,
      response
    )
  )
}

这里是把response.status状态码 [200,300) 之间当成正确的响应,反之就是异常的。官网的axios是支持自定义错误函数,也就是可以根据自己的业务需要来指定response.status什么范围内是正常的,反之是异常的。

总结

今天的文章实现了axios的基础功能和错误处理,下一篇文章会实现axios的拦截器和取消请求的功能。