axios核心模块原理剖析

axios是一个基于promise调用逻辑的http请求库,是一个优秀的开源项目。了解其实现逻辑有助于深化我们对接口往返的理解,提高Promise的应用能力等。本文会挑几个axios重点且常用的功能模块进行原理剖析并使用ts实现,完整代码在这里。本文对每个模块将按照功能描述,原理剖析,ts实现的顺序进行。

请求参数与url的处理

axios会对我们传入的请求参数做统一处理,当发送的是get请求时会根据不同数据类型做不同的拼接处理,总结有以下几点

  • 基本类型 按基础规则拼接

    axios({
      method: 'get',
      url: '/test/get',
      params: {
        a: 1,
        b: 2,
      },
    });
    // http://localhost:8080/simple/get?a=1&b=2
    
  • 数组 属性名拼接数组符号并依次拼接各元素

    axios({
      method: 'get',
      url: '/test/get',
      params: {
        arr: [1, 2],
      },
    });
    // http://localhost:3000/test/get?arr[]=1&arr[]=2
    
  • 对象 encode后拼接

    axios({
      method: 'get',
      url: '/test/get',
      params: {
        obj: {
          name: 'foo',
        },
      },
    });
    // http://localhost:3000/test/get?obj=%7B%22name%22:%22foo%22%7D
    
  • Date toString后拼接

    axios({
      method: 'get',
      url: '/test/get',
      params: {
        date: new Date(),
      },
    });
    // http://localhost:3000/test/get?date=2021-11-02T07:58:49.323Z
    
  • 特殊字符不被encode @:$,[],允许他们存在url中。

  • 忽略空值 类型为null或者undefined的值不会被添加到url中。

  • 忽略哈希标记#.

  • 保存已存在的url参数,传入的参数会继续拼接在已存在的参数后。

接下来具体实现,命名为buildUrl

//  声明两个工具函数 用于判断Date类型和object类型
function isDate(val: any) {
  return toString.call(val) === '[object Date]'
}

function isPlainObject(val: any) {
  return toString.call(val) === '[object Object]'
}
function buildURL(url: string, params?: any): string {
  if (!params) {
    return url;
  }
  // 要拼接的参数数组
  const parts: string[] = [];

  Object.keys(params).forEach(key => {
    let val = params[key];
    if (val === null || typeof val === 'undefined') {
      return;
    }
    let values = [];
    if (Array.isArray(val)) {
      values = val;
      // 属性名加'[]'标记
      key += '[]';
    } else {
      values = [val];
    }
    values.forEach(val => {
      if (isDate(val)) {
        val = val.toISOString();
      } else if (isPlainObject(val)) {
        val = JSON.stringify(val);
      }
      parts.push(`${encode(key)}=${encode(val)}`);
    });
  });

  let serializedParams = parts.join('&');

  if (serializedParams) {
    // 忽略哈希标记
    const markIndex = url.indexOf('#');
    if (markIndex !== -1) {
      url = url.slice(0, markIndex);
    }
    // 保留原有的参数
    url += (url.indexOf('?') === -1 ? '?' : '&') + serializedParams;
  }

  return url;
}

默认配置与配置合并策略

axios有默认配置(axios.defaults),我们可以修改默认配置且其能与我们传入axios的配置合并,下面分析其合并策略。

由于axios的配置的是个复杂对象,因此默认配置和自定义配置的合并也不是简单的对象合并。合并的总体原则是,对于基本类型属性(method,timeout,withCredentials。。。)的合并优先使用自定义属性,没有自定义属性则用默认属性,无默认属性则为null(有些属性没有默认值如urlparamsdata,因为这些属性与当次请求强相关。设置默认值无意义),对于object类型(如headers)属性则需要深度合并,即要进行递归判断。

因此,我们要为每一个属性定制其合并策略,接下来按以下步骤具体实现:

  • 声明一个对象strats用来存储各个属性的合并策略
  const strats = Object.create(null);
  • 分别定义默认策略和深度合并策略

    默认策略

    优先用自定义属性 没有则用默认属性 否则返会null

    const defMerge = (target: any, source: any) => {
      return typeof source !== 'undefined' ? source : typeof target !== 'undefined' ? target : null;
    };
    

    深度合并策略
    基本类型直接合并,对象类型值则判断原属性是否是对象类型,如果是,则递归合并。不是,则用一个空对象与之合并。

    const deepMerge = (...objs: any[]): any => {
      const result = Object.create(null)
      objs.forEach(obj => {
        if (obj) {
          Object.keys(obj).forEach(key => {
            const val = obj[key]
            if (isPlainObject(val)) {
              if (isPlainObject(result[key])) {
                result[key] = deepMerge(result[key], val)
              } else {
                result[key] = deepMerge({}, val)
              }
            } else {
              result[key] = val
            }
          })
        }
      })
      return result
    }
    
  • 为每个属性指定合并策略

由于只有少数属性('headers', 'auth')需要深度合并,因此我们只需将需要深度合并的属性及其合并策略注册到strats中,在执行合并时判断当前属性是否在strats中存在即可,存在则执行其专属的合并策略,不存在则执行默认合并策略。

// 需要深度合并的属性
const stratKeysDeepMerge = ['headers', 'auth'];
// 注册合并策略
stratKeysDeepMerge.forEach(key => {
  strats[key] = deepMergeStrat;
});
  • 执行合并

我们约定config1代表默认配置 config2代表自定义配置。
首先声明一个空对象存储合并结果,遍历自定义配置并执行对应合并策略,会优先使用strats中的合并策略,没有则用默认合并策略。之后遍历默认配置,只有合并结果中不存在该属性时再执行其合并策略。

function mergeConfig(config1: AxiosRequestConfig, config2?: AxiosRequestConfig): AxiosRequestConfig {
  if (!config2) {
    config2 = {};
  }
  const config = Object.create(null);
  // 优先合并自定义配置
  for (let key in config2) {
    mergeField(key);
  }
  // 合并默认配置 只有在没有自定义配置时才使用默认配置
  for (let key in config1) {
    if (!config2[key]) {
      mergeField(key);
    }
  }
  function mergeField(key: string): void {
    // 优先自定义配置合并策略 没有则用默认策略
    const strat = strats[key] || defMerge;
    config[key] = strat(config1[key], config2![key]);
  }
  return config;
}

拦截器

axios的拦截器几乎是项目中必用的一项配置,它可以在请求前/响应后对请求体/响应体做一些处理,先来回顾一下基本用法。

  • 使用use方法注册拦截器,使用类似promise.then,接收两个参数,第一个用来添加我们期望拦截器处理的逻辑,第二个参数用来处理错误。

  • 使用eject方法删除某个拦截器。

  • 拦截器可以添加多个,执行顺序是:请求拦截器先添加的后执行,响应拦截器先添加的先执行。

我们知道axios是基于promise实现的,结合拦截器的执行过程其实不难想到可以用promise的链式调用实现,先来回顾一下链式调用。简单来讲Promise.then方法会返回一个Promise,可以继续调用then方法, 前一个then的回调返回的数据会作为参数传入下一个then的回调。因此我们可以将请求/响应拦截器与请求发送的调用使用promise.then方法串联起来。

基于上述,首先实现一个拦截器管理类

interface ResolvedFn<T = any> {
  (val: T): T | Promise<T>
}

interface RejectedFn {
  (error: any): any
}
interface Interceptor<T> {
  resolved: ResolvedFn<T>
  rejected?: RejectedFn
}

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
  }
  // 遍历所有拦截器,将每个拦截器作为传入函数的参数并执行
  // 将来用于将拦截器推入promise的调用链中
  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
    }
  }
}

接下来为Axios类添加interceptors属性,他有两个值分别是请求和响应拦截器

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

拦截器拦截的是请求,因此最后需要对发送请求的方法进行处理。具体如下:

  • 声明一个chain数组用于存放promise调用链,并首先将请求发送方法放入。

  • 分别调用请求/响应拦截器的foreach方法,将请求拦截器倒序插入数组前部,将响应拦截器顺序插入数组尾部。

  • 声明一个resolved状态的Promise用于启动链式调用,最后循环chain数组,取出每个拦截器,使用then方法调用即可。

request(url: any, config?: any): AxiosPromise {
    // 其他逻辑
    const chain: PromiseChain[] = [{
      resolved: dispatchRequest,
      rejected: undefined
    }]
    // 将请求拦截器倒序插入数组前部
    this.interceptors.request.forEach(interceptor => {
      chain.unshift(interceptor)
    })
    // 将响应拦截器插入数组尾部
    this.interceptors.response.forEach(interceptor => {
      chain.push(interceptor)
    })
    // 初始化一个reslove状态的promise
    let promise = Promise.resolve(config)
    while (chain.length) {
      // 链式调用
      const { resolved, rejected } = chain.shift()!
      promise = promise.then(resolved, rejected)
    }
    return promise
  }

请求取消

请求取消也是axios在项目中的一个常用功能,一个典型场景就是当接口响应慢且会多次触发时(如点击按钮提交,搜索输入框等),由于每次响应时间不定,因此可能出现后发出的请求比先发出的请求的响应速度快的情况,此时就可以使用请求取消,即判断前一次请求结果未返回时,取消当次请求。

回顾下如何使用请求取消,有两种使用方式:

  • 方式一 使用axios.CancelToken的source方法,调用后返回token和cancel两个属性,token用于请求时传给配置对象中的cancelToken属性,在请求发出后,可以使用cancel方法取消请求。
const CancelToken = axios.CancelToken;
const source = CancelToken.source();
axios.get('/test/get', {
  cancelToken: source.token
})
source.cancel('Operation canceled by the user.');
  • 方式二 直接new一个CancelToken的实例赋给配置对象的cancelToken属性并传入一个函数,该函数接收一个处理取消逻辑的函数参数,在函数体内将其赋给在外手动声明的cancel变量,通过执行cancel函数取消请求。
const CancelToken = axios.CancelToken;
let cancel;
axios.get('/test/get', {
  cancelToken: new CancelToken(function executor(c) {
    cancel = c;
  })
});
cancel();

接下来分析其实现思路。
我们知道http请求取消是通过调用xhr对象的abort方法实现的。现在的问题是当想要取消请求时我们往往无法直接访问xhr对象。要想操作xhr对象我们只有在请求发送的过程中,也即在new XMLHttpRequest()后才能访问xhr对象。而要实现请求取消,我们只能事先将请求取消的逻辑写好但不执行,等将来需要取消时再执行这段逻辑。说到这里就又想到了promise,我们知道promise.then方法中指定的回调是会等promise的状态改变后再执行的,因此实现思路也就有啦。我们可以声明一个pending状态的promise,在then方法的成功回调中添加请求取消的逻辑,即xhr.abort。等需要取消时,将该promise的状态改变即可。换句话说我们把请求取消的逻辑"寄托"在了一个promise上。那么这个promise从何而来?观察拦截器的两种使用方式,其实配置对象中的cancelToken属性就是那个promise。

既然有两种使用方式,相应的也就有两种方式可以得到这个promise,分别是直接实例化axios.CancelToken类以及调用CancelToken类的source方法得到。axios得到这个promise后就可以将取消逻辑‘寄托’在其then方法上,接下来看一下实现:

export default (config: AxiosRequestConfig): AxiosPromise => {
  return new Promise((resolve,reject)=>{
    const {
   ......
   cancelToken,// 传入的promise
    } = config
  const request = new XMLHttpRequest()
  // ...其他逻辑
  if (cancelToken) {
        cancelToken.promise.then(reason => {
          // 调用取消方法
          request.abort()
          // 改变Axiospromise状态为失败
          reject(reason)
        })
      }
  })
}

接下里分析如何改变该promise的状态。观察第二种使用方式,executor函数接收的参数就是用来处理取消逻辑的函数,即改变promise的状态。因此在实现CancelToken类时首先需要声明一个pending状态的promise,即暂不执行resolve函数,而是将其暂存起来(赋给外部变量)。之后执行executor函数并传入一个函数,函数内部执行暂存的resolve函数即可改变上述promise的状态,接下来具体实现

interface ResolvePromise {
  (reason?: string): void
}
class CancelToken {
  promise: Promise<string>
  reason?: string
  constructor(executor) {
    let resolvePromise: ResolvePromise
    // 声明一个pending状态的promise
    this.promise = new Promise<string>(resolve => {
      resolvePromise = resolve
    })
    // executor函数传入的参数会被赋值给外部变量cancel用于取消请求
    executor(message => {
      if (this.reason) {
        return
      }
      this.reason = message
      resolvePromise(this.reason)
    })
  }
}

至此第二种使用方式已经实现,再来看第一种。容易看出source函数返回的token就是一个pending状态的promise,返回的cancel函数可直接调用,无需我们手动声明,对比一下不难发现其实这就是相对于第二种方式做了一层封装,将CancelToken的实例化和取消函数的处理逻辑放在source方法内部实现,接下来实现source方法。

class CancelToken {
 ...
  //静态方法source 实例化CancelToken 声明cancel变量 接收取消函数并返回
  static source(): CancelTokenSource {
    let cancel
    const token = new CancelToken(c => {
      cancel = c
    })
    return {
      cancel,
      token
    }
  }
  constructor(executor) {...} 
}

至此请求取消的主体逻辑已经实现。有一点需要特别区分,即axios.CancelToken和config的cancelToken这两个属性,事实上他们是类与实例的关系。

源码地址,如有错误恳请指正。

本文章由javascript技术分享原创和收集

发表评论 (审核通过后显示评论):