從 axios 源碼中了解到的 Promise 鏈與請求的取消

  • 2019 年 10 月 10 日
  • 筆記

axios 中一個請求取消的示例:

axios 取消請求的示例程式碼
import React, { useState, useEffect } from "react";  import axios, { AxiosResponse } from "axios";    export default function App() {    const [index, setIndex] = useState(0);    const [imgUrl, setImgUrl] = useState("");    useEffect(() => {      console.log(`loading ${index}`);      const source = axios.CancelToken.source();      axios        .get("https://dog.ceo/api/breeds/image/random", {          cancelToken: source.token        })        .then((res: AxiosResponse<{ message: string; status: string }>) => {          console.log(`${index} done`);          setImgUrl(res.data.message);        })        .catch(err => {          if (axios.isCancel(source)) {            console.log(err.message);          }        });        return () => {        console.log(`canceling ${index}`);        source.cancel(`canceling ${index}`);      };    }, [index]);      return (      <div>        <button          onClick={() => {            setIndex(index + 1);          }}        >          click        </button>        <div>          <img src={imgUrl} alt="" />        </div>      </div>    );  }

axios 中一個請求取消的示例

axios 中一個請求取消的示例

通過解讀其源碼不難實現出一個自己的版本。Here we go…

Promise 鏈與攔截器

這個和請求的取消其實關係不大,但不妨先來了解一下,axios 中如何組織起來一個 Promise 鏈(Promise chain),從而實現在請求前後可執行一個攔截器(Interceptor)的。

簡單來說,通過 axios 發起的請求,可在請求前後執行一些函數,來實現特定功能,比如請求前添加一些自定義的 header,請求後進行一些數據上的統一轉換等。

用法

首先,通過 axios 實例配置需要執行的攔截器:

axios.interceptors.request.use(function (config) {      console.log('before request')      return config;    }, function (error) {      return Promise.reject(error);    });    axios.interceptors.response.use(function (response) {      console.log('after response');      return response;    }, function (error) {      return Promise.reject(error);    });

然後每次請求前後都會列印出相應資訊,攔截器生效了。

axios({      url: "https://dog.ceo/api/breeds/image/random",      method: "GET"  }).then(res => {      console.log("load success");  });

下面編寫一個頁面,放置一個按鈕,點擊後發起請求,後續示例中將一直使用該頁面來測試。

import React from "react";  import axios from "axios";    export default function App() {    const sendRequest = () => {      axios.interceptors.request.use(        config => {          console.log("before request");          return config;        },        function(error) {          return Promise.reject(error);        }      );        axios.interceptors.response.use(        response => {          console.log("after response");          return response;        },        function(error) {          return Promise.reject(error);        }      );        axios({        url: "https://dog.ceo/api/breeds/image/random",        method: "GET"      }).then(res => {        console.log("load success");      });    };    return (      <div>        <button onClick={sendRequest}>click me</button>      </div>    );  }  

點擊按鈕後運行結果:

before request  after response  load success  

攔截器機制的實現

實現分兩步走,先看請求前的攔截器。

請求前攔截器的實現

Promise 的常規用法如下:

new Promise(resolve,reject);

假如我們封裝一個類似 axios 的請求庫,可以這麼寫:

interface Config {    url: string;    method: "GET" | "POST";  }    function request(config: Config) {    return new Promise((resolve, reject) => {      const xhr = new XMLHttpRequest();      xhr.open(config.method, config.url);      xhr.onload = () => {        resolve(xhr.responseText);      };      xhr.onerror = err => {        reject(err);      };      xhr.send();    });  }

除了像上面那個直接 new 一個 Promise 外,其實任意對象值都可以形成一個 Promise,方法是調用 Promise.resolve

Promise.resolve(value).then(()=>{ /**... */ });

這種方式創建 Promise 的好處是,我們可以從 config 開始,創建一個 Promise 鏈,在真實的請求發出前,先執行一些函數,像這樣:

function request(config: Config) {    return Promise.resolve(config)      .then(config => {        console.log("interceptor 1");        return config;      })      .then(config => {        console.log("interceptor 2");        return config;      })      .then(config => {        return new Promise((resolve, reject) => {          const xhr = new XMLHttpRequest();          xhr.open(config.method, config.url);          xhr.onload = () => {            resolve(xhr.responseText);          };          xhr.onerror = err => {            reject(err);          };          xhr.send();        });      });  }

將前面示例中 axios 替換為我們自己寫的 request 函數,示例可以正常跑起來,輸出如下:

interceptor 1  interceptor 2  load success  

這裡,已經實現了 axios 中請求前攔截器的功能。仔細觀察,上面三個 then 當中的函數,形成了一個 Promise 鏈,在這個鏈中順次執行,每一個都可以看成一個攔截器,即使是執行發送請求的那個 then

於是我們可以將他們抽取成三個函數,每個函數就是一個攔截器

function interceptor1(config: Config) {    console.log("interceptor 1");    return config;  }  function interceptor2(config: Config) {    console.log("interceptor 2");    return config;  }    function xmlHttpRequest<T>(config: Config) {    return new Promise<T>((resolve, reject) => {      const xhr = new XMLHttpRequest();      xhr.open(config.method, config.url);      xhr.onload = () => {        resolve(xhr.responseText as any);      };      xhr.onerror = err => {        reject(err);      };      xhr.send();    });  }

接下來要做的,就是從 Promise 鏈的頭部 Promise.resolve(config) 開始,將上面三個函數串起來。藉助 Monkey patch 這不難實現:

function request<T = any>(config: Config) {    let chain: Promise<any> = Promise.resolve(config);    chain = chain.then(interceptor1);    chain = chain.then(interceptor2);    chain = chain.then(xmlHttpRequest);    return chain as Promise<T>;  }

然後,將上面硬編碼的寫法程式化一下,就實現了任意個請求前攔截器的功能。

擴展配置,以接收攔截器:

interface Config {    url: string;    method: "GET" | "POST";    interceptors?: Interceptor<Config>[];  }

創建一個數組,將執行請求的函數做為默認的元素放進去,然後將用戶配置的攔截器壓入數組前面,這樣形成了一個攔截器的數組。最後再遍歷這個數組形成 Promise 鏈。

function request<T = any>({ interceptors = [], ...config }: Config) {    // 發送請求的攔截器為默認,用戶配置的攔截器壓入數組前面    const tmpInterceptors: Interceptor<any>[] = [xmlHttpRequest];    interceptors.forEach(interceptor => {      tmpInterceptors.unshift(interceptor);    });    let chain: Promise<any> = Promise.resolve(config);    tmpInterceptors.forEach(interceptor => (chain = chain.then(interceptor)));    return chain as Promise<T>;  }

使用:

request({      url: "https://dog.ceo/api/breeds/image/random",      method: "GET",      interceptors: [interceptor1, interceptor2]  }).then(res => {      console.log("load success");  });

執行結果:

interceptor 2  interceptor 1  load success  

注意這裡順序為傳入的攔截器的反序,不過這不重要,可通過傳遞的順序來控制。

響應後攔截器

上面實現了在請求前執行一序列攔截函數,同理,如果將攔截器壓入到數組後面,即執行請求那個函數的後面,便實現了響應後的攔截器。

繼續擴展配置,將請求與響應的攔截器分開:

interface Config {    url: string;    method: "GET" | "POST";    interceptors?: {      request: Interceptor<Config>[];      response: Interceptor<any>[];    };  }

更新 request 方法,請求前攔截器的邏輯不變,將新增的響應攔截器通過 push 壓入數組後面:

function request<T = any>({    interceptors = { request: [], response: [] },    ...config  }: Config) {    const tmpInterceptors: Interceptor<any>[] = [xmlHttpRequest];    interceptors.request.forEach(interceptor => {      tmpInterceptors.unshift(interceptor);    });      interceptors.response.forEach(interceptor => {      tmpInterceptors.push(interceptor);    });      let chain: Promise<any> = Promise.resolve(config);    tmpInterceptors.forEach(interceptor => (chain = chain.then(interceptor)));    return chain as Promise<T>;  }

類似 interceptor1 interceptor2,新增兩個攔截器用於響應後執行,

function interceptor3<T>(res: T) {    console.log("interceptor 3");    return res;  }    function interceptor4<T>(res: T) {    console.log("interceptor 4");    return res;  }

測試程式碼:

request({      url: "https://dog.ceo/api/breeds/image/random",      method: "GET",      interceptors: {      request: [interceptor1, interceptor2],      response: [interceptor3, interceptor4]      }  }).then(res => {      console.log("load success");  });

運行結果:

interceptor 2  interceptor 1  interceptor 3  interceptor 4  load success  

不難看出,當我們發起一次 axios 請求時,其實是發起了一次 Promise 鏈,鏈上的函數順次執行。

request interceptor 1  request interceptor 2  ...  request  response interceptor 1  response interceptor 2  ...  

因為拉弓沒有回頭箭,請求發出後,能夠取消的是後續操作,而不是請求本身,所以上面的 Promise 鏈中,需要實現 request 之後的攔截器和後續回調的取消執行。

request interceptor 1  request interceptor 2  ...  request  # ? 後續操作不再執行  response interceptor 1  response interceptor 2  ...  

請求的取消

Promise 鏈的中斷

中斷 Promise 鏈的執行,可通過 throw 異常來實現。

添加一個中間函數,將執行請求的函數進行封裝,無論其成功與否,都拋出異常將後續執行中斷。

function adapter(config: Config) {    return xmlHttpRequest(config).then(      res => {        throw "baddie!";      },      err => {        throw "baddie!";      }    );  }

更新 request 函數使用 adapter 而不是直接使用 xmlHttpRequest

function request<T = any>({    interceptors = { request: [], response: [] },    ...config  }: Config) {  -  const tmpInterceptors: Interceptor<any>[] = [xmlHttpRequest];  +  const tmpInterceptors: Interceptor<any>[] = [adapter];    interceptors.request.forEach(interceptor => {      tmpInterceptors.unshift(interceptor);    });      interceptors.response.forEach(interceptor => {      tmpInterceptors.push(interceptor);    });      let chain: Promise<any> = Promise.resolve(config);    tmpInterceptors.forEach(interceptor => (chain = chain.then(interceptor)));    return chain as Promise<T>;  }

再次執行其輸出結果為:

interceptor 2  interceptor 1  Uncaught (in promise) baddie!  

請求取消的實現

按照 axios 的實現思路,要實現請求的取消,需要先創建一個 token,通過該 token 可調用一個 cancel 方法;通過將 token 傳遞到配置中,在發起請求時對 token 進行檢查以判定該 token 是否執行過取消,如果是則利用上面的思路,將 Promise 鏈中斷掉。

構造 token

所以不難看出,這裡的 token 對象至少:

  • 有一個 cancel 方法
  • 有一個欄位記錄 cancel 方法是否被調用過

額外地,

  • 如果有一個欄位記錄取消的原因,那也不錯。

由此我們得到這麼一個類:

class CancelTokenSource {    private _canceled = false;    get canceled() {      return this._canceled;    }    private _message = "unknown reason";    get message() {      return this._message;    }      cancel(reason?: string) {      if (this.canceled) return;      if (reason) {        this._message = reason;      }      this._canceled = true;    }  }

添加 token 到配置

擴展配置,以接收一個用來取消的 token 對象:

interface Config {    url: string;    method: "GET" | "POST";  +  cancelToken?: CancelTokenSource;    interceptors?: {      request: Interceptor<Config>[];      response: Interceptor<any>[];    };  }

請求邏輯中處理取消

同時更新 xmlHttpRequest 函數,判斷 token 的狀態是否調用過取消,如果是則調用 xhr.abort(),同時添加 onabort 回調以 reject 掉 Promise:

function xmlHttpRequest<T>(config: Config) {    return new Promise<T>((resolve, reject) => {      const xhr = new XMLHttpRequest();      xhr.open(config.method, config.url);      xhr.onload = () => {        resolve(xhr.responseText as any);      };      xhr.onerror = err => {        reject(err);      };  +    xhr.onabort = () => {  +      reject();  +    };  +    if (config.cancelToken) {  +      xhr.abort();  +    }      xhr.send();    });  }

取消的調用

將拋異常的程式碼抽取成方法以在多處調用,更新 adapter 的邏輯,在沒有取消的情況下正常返回和 reject。

function throwIfCancelRequested(config: Config) {    if (config.cancelToken && config.cancelToken.canceled) {      throw config.cancelToken.message;    }  }    function adapter(config: Config) {    throwIfCancelRequested(config);    return xmlHttpRequest(config).then(      res => {        throwIfCancelRequested(config);        return res;      },      err => {        throwIfCancelRequested(config);        return Promise.reject(err);      }    );  }

測試請求的取消

似乎一切 okay,接下來測試一波。以下程式碼期望每次點擊按鈕發起請求,請求前先取消掉之前的請求。為了區分每次不同的請求,添加 index 變數,按鈕點擊時自增。

import React, { useEffect, useState } from "react";    export default function App() {    const [index, setIndex] = useState(0);      useEffect(() => {      const token = new CancelTokenSource();      request({        url: "https://dog.ceo/api/breeds/image/random",        method: "GET",        cancelToken: token,        interceptors: {          request: [interceptor1, interceptor2],          response: [interceptor3, interceptor4]        }      })        .then(res => {          console.log(`load ${index} success`);        })        .catch(err => {          console.log("outer catch ", err);        });        return () => {        token.cancel(`just cancel ${index}`);      };    }, [index]);      return (      <div>        <button          onClick={() => {            setIndex(index + 1);          }}        >          click me        </button>      </div>    );  }

載入頁面進行測試,useEffect 會在頁面載入後首次運行,會觸發一次完整的請求流程。然後連續點擊兩次按鈕,以取消掉兩次中的前一次。運行結果:

interceptor 2  interceptor 1  interceptor 3  interceptor 4  load 0 success    interceptor 2  interceptor 1    interceptor 2  interceptor 1  outer catch  just cancel 1  interceptor 3  interceptor 4  load 2 success

現有實現中的問題

從輸出來看,

  • 第一部分為首次請求,是一次正常的請求。
  • 第二部分為第一次點擊的請求攔截器的執行。
  • 第三部分為第二次點擊,將第一次請求進行了取消,然後完成一次完整的請求。

從輸出和網路請求來看,有兩個問題:

  • xhr.abort() 沒有生效,連續的兩次點擊中,瀏覽器調試工具中會有兩條狀態為 200 的請求。
  • 第一條請求後續的回調確實被取消掉了,但它是在等待請求成功後,在成功回調中取消的,這點可通過在取消函數中添加標誌位來查看。
function throwIfCancelRequested(config: Config, flag?: number) {    if (config.cancelToken && config.cancelToken.canceled) {      console.log(flag);      throw config.cancelToken.message;    }  }    function adapter(config: Config) {    throwIfCancelRequested(config, 1);    return xmlHttpRequest(config).then(      res => {      //ℹ 後續輸出證明,實際生效的是此處        throwIfCancelRequested(config, 2);        return res;      },      err => {      //ℹ 而非此處,即使取消的動作是在請求進行過程中        throwIfCancelRequested(config, 3);        return Promise.reject(err);      }    );  }

輸出:

interceptor 2  interceptor 1  interceptor 2  interceptor 1  2  outer catch  just cancel 1  interceptor 3  interceptor 4  load 2 success

優化

下面的優化需要解決上面的問題。所用到的方法便是 axios 中的邏輯,也是一開始看源碼會不太理解的地方。

其實外部調用 cancel() 的時機並不確定,所以 token 對象上記錄其是否被取消的欄位,何時被置為 true 是不確定的,因此,我們取消請求的邏輯(xhr.abort())應該是在一個 Promise 中來完成。

因此,在 CancelTokenSource 類中,創建一個 Promise 類型的欄位,它會在 cancel() 方法被調用的時候 resolve 掉。

更新後的 CancelTokenSource 類:

class CancelTokenSource {    public promise: Promise<unknown>;    private resolvePromise!: (value?: any) => void;    constructor() {      this.promise = new Promise(resolve => {        this.resolvePromise = resolve;      });    }    private _canceled = false;    get canceled() {      return this._canceled;    }    private _message = "unknown reason";    get message() {      return this._message;    }      cancel(reason?: string) {      if (reason) {        this._message = reason;      }      this._canceled = true;      this.resolvePromise();    }  }

更新後訪問 canceled 欄位的邏輯:

function xmlHttpRequest<T>(config: Config) {    return new Promise<T>((resolve, reject) => {      const xhr = new XMLHttpRequest();      xhr.open(config.method, config.url);      xhr.onload = () => {        resolve(xhr.responseText as any);      };      xhr.onerror = err => {        reject(err);      };      xhr.onabort = () => {        reject();      };      if (config.cancelToken) {        config.cancelToken.promise.then(() => {          xhr.abort();        });      }      xhr.send();    });  }

測試優化後的版本

輸出結果:

interceptor 2  interceptor 1  interceptor 3  interceptor 4  load 0 success    interceptor 2  interceptor 1    interceptor 2  3  interceptor 1  outer catch  just cancel 1  interceptor 3  interceptor 4  load 2 success  

瀏覽器調試工具的網路會有一次飄紅被 abort 掉的請求,同時上面的輸出(生效的地方是 3 而非 2)顯示被取消的請求正確地 reject 掉了。

完整程式碼

自己實現的請求取消機制完整程式碼
import React, { useState, useEffect } from "react";    class CancelTokenSource {    public promise: Promise<unknown>;    private resolvePromise!: (value?: any) => void;    constructor() {      this.promise = new Promise(resolve => {        this.resolvePromise = resolve;      });    }    private _canceled = false;    get canceled() {      return this._canceled;    }    private _message = "unknown reason";    get message() {      return this._message;    }      cancel(reason?: string) {      if (reason) {        this._message = reason;      }      this._canceled = true;      this.resolvePromise();    }  }    type Interceptor<T> = (value: T) => T | Promise<T>;    interface Config {    url: string;    method: "GET" | "POST";    cancelToken?: CancelTokenSource;    interceptors?: {      request: Interceptor<Config>[];      response: Interceptor<any>[];    };  }    function interceptor1(config: Config) {    console.log("interceptor 1");    return config;  }  function interceptor2(config: Config) {    console.log("interceptor 2");    return config;  }    function interceptor3<T>(res: T) {    console.log("interceptor 3");    return res;  }    function interceptor4<T>(res: T) {    console.log("interceptor 4");    return res;  }    function xmlHttpRequest<T>(config: Config) {    return new Promise<T>((resolve, reject) => {      const xhr = new XMLHttpRequest();      xhr.open(config.method, config.url);      xhr.onload = () => {        resolve(xhr.responseText as any);      };      xhr.onerror = err => {        reject(err);      };      xhr.onabort = () => {        reject();      };      if (config.cancelToken) {        config.cancelToken.promise.then(() => {          xhr.abort();        });      }      xhr.send();    });  }    function throwIfCancelRequested(config: Config, flag?: number) {    if (config.cancelToken && config.cancelToken.canceled) {      console.log(flag);      throw config.cancelToken.message;    }  }    function adapter(config: Config) {    throwIfCancelRequested(config, 1);    return xmlHttpRequest(config).then(      res => {        throwIfCancelRequested(config, 2);        return res;      },      err => {        throwIfCancelRequested(config, 3);        return Promise.reject(err);      }    );  }    function request<T = any>({    interceptors = { request: [], response: [] },    ...config  }: Config) {    const tmpInterceptors: Interceptor<any>[] = [adapter];    interceptors.request.forEach(interceptor => {      tmpInterceptors.unshift(interceptor);    });      interceptors.response.forEach(interceptor => {      tmpInterceptors.push(interceptor);    });      let chain: Promise<any> = Promise.resolve(config);    tmpInterceptors.forEach(interceptor => (chain = chain.then(interceptor)));    return chain as Promise<T>;  }    export default function App() {    const [index, setIndex] = useState(0);      useEffect(() => {      const token = new CancelTokenSource();      request({        url: "https://dog.ceo/api/breeds/image/random",        method: "GET",        cancelToken: token,        interceptors: {          request: [interceptor1, interceptor2],          response: [interceptor3, interceptor4]        }      })        .then(res => {          console.log(`load ${index} success`);        })        .catch(err => {          console.log("outer catch ", err);        });        return () => {        token.cancel(`just cancel ${index}`);      };    }, [index]);      return (      <div>        <button          onClick={() => {            setIndex(index + 1);          }}        >          click me        </button>      </div>    );  }

運行效果

運行效果

相關資源