axios核心模块原理剖析

作者: 竹叶寨少主 | 来源:发表于2021-11-14 19:33 被阅读0次

    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这两个属性,事实上他们是类与实例的关系。

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

    相关文章

      网友评论

        本文标题:axios核心模块原理剖析

        本文链接:https://www.haomeiwen.com/subject/miyoaltx.html