Axios, an HTTP request library based on promise invocation logic, is an excellent open source project. Understanding its implementation logic is helpful to deepen our understanding of interface round-trip and improve the application ability of Promise. This paper will select several axios key and commonly used functional modules for principle analysis and implementation using TS, the complete code is here. In this paper, each module will be in accordance with the function description, principle analysis, TS implementation order.

Processing of request parameters and URLS

Axios handles the request parameters we pass in uniformly, and when sending a GET request it does different concatenation based on different data types

  • Basic types are concatenated according to basic rules

    axios({
      method: 'get'.url: '/test/get'.params: {
        a: 1.b: 2,}});// http://localhost:8080/simple/get? a=1&b=2
    Copy the code
  • Array property names concatenate array symbols and sequentially concatenate elements

    axios({
      method: 'get'.url: '/test/get'.params: {
        arr: [1.2],}});// http://localhost:3000/test/get? arr[]=1&arr[]=2
    Copy the code
  • Encode objects after concatenation

    axios({
      method: 'get'.url: '/test/get'.params: {
        obj: {
          name: 'foo',}}});// http://localhost:3000/test/get? obj=%7B%22name%22:%22foo%22%7D
    Copy the code
  • Date toString after concatenation

    axios({
      method: 'get'.url: '/test/get'.params: {
        date: new Date(),}});// http://localhost:3000/test/get? The date = 2021-11-02 T07:58:49. 323 z
    Copy the code
  • Special characters are not encoded @, :, $,, [,], allowing them to exist in the URL.

  • Ignore that null values of type NULL or undefined are not added to the URL.

  • Ignore the hash tag #.

  • Save existing URL parameters. Incoming parameters continue to be concatenated after existing parameters.

Let’s implement it and call it buildUrl

// Declare two utility functions to determine the Date and object types
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;
  }
  // An array of parameters to concatenate
  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;
      // Attribute name with '[]' tag
      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) {
    // Ignore hash tags
    const markIndex = url.indexOf(The '#');
    if(markIndex ! = = -1) {
      url = url.slice(0, markIndex);
    }
    // Keep the original parameters
    url += (url.indexOf('? ') = = = -1 ? '? ' : '&') + serializedParams;
  }

  return url;
}
Copy the code

Default configuration and merge policy configuration

Axios has default configurations (axios.defaults) that we can modify and merge with the configurations we passed into Axios. Here’s the merge strategy.

Since the configuration of AXIos is a complex object, the merge of default and custom configurations is not a simple object merge. The general principle of merging is that custom attributes are preferred for basic type attributes (method, timeout, withCredentials, etc.), default attributes are used if there are no custom attributes, and null attributes are used if there are no default attributes (some attributes have no default values, such as URL, params, data, etc.). Because these attributes are strongly related to the current request. Setting the default value is meaningless), and for attributes of type Object (such as headers) a deep merge is required, that is, a recursive judgment is made.

Therefore, we will customize the merge strategy for each attribute, which will be implemented in the following steps:

  • Declares the merge policy that strats uses to store individual attributes
  const strats = Object.create(null);
Copy the code
  • Define the default policy and the deep merge policy respectively

    The default policy

    Custom attributes are preferred, default attributes are used, otherwise null is returned

    const defMerge = (target: any, source: any) = > {
      return typeofsource ! = ='undefined' ? source : typeoftarget ! = ='undefined' ? target : null;
    };
    Copy the code

    Deep merge strategy

Primitive types are merged directly, object type values determine whether the original attribute is an object type, and if so, recursively merge. If not, merge it with an empty object.

constdeepMerge = (... 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
}
Copy the code
  • Specify a merge policy for each attribute

Since only a few attributes (‘headers’, ‘auth’) need to be deeply merged, we only need to register the attributes that need to be deeply merged with Strats and their merge policies. During the merge, we can determine whether the current attribute exists in Strats, and then execute its exclusive merge policy. If no, the default merge policy is executed.

// Attributes that require deep merging
const stratKeysDeepMerge = ['headers'.'auth'];
// Register the merge policy
stratKeysDeepMerge.forEach(key= > {
  strats[key] = deepMergeStrat;
});
Copy the code
  • Execute mergers

We agree that config1 stands for default configuration and config2 stands for custom configuration. First declare an empty object store merge result, iterate through the custom configuration, and execute the corresponding merge policy. The merge policy in Strats is preferred, and the default merge policy is used if there is no merge policy. The default configuration is then iterated over and the merge policy is executed only if the attribute is not present in the merge result.

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 {/ / priority custom configuration merge strategy did not use the default policy const strat = strats [key] | | defMerge; config[key] = strat(config1[key], config2! [key]); } return config; }Copy the code

The interceptor

Axios’s interceptor is almost a mandatory configuration in a project. It can do some processing on the request/response body before/after the request, but to review the basic usage.

  • Use the use method to register an interceptor, using something like promise.then, which takes two arguments, the first to add the logic we expect the interceptor to handle, and the second to handle errors.

  • Remove an interceptor using the eject method.

  • Multiple interceptors can be added in the order that the request interceptor is added first and the response interceptor is added first.

We know that AXIos is implemented based on promises, and with the implementation of interceptors it’s not hard to think of a way to implement promise’s chained calls. Let’s review the chained calls. The promise. then method returns a Promise, which can then be called, and the data returned by the callback of the previous THEN is passed as an argument to the callback of the next THEN. So we can concatenate the request/response interceptor with the invocation sent by the request using the promise.then method.

Based on the above, we first implement an interceptor management class

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 () {/ / to hold the Interceptor enclosing interceptors = []} / / registered interceptors, returns to its index can be used to delete the 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 } } }Copy the code

Next add the Interceptors attribute to the Axios class, which has two values: request and response interceptors

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

Interceptors intercept requests, so the method that sent the request needs to be processed at the end. Details are as follows:

  • Declare a chain array to hold the promise invocation chain and first place the request sending method in.

  • The request/response interceptors’ foreach methods are called, respectively, to insert the request interceptors in reverse order to the front of the array and the response interceptors in order to the end of the array.

  • Declare an Resolved Promise to initiate the chained call, loop through the chain array, pull out each interceptor, and call it using the THEN method.

request(url: any, config? : any): AxiosPromise {// other logic const chain: PromiseChain[] = [{resolved: dispatchRequest, rejected: Undefined}] / / the front insert request interceptor order array enclosing interceptors. Request. The forEach (interceptor = > {chain. The unshift (interceptor)}) / / Insert response interceptor array tail enclosing interceptors. Response. The forEach (interceptor = > {chain. Push (interceptor)}) / / initializes a reslove state of promise Let promise = promise.resolve (config) while (chain-.length) {// Const {resolved, rejected} = chain-.shift ()! promise = promise.then(resolved, rejected) } return promise }Copy the code

The request to cancel

Request cancel axios in the project a common functions, a typical scenario is when the interface response slow and will trigger for many times (such as, click on the button to submit search input box, etc.), since each response time, so may appear after a request from the first request of fast response speed, can use the request to cancel at this time, That is, if the result of the previous request is not returned, cancel the current request.

To review how to use request cancellation, there are two ways to use it:

  • CancelToken CancelToken CancelToken CancelToken CancelToken CancelToken CancelToken CancelToken CancelToken CancelToken CancelToken
const CancelToken = axios.CancelToken;
const source = CancelToken.source();
axios.get('/test/get', {
  cancelToken: source.token
})
source.cancel('Operation canceled by the user.');
Copy the code
  • Method 2: Directly assign a new CancelToken instance to the CancelToken attribute of the configuration object and pass in a function that takes a function argument that handles the cancellation logic, assigns it to the manually declared cancel variable inside the function, and cancels the request by executing the cancel function.
const CancelToken = axios.CancelToken; let cancel; axios.get('/test/get', { cancelToken: new CancelToken(function executor(c) { cancel = c; })}); cancel();Copy the code

The next analysis of its implementation ideas. We know that HTTP request cancellation is done by calling the ABORT method of the XHR object. The problem is that we often don’t have direct access to XHR objects when we want to cancel requests. The XHR object can only be accessed during request sending, after new XMLHttpRequest(). In order to realize the request cancellation, we can only write the logic of the request cancellation in advance but do not execute it, and then execute this logic when it needs to be canceled in the future. Then we know that the callback specified in the promise.then method will wait for the state of the promise to change before executing, so we have an implementation idea. We can declare a promise of pending state, adding cancellation logic to the successful callback of the then method, xhr.abort. When it is necessary to cancel, the state of the promise can be changed. In other words, we’re pinning the logic of the request for cancellation on a promise. So where did this promise come from? Looking at the two ways interceptors can be used, the cancelToken property in the configuration object is that promise.

Since there are two ways to use this promise, there are two corresponding ways to get it: directly instantiating axios.CancelToken and calling the Source method of the CancelToken class. Axios takes this promise and ‘pins’ the cancellation logic on its then method. Let’s look at the implementation:

export default (config: AxiosRequestConfig): AxiosPromise => { return new Promise((resolve,reject)=>{ const { ...... CancelToken, // incoming promise} = config const Request = new XMLHttpRequest() //... If (cancelToken) {canceltoken.promise.then (reason => {// Call the cancellation method request.abort() // change the Axiospromise state to failure reject(reason) }) } }) }Copy the code

Next, look at how to change the state of that promise. Looking at the second use, the executor function takes arguments that are used to handle cancellation logic, changing the state of the promise. Therefore, when implementing the CancelToken class, we first declare a promise of a pending state, that is, we will not execute the resolve function, but will hold it temporarily (assign it to an external variable). Then execute the executor function and pass in a function that changes the state of the promise by executing a temporary resolve function

interface ResolvePromise { (reason? : string): void } class CancelToken { promise: Promise<string> reason? : string constructor(executor) { let resolvePromise: Promise = new Promise<string>(resolve => {ResolvePromise = resolve}) // The argument passed by the executor function is assigned to the external variable cancel, which cancels the request executor(message => {if (this.reason) {return} this.reason = message resolvePromise(this.reason) }) } }Copy the code

Now that the second use has been implemented, let’s look at the first. It is easy to see that the token returned by the source function is a promise of pending state, and the returned cancel function can be called directly without manual declaration. By comparison, it is easy to find that this is actually a layer of encapsulation compared to the second method. The instantiation of CancelToken and the handling logic of the cancellation function are implemented inside the Source method, which is then implemented.

class CancelToken { ... CancelToken CancelToken CancelToken CancelToken CancelToken CancelToken CancelToken CancelToken CancelTokenSource { let cancel const token = new CancelToken(c => { cancel = c }) return { cancel, token } } constructor(executor) {... }}Copy the code

The principal logic requesting cancellation has now been implemented. There is a special distinction to be made between axios.CancelToken and config’s CancelToken attributes, which are in fact class-instance relationships.

Source address, such as error kindly correct.