Axios 原始碼解讀 —— 原始碼實現篇

曬兜斯 發表於 2022-01-22
iOS

在上兩期,我們講解了 Axios 的原始碼:

今天,我們將實現一個簡易的 Axios,用於在 Node 端實現網路請求,並支援一些基礎配置,比如 baseURL、url、請求方法、攔截器、取消請求...

本次實現所有的原始碼都放在 這裡,感興趣的可以看看。

Axios 例項

本次我們將使用 typescript + node 來實現相關程式碼,這樣對大家理解程式碼也會比較清晰。

這裡,先來實現一個 Axios 類吧。

type AxiosConfig = {
  url: string;
  method: string;
  baseURL: string;
  headers: {[key: string]: string};
  params: {};
  data: {};
  adapter: Function;
  cancelToken?: number;
}

class Axios {
  public defaults: AxiosConfig;
  public createInstance!: Function;

  constructor(config: AxiosConfig) {
    this.defaults = config;
    this.createInstance = (cfg: AxiosConfig) => {
      return new Axios({ ...config, ...cfg });
    };
  }
}

const defaultAxios = new Axios(defaultConfig);

export default defaultAxios;

在上面,我們主要是實現了 Axios 類,使用 defaults 儲存預設配置,同時宣告瞭 createInstance 方法。該方法會建立一個新的 Axios 例項,並且會繼承上一個 Axios 例項的配置。

請求方法

接下來,我們將對 https://mbd.baidu.com/newspage/api/getpcvoicelist 發起一個網路請求,將響應返回的資料輸出在控制檯。

我們發起請求的語法如下:

import axios from './Axios';

const service = axios.createInstance({
  baseURL: 'https://mbd.baidu.com'
});

(async () => {
  const reply = await service.get('/newspage/api/getpcvoicelist');
  console.log(reply);
})();

request 方法

我們先來給我們的 Axios 類加上一個 requestget 方法吧。

import { dispatchRequest } from './request';

class Axios {
  //...

  public request(configOrUrl: AxiosConfig | string, config?: AxiosConfig) {
    if (typeof configOrUrl === 'string') {
      config!.url = configOrUrl;
    } else {
      config = configOrUrl;
    }
    
    const cfg = { ...this.defaults, ...config };
    return dispatchRequest(cfg);
  }

  public get(configOrUrl: AxiosConfig | string, config?: AxiosConfig) {
    return this.request(configOrUrl, {...(config || {} as any), method: 'get'});
  }
}

這裡 request 方法的實現和 axios 自帶的方法差異性不大。

現在,我們來編輯發起真實請求的 dispatchRequest 方法吧。

export const dispatchRequest = (config: AxiosConfig) => {
  const { adapter } = config;
  return adapter(config);
};

axios 一樣,呼叫了配置中的 adapter 來發起網路請求,而我們在 defaultConfig 中配置了預設的 adapter。(如下)

const defaultConfig: AxiosConfig = {
  url: '',
  method: 'get',
  baseURL: '',
  headers: {},
  params: {},
  data: {},
  adapter: getAdapter()
};

adapter 方法

接下來,我們重點來看看我們的 adapter 實現即可。

// 這裡偷個懶,直接用一個 fetch 庫
import fetch from 'isomorphic-fetch';
import { AxiosConfig } from './defaults';

// 檢測是否為超連結
const getEffectiveUrl = (config: AxiosConfig) => /^https?/.test(config.url) ? config.url : config.baseURL + config.url;

// 獲取 query 字串
const getQueryStr = (config: AxiosConfig) => {
  const { params } = config;
  if (!Object.keys(params).length) return '';

  let queryStr = '';
  for (const key in params) {
    queryStr += `&${key}=${(params as any)[key]}`;
  }

  return config.url.indexOf('?') > -1 
    ? queryStr
    : '?' + queryStr.slice(1);
};

const getAdapter = () => async (config: AxiosConfig) => {
  const { method, headers, data } = config;
  let url = getEffectiveUrl(config);
  url += getQueryStr(config);

  const response = await fetch(url, {
    method,
    // 非 GET 方法才傳送 body
    body: method !== 'get' ? JSON.stringify(data) : null,
    headers
  });

  // 組裝響應資料
  const reply = {
    data: await response.json(),
    status: response.status,
    statusText: response.statusText,
    headers: response.headers,
    config: config,
  };
  return reply;
};

export default getAdapter;

在這裡,我們的實現相對來說比較簡陋。簡單來說就是幾步

  1. 組裝 url
  2. 發起請求
  3. 組裝響應資料

看看效果

現在來控制檯執行一下我們的程式碼,也就是下面這,看看控制檯輸出吧。

import axios from './Axios';

const service = axios.createInstance({
  baseURL: 'https://mbd.baidu.com'
});

(async () => {
  const reply = await service.get('/newspage/api/getpcvoicelist');
  console.log(reply);
})();

image

從上圖可以看出,我們的 axios 最基礎的功能已經實現了(雖然偷了個懶用了 fetch)。

接下來,我們來完善一下它的能力吧。

攔截器

現在,我想要讓我的 axios 擁有新增攔截器的能力。

  1. 我將在請求處新增一個攔截器,在每次請求前加上一些自定義 headers
  2. 我將在響應處新增一個攔截器,直接取出響應的資料主體(data)和配置資訊(config),去除多餘的資訊。

程式碼實現如下:

// 新增請求攔截器
service.interceptors.request.use((config: AxiosConfig) => {
  config.headers.test = 'A';
  config.headers.check = 'B';
  return config;
});

// 新增響應攔截器
service.interceptors.response.use((response: any) => ({ data: response.data, config: response.config }));

改造 Axios 類,新增 interceptors

我們先來建立一個 InterceptorManager 類,用於管理我們的攔截器。(如下)

class InterceptorManager {
  private handlers: any[] = [];

  // 註冊攔截器
  public use(handler: Function): number {
    this.handlers.push(handler);
    return this.handlers.length - 1;
  }

  // 移除攔截器
  public eject(id: number) {
    this.handlers[id] = null;
  }

  // 獲取所有攔截器
  public getAll() {
    return this.handlers.filter(h => h);
  }
}

export default InterceptorManager;

定義好了攔截器後,我們需要在 Axios 類中加上攔截器 —— interceptors,如下:

class Axios {
  public interceptors: {
    request: InterceptorManager;
    response: InterceptorManager;
  }

  constructor(config: AxiosConfig) {
    // ...
    this.interceptors = {
      request: new InterceptorManager(),
      response: new InterceptorManager()
    }
  }
}

接下來,我們在 request 方法中處理一下這些攔截器的呼叫吧。(如下)

public async request(configOrUrl: AxiosConfig | string, config?: AxiosConfig) {
  if (typeof configOrUrl === 'string') {
    config!.url = configOrUrl;
  } else {
    config = configOrUrl;
  }

  const cfg = { ...this.defaults, ...config };
  // 將攔截器與真實請求合併在一個陣列內
  const requestInterceptors = this.interceptors.request.getAll();
  const responseInterceptors = this.interceptors.response.getAll();
  const handlers = [...requestInterceptors, dispatchRequest, ...responseInterceptors];

  // 使用 Promise 將陣列串聯呼叫
  let promise = Promise.resolve(cfg);
  while (handlers.length) {
    promise = promise.then(handlers.shift() as any);
  }

  return promise;
}

這裡主要是將攔截器和真實的請求合併成一個陣列,然後再使用 Promise 進行串聯。

這裡發現了一個自己還不知道的 Promise 知識點,在 Promise.resolve 中,不需要顯式返回一個 Promise 物件,Promise 內部會將返回的值包裝成一個 Promise 物件,支援 .then 語法呼叫。

現在,再執行一下我們的程式碼,看看加上攔截器後的執行效果吧。(如下圖)

image

從上圖可以看出,返回的內容中,只剩下了 dataconfig 欄位(響應攔截器)。並且在 config 欄位中也可以看到我們在 請求攔截器 中新增的自定義 headers 也起作用啦!

取消請求

最後,我們來實現 CancelToken 類,用於取消 axios 請求。

在實際應用中,我經常會使用 CancelToken 來自動檢測重複請求(來源於頻繁點選),然後取消掉更早的請求,僅使用最後一次請求作為有效請求。

所以,CancelToken 對我而言其實是個非常喜愛的功能,它本身的實現並不複雜,我們下面就開始來實現它吧。

我們先看看呼叫方式吧,下面我將在發起請求後 10ms 後(利用 setTimeout),將請求取消。也就是說,只有 10ms 內完成的請求才能成功。

import axios, { CancelToken } from './Axios';

// ...
(async () => {
  const source = CancelToken.source();
  // 10ms 後,取消請求
  setTimeout(() => {
    source.cancel('Operation canceled by the user.');
  }, 10);
  
  const reply = await service.get('/newspage/api/getpcvoicelist', { cancelToken: source.token });
  console.log(reply);
})();

我們先來理一理思路。

首先,我們使用了 CancelToken.source() 獲取了一個 cancelToken,並傳給了對應的請求函式。

接下來,我們應該是使用這個 token 進行查詢,查詢該請求是否被取消,如果被取消則丟擲錯誤,結束這次請求。

CancelToken

ok,思路已經清晰了,接下來就開始實現吧,先從 CancelToken 開始吧。

class CancelError extends Error {
  constructor(...options: any) {
    super(...options);
    this.name = 'CancelError';
  }
}

class CancelToken {
  private static list: any[] = [];

  // 每次返回一個 CancelToken 例項,用於取消請求
  public static source(): CancelToken {
    const cancelToken = new CancelToken();
    CancelToken.list.push(cancelToken);
    return cancelToken;
  }

  // 通過檢測是否有 message 欄位來確定該請求是否被取消
  public static checkIsCancel(token: number | null) {
    if (typeof token !== 'number') return false;
    
    const cancelToken: CancelToken = CancelToken.list[token];
    if (!cancelToken.message) return false;

    // 丟擲 CancelError 型別,在後續請求中處理該型別錯誤
    throw new CancelError(cancelToken.message);
  }

  public token = 0;
  private message: string = '';
  constructor() {
    // 使用列表長度作為 token id
    this.token = CancelToken.list.length;
  }

  // 取消請求,寫入 message
  public cancel(message: string) {
    this.message = message;
  }
}

export default CancelToken;

CancelToken 基本上完成了,它的主要功能就是使用一個 CancelToken 例項對應一個需要做處理的請求,然後在已取消的請求中丟擲了一個 CancelError 型別的拋錯。

處理 CancelError

接下來,我們需要在對應的請求處(dispatchRequest)新增取消請求檢測,最後再加上一個對應的響應攔截器處理對應錯誤即可。

export const dispatchRequest = (config: AxiosConfig) => {
  // 在發起請求前,檢測是否取消請求
  CancelToken.checkIsCancel(config.cancelToken ?? null);
  const { adapter } = config;
  return adapter(config).then((response: any) => {
    // 在請求成功響應後,檢測是否取消請求
    CancelToken.checkIsCancel(config.cancelToken ?? null);
    return response;
  });
};

由於我們的攔截器實現的太粗糙,並沒有新增失敗響應攔截器(本應在這裡處理),所以我這裡直接將整個請求包裹在 try ... catch 中處理。

try {
  const reply = await service.get('/newspage/api/getpcvoicelist', { cancelToken: source.token });
  console.log(reply);
} catch(e) {
  if (e.name === 'CancelError') {
    // 如果請求被取消,則不丟擲錯誤,只在控制檯輸出提示
    console.log(`請求被取消了, 取消原因: ${e.message}`);
    return;
  }
  throw e;
}

接下來,我們執行我們的程式,看看控制檯輸出吧!(如下圖)

image

大功告成!

小結

到這裡,我們的簡易版 axios 就已經完成啦。

它可以用於在 Node 端實現網路請求,並支援一些基礎配置,比如 baseURL、url、請求方法、攔截器、取消請求...

但是,還是有很多不夠完善的地方,感興趣的小夥伴可以找到下面的原始碼地址,繼續往下續寫。

原始碼地址,建議練習

最後一件事

如果您已經看到這裡了,希望您還是點個贊再走吧~

您的點贊是對作者的最大鼓勵,也可以讓更多人看到本篇文章!

如果覺得本文對您有幫助,請幫忙在 github 上點亮 star 鼓勵一下吧!