記錄---前端中斷請求的方式與原理

林恒發表於2024-11-22

🧑‍💻 寫在開頭

點贊 + 收藏 === 學會🤣🤣🤣

Axios.CancelToken

axios物件有一個屬性叫CancelToken,該屬性提供了中斷已經發出去的請求的方式。具體使用方式有兩種:

方式一:執行器模式

<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
  const CancelTokenFunc = axios.CancelToken;
  let cancel;
​
  // 傳送請求
  axios
    .get("https://jsonplaceholder.typicode.com/todos/1", {
      cancelToken: new CancelTokenFunc(function executor(c) {
        // 將 cancel 函式賦值給外部變數
        cancel = c;
      }),
    })
    .catch((error) => {
       console.log(error.message);
    });
​
  // 取消請求
  setTimeout(() => {
    cancel("Operation canceled by the user.");
  }, 1000);
</script>

在第4行中,我們先獲取一箇中斷建構函式CancelTokenFunc,我們在第10行中用這個建構函式new出一個例項賦值給get請求的引數cancelToken欄位。

在呼叫CancelTokenFunc建構函式new出一個例項的時候,我們傳入了一個執行器函式,該執行器會接受一個引數,這個引數就是用來控制中斷請求的取消函式,接著我們把該引數函式賦值給外部變數,這樣就可以在外部需要的時候執行中斷請求的操作。

執行上述程式碼,將瀏覽器調整成低速3G模式後,執行結果如下:

並在控制檯中輸入瞭如下資訊:

Operation canceled by the user.

方式二:令牌模式

// 建立一個 CancelToken 源
const CancelTokenFunc = axios.CancelToken;
const { token, cancel } = CancelTokenFunc.source();
​
// 傳送請求
axios
  .get("https://jsonplaceholder.typicode.com/todos/1", {
    cancelToken: token,
  })
  .catch((error) => {
    console.log(error.message);
  });
​
// 取消請求
setTimeout(() => {
  cancel("Operation canceled by the user.");
}, 1000);

  

在第3行程式碼中,用CancelTokenFuncsource方法生成一個取消令牌源,並從取消令牌源中解構出tokencancel欄位,然後在GET請求中將取消令牌源的token傳遞給cancelToken,接著在外部呼叫請求令牌源的cancel方法來取消請求。

執行結果和上面那種方式一樣,就不再贅述了。

相比於方式一的執行器模式,方式二的令牌模式更簡單易懂,另外需要注意一下,每次呼叫CancelTokenFunc.source()生成的令牌源是不一樣的。

AbortController

AbortController是一個Web API,用於控制和管理可中止的非同步操作,例如 fetch 請求、DOM 操作。接下來我們看看怎麼用AbortController來中止請求。

<!DOCTYPE html>
<html>
  <head>
    <title>中斷請求demo</title>
  </head>
  <body>
    <script>
      // 建立一個 AbortController 訊號源
      const controller = new AbortController();
      const { signal } = controller;
​
      // 傳送請求
      fetch("https://jsonplaceholder.typicode.com/todos/1", {
        signal,
      }).catch((error) => {
        console.log(error);
      });
​
      // 取消請求
      setTimeout(() => {
        controller.abort("Operation canceled by the user.");
      }, 1000);
    </script>
  </body>
</html>

  

在第9行中,我們建立了一個AbortController訊號源,在fetch請求的時候傳遞一個訊號給請求的signal引數,之後便可以在請求的外部透過呼叫訊號源的abort方法來取消請求。

這個API的用法其實和Axios.CancelToken的令牌模式一樣,但是該API會有相容性問題,需要透過引入yet-another-abortcontroller-polyfill或者abortcontroller-polyfill來解決。

令牌中斷請求原理

中斷請求的原理其實很簡單,只要監聽到呼叫取消函式,就執行xhr.abort()(其中,xhrXMLHttpRequest的例項)中斷請求即可,值得探究的是令牌中斷請求的原理,也就是tokencancel之間的對映關係是怎麼建立的。

首先我們需要模擬下請求取消的過程,其程式碼如下:

function fetchData(url, options = {}) {
  const { cancelToken } = options;
​
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', url);
​
    // 監聽請求狀態變化,處理請求的常規邏輯
    xhr.onreadystatechange = () => {
      if (xhr.readyState === 4) {
        if (xhr.status >= 200 && xhr.status < 300) {
          resolve(xhr.responseText);
        }
      }
    };
​
    // 監聽取消請求
    if (cancelToken) {
      // ... 需要在外界呼叫cancel請求的時候,呼叫xhr.abort()方法中止請求,
      // 並在這裡呼叫reject函式將Promise物件的狀態改成rejected
    }
​
    xhr.send();
  });
}
fetchData("https://jsonplaceholder.typicode.com/todos/1").then((res) => {
  console.log(res);
});

  

上述程式碼中,我們在fetchData中返回一個Promise物件,並在Promise物件新建一個原生的XMLHttpRequest物件。

其中的關鍵程式碼,在於監聽取消請求這個判斷裡。

在監聽取消請求這個判斷中,我們只有一個cancelToken屬性,這個屬性需要在外界執行cancel時呼叫xhr.abort()來中止已經發出去的請求,同時將fetchData內的Promise物件的狀態改成Rejected

因此,cancelToken需要攜帶一個回撥屬性,在外界執行cancel方法時觸發回撥。

自然而然的,我們就想到,能否給cancelToken掛載一個Promise例項的屬性,然後將這個Promise屬性的resolved方法傳遞給cancel,這樣,當執行cancel函式的時候,其實就是執行resolve(),從而改變Promise例項的狀態,我們就能在Promise例項的then方法中執行需要的操作。

也就是說,監聽取消請求需要被設計成這樣:

function fetchData(url, options = {}) {
  const { cancelToken } = options;
​
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', url);
​
    // 監聽請求狀態變化,處理請求的常規邏輯
    // 其他程式碼
​
     // 監聽取消請求
    if (cancelToken) {
      // 需要在外界呼叫cancel請求的時候,呼叫xhr.abort()方法中止請求 
      // 並呼叫reject函式將Promise物件的狀態改成rejected
      cancelToken.promise.then((msg) => {
        xhr.abort();
        reject(new Error(msg));
      })
    }。
    xhr.send();
  });
}
​

其中,cancelToken.promise是一個Promise例項的屬性。

現在,我們繼續設計建構函式CancelToken的實現,這個函式需要有一個source方法,該方法返回兩個屬性,一個是token,一個是cancel函式,其中token應該有一個promise屬性,該屬性是一個Promise例項,該例項的resolved方法將傳遞給cancel函式。

function CancelToken() {}
CancelToken.source = function () {
  let cancel;
  const token = {
    promise: new Promise((resolve) => {cancel = resolve})
  };
  return {
    cancel,
    token,
  };
};

  

上述程式碼裡,我們將token宣告為物件,並在第5行中給token新增一個promise屬性,該屬性是一個Promise例項,並且將Promise例項的resolve方法傳遞給了cancel變數,這樣,當呼叫執行cancel()的時候,就是在執行resolve()tokenpromise屬性就能觸發then回撥函式。

這樣,我們就實現了令牌中斷請求的要求,並將cancel和token關聯起來了。到這裡,我們就明白每一次呼叫source方法生成的canceltoken為啥能一一對應了。

執行器模式原理

CancelToken不僅支援令牌中斷模式,還支援執行器中斷模式,而執行器模式是需要透過CancelToken的建構函式實現。

該建構函式的實現有三個細節需要注意:

  1. 首先,該建構函式同樣需要給例項物件掛載一個promise屬性,該屬性是一個Promise例項。這樣才能支援在token.promise.then回撥裡執行取消操作。
  2. 其次,需要接受一個執行器函式作為入參,
  3. 最後,作為入參的執行器,它本身也有入參,它的入參是一個方法,在這個方法呼叫的時候,執行promise屬性的resolve方法,這樣才能觸發toekn.promise.then回撥。

帶著上面三個細節,我們來嘗試實現CancelToken建構函式:

function CancelToken(executor) {
  let resolvePromise;
  this.promise = new Promise((resolve) => { resolvePromise = resolve;});
  
  executor(function c() {
    resolvePromise();
  })
}

  

上述程式碼中,我們依照三個細節,來一一解讀下:

  1. 對於第一個細節,我們在第3行程式碼中,我們在this上掛載了promise屬性,該屬性是一個Promise物件,同時,為了達到在外部觸發該Promise物件的狀態變更,我們將其resolve方法儲存給了外部變數resolvePromise
  2. 對於第二個細節,我們在第1行宣告建構函式的時候就宣告瞭executor入參。
  3. 對於第三個細節,我們在第5行中,在執行器呼叫的時候傳入一個函式作為入參,同時在函式內部執行resolvePromise()觸發this.promise狀態變更。

這樣,我們就實現了簡單的CancelToken的建構函式。

兩個模式結合

接下來我們將執行器模式結合令牌中斷模式的程式碼一起看下:

function CancelToken(executor) {
  let resolvePromise;
  this.promise = new Promise((resolve) => { resolvePromise = resolve;});
  
  executor(function c() {
    resolvePromise();
  })
}
CancelToken.source = function () {
  let cancel;
  const token = {
    promise: new Promise((resolve) => {cancel = resolve})
  };
  return {
    cancel,
    token,
  };
};

  

結合令牌中斷模式和執行器中斷模式的程式碼一起看後,我們發現,第3行中給this.promise賦值了一個Promies例項,第11行中token需要的promise屬性,也是一個Promise例項,因此,這兩個能最佳化一下:

function CancelToken(executor) {
  let resolvePromise;
  this.promise = new Promise((resolve) => { resolvePromise = resolve;});
  
  executor(function c() {
    resolvePromise();
  })
}
CancelToken.source = function () {
  let cancel;
  const token = new CancelToken(function executor(c) {
    cancel = c;
  });
  return {
    cancel,
    token,
  };
};

  

上述程式碼中,我們修改了第11行程式碼,給token賦值為CancelToken例項物件,並在例項化的時候傳入一個執行器函式executor,該執行器函式接受一個引數c,並將c賦值給了外部變數cancel屬性,這樣,執行cancel的流程就變成下面這樣:

  1. 呼叫執行第15行返回的cancel()函式。
  2. cancel函式來自於第11行中executor的入參c
  3. 第11行中的入參c來自於第5行執行executor時的賦值。
  4. 最終,執行cancel()的時候,就會執行第6行中的resolvePromise()方法,從而改變promise屬性的狀態,觸發then回撥函式。

測試手寫版CancelToken

接下來,使用我們實現的CancelToken來試試取消網路請求,

方式一:執行器模式示例如下:

<script>
  function CancelToken(executor) {
    let resolvePromise;
    this.promise = new Promise((resolve) => {
      resolvePromise = resolve;
    });

    executor(function c() {
      resolvePromise();
    });
  }
  CancelToken.source = function () {
    let cancel;
    const token = new CancelToken(function executor(c) {
      cancel = c;
    });
    return {
      cancel,
      token,
    };
  };

  function fetchData(url, options = {}) {
    const { cancelToken } = options;

    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.open("GET", url);

      // 監聽請求狀態變化,處理請求的常規邏輯
      xhr.onreadystatechange = () => {
        if (xhr.readyState === 4) {
          if (xhr.status >= 200 && xhr.status < 300) {
            resolve(xhr.responseText);
          }
        }
      };

      // 監聽取消請求
      if (cancelToken) {
        cancelToken.promise.then((msg) => {
          xhr.abort();
          reject(`Request cancelled: ${msg}`);
        });
      }

      xhr.send();
    });
  }

  let cancel;

  fetchData("https://jsonplaceholder.typicode.com/todos/1", {
    cancelToken: new CancelToken((c) => {
      cancel = c;
    }),
  }).catch((e) => {
    console.log(e);
  });

  setTimeout(() => {
    cancel("取消請求");
  }, 500);
</script>

將網速調整成慢速3G後執行後效果如下:

控制檯列印的結果,有個undefined

方式二:令牌模式示例如下:

<script>
  function CancelToken(executor) {
    // ...
  }
  CancelToken.source = function () {
    // ...
  };

  function fetchData(url, options = {}) {
	  // ...
  }
  const { token, cancel } = CancelToken.source();

  fetchData("https://jsonplaceholder.typicode.com/todos/1", {
    cancelToken: token,
  }).catch((e) => {
    console.log(e);
  });

  setTimeout(() => {
    cancel("取消請求");
  }, 500);
</script>

執行結果同執行器模式,這裡就不截圖了。

最佳化

我們手寫版的CancelToken已經實現了基本的功能,也就是取消請求,但是有個問題,那就是呼叫cancel("取消請求")裡,引數沒有傳遞到給cancelToken.promise.then回撥函式,所以列印出來的結果裡有個undefined。因此,我們需要稍微最佳化下CancelToken,補齊引數的傳遞。

最佳化的方式也很簡單,取消函式cancel的入參,會透過形參賦值的方式傳遞給c的入參,因此我們只需要拿c的入參給resolve就行了。具體如下:

function CancelToken(executor) {
  let resolvePromise;
  this.promise = new Promise((resolve) => {
    resolvePromise = resolve;
  });

  executor(function c(msg) {
    resolvePromise(msg); // 這裡將cancel的入參傳遞給resolve
  });
}

這樣,就完成了引數的傳遞。

還有一點需要注意,那就是cancel可能會被多次呼叫,我們需要在第二次之後的呼叫直接結束。這裡我們就可以在第一次呼叫cancel的時候用傳入的引數做個標記,有引數則代表已經呼叫過cancel,後續再呼叫cancel時直接返回,這樣就能防止多次呼叫。

function CancelToken(executor) {
  let resolvePromise;
  this.promise = new Promise((resolve) => {
    resolvePromise = resolve;
  });
	
  const token = this;
  executor(function c(msg) {
    if (token.reason) {
      return; // 如果已經有了reason,說明之前呼叫過cancel,後續再次呼叫直接接結束
    }
    token.reason = msg || 'cancel request';
    resolvePromise(token.reason); // 這裡將cancel的入參傳遞給resolve
  });
}

上述程式碼中,我們在executor的外部,也就是第7行先儲存this指向為token,然後在第9行中判斷是token是否存在取消原因欄位reason,有的話,說明之前已經呼叫過cancel了,這時再次呼叫cancel就是重複執行cancel方法,我們可以直接retuen從而避免重複取消請求。

在第12行中,我們給token.reason賦了一個預設值cancel request,因為第一次呼叫cancel時有可能沒傳參。

這樣,我們就完成了CancelToken的手寫版最佳化,完整程式碼如下:

function CancelToken(executor) {
  let resolvePromise;
  this.promise = new Promise((resolve) => {
    resolvePromise = resolve;
  });
	
  const token = this;
  executor(function c(msg) {
    if (token.reason) {
      return;
    }
    token.reason = msg || 'cancel request';
    resolvePromise(token.reason);
  });
}
CancelToken.source = function () {
  let cancel;
  const token = new CancelToken(function executor(c) {
    cancel = c;
  });
  return {
    cancel,
    token,
  };
};

  

本文轉載於:https://juejin.cn/post/7395446371031728169

如果對您有所幫助,歡迎您點個關注,我會定時更新技術文件,大家一起討論學習,一起進步。

記錄---前端中斷請求的方式與原理

相關文章