likes
comments
collection
share

手把手带你写一个axios库

作者站长头像
站长
· 阅读数 59

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第2篇文章,点击查看活动详情

axios 应该是前端最常用的一个库了,现在以它为基础我们来实现一个缩减版的axios,目的是让我们在项目中合理高效的配置和使用这个请求库。

axios 是通过什么来发送请求的?

浏览器原生支持 XMLHttpRequestfetch 两种方式来发送请求,axios选择了使用XMLHttpRequest发送请求。

  • 创建请求实例 const request = new XMLHttpRequest()
const request = new XMLHttpRequest()
request.open(method.toUpperCase(), url!, true)
request.send(data)
  • 根据配置参数config设置request
 if (responseType) {
    request.responseType = responseType
 }
 if (timeout) {
    request.timeout = timeout
 }
  • 监听onreadystatechange,如果readyState=4表示请求完成,然后把相应的数据resolve出去

详细代码如下:

export default function xhr(config: AxiosRequestConfig): AxiosPromise {
  return new Promise((resolve, reject) => {
    const {
      data = null,
      url,
      method = 'get',
      headers,
      responseType,
      timeout
    } = config

    const request = new XMLHttpRequest()

    request.open(method.toUpperCase(), url!, true)
    
     if (responseType) {
        request.responseType = responseType
     }
     
     if (timeout) {
        request.timeout = timeout
     }
     
     // 设置请求头
     Object.keys(headers).forEach(name => {
        // 如果没有传递data数据,就没有必要设置content-type
        if (data === null && name.toLowerCase() === 'content-type') {
          delete headers[name]
        } else {
          request.setRequestHeader(name, headers[name])
        }
      })
     
     request.onreadystatechange = function handleLoad() {
        // 4:表示请求操作已经完成,这意味着数据传输已经彻底完成或失败
        if (request.readyState !== 4) {
          return
        }
        // 如果 XMLHttpRequest 出错,浏览器返回的 status 也为 0
        if (request.status === 0) {
          return
        }
        // 响应数据都是字符串的形式,这里把字符串转化为对象
        const responseHeaders = parseHeaders(request.getAllResponseHeaders())
        const responseData = responseType !== 'text' ? request.response : request.responseText
        const response: AxiosResponse = {
          data: responseData,
          status: request.status,
          statusText: request.statusText,
          // 响应头
          headers: responseHeaders,
          // 请求头配置
          config,
          // xhr实例
          request
        }
        
        if (request.status >= 200 && request.status < 300) {
            resolve(responseData)
        } else {
            reject()
        }
     }
    
    request.send(data)
  })
}

axios 怎么调用呢?

调用方式

我们在调用axios方法的时候,有很多种形式:

  1. 直接调用 axios,参数是一个 config 对象
axios({
  method: 'get',
  url: '/base/get',
  params: {
    a: 1,
    b: 2
  }
})
  1. 直接调用 axios,参数有两个,一个url,另一个是 config对象
axios('/extend/post', {
  method: 'post',
  data: {
    msg: 'hello'
  }
})
  1. 调用axios上面的方法
axios.request(config)
axios.get(url[, config])

从需求上来看,axios 不再单单是一个方法,更像是一个混合对象,本身是一个方法,又有很多方法属性,接下来我们就来实现这个混合对象。

创建 Axios 类

// 这个方法就是我们在第一部分实现的请求方法
import dispatchRequest from './dispatchRequest'

export default class Axios {
  request(config: AxiosRequestConfig): AxiosPromise {
    return dispatchRequest(config)
  }
  get(url: string, config?: AxiosRequestConfig): AxiosPromise {
    return this._requestMethodWithoutData('get', url, config)
  }
  post(url: string, data?: any, config?: AxiosRequestConfig): AxiosPromise {
    return this._requestMethodWithData('post', url, data, config)
  }
  // 没有请求体的方法,比如get
  _requestMethodWithoutData(method: Method, url: string, config?: AxiosRequestConfig) {
    return this.request(
      Object.assign(config || {}, {
        method,
        url
      })
    )
  }
  // 有请求体的方法,比如post
  _requestMethodWithData(method: Method, url: string, data?: any, config?: AxiosRequestConfig) {
    return this.request(
      Object.assign(config || {}, {
        method,
        url,
        data
      })
    )
  }
}

创建了Axios类之后,我们实例化这个类,然后就可以通过如下方式调用了:

const axios = new Axios()

axios.request(config)
axios.get(url, config)
axios.post(url, data, config)

但是我们无法直接调用axios实例本身去发送请求,因此此时axios是一个实例,即对象,对象不是函数,所以无法像函数一样调用,怎么办呢?(axios的实现真是精妙)

// 引入上面写的类
import Axios from './core/Axios'

function createInstance(): AxiosInstance {
  // 创建axios实例
  const context = new Axios()
  // 把实例bind到实例的request方法上,这样request方法里面如果访问this,那么this就指向context
  const instance = Axios.prototype.request.bind(context)
  // 把实例上的所有属性和方法都扩展到这个方法instance上  
  extend(instance, context)

  return instance as AxiosInstance
}

function extend<T, U>(to: T, from: U): T & U {
  for (const key in from) {
    ;(to as T & U)[key] = from[key] as any
  }
  return to as T & U
}

const axios = createInstance()

export default axios

在 createInstance 工厂函数的内部,我们首先实例化了 Axios 实例 context,接着创建instance 指向 Axios.prototype.request 方法,并绑定了上下文 context;接着通过 extend 方法把 context 中的原型方法和实例方法全部拷贝到 instance 上,这样就实现了一个混合对象:instance 本身是一个函数,又拥有了 Axios 类的所有原型和实例属性,最终把这个 instance 返回。

目前我们的 axios 函数只支持传入 1 个参数,如下:

axios({
  url: '/extend/post',
  method: 'post',
  data: {
    msg: 'hi'
  }
})

我们希望该函数也能支持传入 2 个参数,如下:

axios('/extend/post', {
  method: 'post',
  data: {
    msg: 'hello'
  }
})

这就需要使用函数重载实现

首先我们要修改 AxiosInstance 的类型定义。

export interface AxiosInstance extends Axios {
  <T = any>(config: AxiosRequestConfig): AxiosPromise<T>

  <T = any>(url: string, config?: AxiosRequestConfig): AxiosPromise<T>
}

我们增加一种函数的定义,它支持 2 个参数,其中 url 是必选参数,config 是可选参数。

由于 axios 函数实际上指向的是 request 函数,所以我们来修改 request 函数的实现。

request(url: any, config?: any): AxiosPromise {
    if (typeof url === 'string') {
      if (!config) {
        config = {}
      }
      config.url = url
    } else {
      config = url
    }
    return dispatchRequest(config)
  }

我们把 request 函数的参数改成 2 个,url 和 config 都是 any 类型,config 还是可选参数。

接着在函数体我们判断 url 是否为字符串类型,一旦它为字符串类型,则继续对 config 判断,因为它可能不传,如果为空则构造一个空对象,然后把 url 添加到 config.url 中。如果 url 不是字符串类型,则说明我们传入的就是单个参数,且 url 就是 config,因此把 url 赋值给 config

这里要注意的是,我们虽然修改了 request 的实现,支持了 2 种参数,但是我们对外提供的 request 接口仍然不变,可以理解为这仅仅是内部的实现的修改,与对外接口不必一致,只要保留实现兼容接口即可。

axios 拦截器的实现

我们希望能对请求的发送和响应做拦截,也就是在发送请求之前和接收到响应之后做一些额外逻辑。

axios 设计的拦截器的使用方式如下:

// 添加一个请求拦截器
axios.interceptors.request.use(function (config) {
  // 在发送请求之前可以做一些事情
  return config;
}, function (error) {
  // 处理请求错误
  return Promise.reject(error);
});
// 添加一个响应拦截器
axios.interceptors.response.use(function (response) {
  // 处理响应数据
  return response;
}, function (error) {
  // 处理响应错误
  return Promise.reject(error);
});

我们先用一张图来展示一下拦截器工作流程:

手把手带你写一个axios库

整个过程是一个链式调用的方式,并且每个拦截器都可以支持同步和异步处理,我们自然而然地就联想到使用 Promise 链的方式来实现整个调用过程。

在这个 Promise 链的执行过程中,请求拦截器 resolve 函数处理的是 config 对象,而相应拦截器 resolve 函数处理的是 response 对象。

我们先要创建一个拦截器管理类,允许我们去添加 删除和遍历拦截器。

创建拦截器管理类

export default class InterceptorManager<T> {
  private interceptors: Array<Interceptor<T> | null>

  constructor() {
    this.interceptors = []
  }

  use(resolved: ResolvedFn<T>, rejected?: RejectedFn): number {
    this.interceptors.push({
      resolved,
      rejected
    })
    return this.interceptors.length - 1
  }

  forEach(fn: (interceptor: Interceptor<T>) => void): void {
    this.interceptors.forEach(interceptor => {
      if (interceptor !== null) {
        fn(interceptor)
      }
    })
  }

  eject(id: number): void {
    if (this.interceptors[id]) {
      this.interceptors[id] = null
    }
  }
}

我们定义了一个 InterceptorManager 泛型类,内部维护了一个私有属性 interceptors,它是一个数组,用来存储拦截器。该类还对外提供了 3 个方法,其中 use 接口就是添加拦截器到 interceptors 中,并返回一个 id 用于删除;forEach 接口就是遍历 interceptors 用的,它支持传入一个函数,遍历过程中会调用该函数,并把每一个 interceptor 作为该函数的参数传入;eject 就是删除一个拦截器,通过传入拦截器的 id 删除。

链式调用实现

当我们实现好拦截器管理类,接下来就是在 Axios 中定义一个 interceptors 属性:

export default class Axios {
  constructor() {
    this.interceptors = {
      request: new InterceptorManager<AxiosRequestConfig>(),
      response: new InterceptorManager<AxiosResponse>()
    }
  }
}

Interceptors 类型拥有 2 个属性,一个请求拦截器管理类实例,一个是响应拦截器管理类实例。我们在实例化 Axios 类的时候,在它的构造器去初始化这个 interceptors 实例属性。

接下来,我们修改 request 方法的逻辑,添加拦截器链式调用的逻辑:

request(url: any, config?: any): AxiosPromise {
  if (typeof url === 'string') {
    if (!config) {
      config = {}
    }
    config.url = url
  } else {
    config = url
  }

  const chain: PromiseChain[] = [{
    resolved: dispatchRequest,
    rejected: undefined
  }]

  this.interceptors.request.forEach(interceptor => {
    chain.unshift(interceptor)
  })

  this.interceptors.response.forEach(interceptor => {
    chain.push(interceptor)
  })

  let promise = Promise.resolve(config)

  while (chain.length) {
    const { resolved, rejected } = chain.shift()!
    promise = promise.then(resolved, rejected)
  }

  return promise
}

首先,构造一个 PromiseChain 类型的数组 chain,并把 dispatchRequest 函数赋值给 resolved 属性;接着先遍历请求拦截器插入到 chain 的前面;然后再遍历响应拦截器插入到 chain 后面。

接下来定义一个已经 resolve 的 promise,循环这个 chain,拿到每个拦截器对象,把它们的 resolved 函数和 rejected 函数添加到 promise.then 的参数中,这样就相当于通过 Promise 的链式调用方式,实现了拦截器一层层的链式调用的效果。

每一个任务都放在promise.then函数里面,那么这个任务的执行时机就是上一个任务执行完成后调用resolve方法,此时才会执行then里面的函数,也就是执行下一个任务,这样就形成了一个串行调用。

至此,axios拦截器的功能就此完成。

axios.create方法实现

目前为止,我们的 axios 都是一个单例,一旦我们修改了 axios 的默认配置,会影响所有的请求。我们希望提供了一个 axios.create 的静态接口允许我们创建一个新的 axios 实例,同时允许我们传入新的配置和默认配置合并,并做为新的默认配置。

export interface AxiosStatic extends AxiosInstance{
  create(config?: AxiosRequestConfig): AxiosInstance
}

function createInstance(config: AxiosRequestConfig): AxiosStatic {
  const context = new Axios(config)
  const instance = Axios.prototype.request.bind(context)

  extend(instance, context)

  return instance as AxiosStatic
}

axios.create = function create(config) {
  return createInstance(mergeConfig(defaults, config))
}

create方法不是属于Axios类上的方法,它属于Axios类实例上的静态方法,这个方法的作用就是生成一个Axios类实例。

总结

到目前为止,缩减版的axios基本完成了,它是通过浏览器器支持的XMLHttpRequest发送一个请求,相关的配置我们都可以在XMLHttpRequest上找到出处。

axios通过对类的扩展实现了多种方式的灵活调用,它本身是一个方法,这个方法上面又挂载了Axios类上面的属性和方法。同时在axios这个方法生又扩展了一个create方法,这样我们就可以在项目中根据需要灵活配置多个axios实例。

axios利用promise链式调用实现了拦截器的功能,首先创建一个chainPromise数据,然后依次取出任务执行,下一个任务必须放在上一个任务的promise.then中执行,这样才能保证串行执行。