如何優雅地中斷 Promise?來試試 AbortController 吧!

歡迎大家來到 前端小課堂 的第五期,今天我們來聊一聊如何終止正在進行中的 Fetch 以及 Promise。文中會跟大家詳細介紹這裏面的兩個關鍵知識點 AbortControllerAbortSignal。對動手實踐比較感興趣的同學還可以看對應的視頻版本。

大家在平時的開發過程中估計不會經常碰到需要主動取消一個 Fetch 請求的需求,所以一部分同學可能對這一塊知識不是很瞭解。沒有關係,看完這篇文章你就能夠掌握關於如何終止一個 Fetch 請求或者一個 Promise 的全部技能了。那我們趕快開始吧~

這篇文章比我預期要花費的時間和精力還要多,所以文章比較長,大家現在沒時間瀏覽的可以先收藏起來,以後慢慢看。如果覺得這篇文章不錯的話,也可以幫忙點個贊,轉發支持一下。

使用 AbortController 終止 Fetch 請求

fetch 之前,我們請求後端的資源使用的方式是通過 XMLHttpRequest 這個構造函數,創建一個 xhr 對象,然後通過這個 xhr 對象進行請求的發送以及接收。

const xhr = new XMLHttpRequest();
xhr.addEventListener('load'function (e) {
    console.log(this.responseText);
});
xhr.open('GET''https://jsonplaceholder.typicode.com/todos/1');
xhr.send();

這個 xhr 上也存在一個 abort 方法用來進行請求的終止操作。但是需要注意的是,這個 abort 的執行過程是比較模糊的。 我們不清楚 abort 在什麼時候可以不進行或終止對應的網絡請求,又或者如果在調用 abort 方法和獲取到請求的資源之間存在競爭條件的時候會發生什麼。我們可以通過簡單的代碼來實踐一下:

// ... 省略掉上面的代碼
setTimeout(() ={
    xhr.abort();
}, 10);

通過添加一個延時,然後取消掉對應的請求;在控制檯可以看到,有時請求已經獲取到結果了,但是卻沒有打印出對應的結果;有時請求沒有獲取到對應的結果,但是查看對應的網絡的狀態卻是成功的。所以這裏面有很多的不確定性,跟我們的感覺是比較模糊的。 等到 fetch 出來的時候,大家就在討論關於如何正確,清楚地取消一個 fetch 請求。最早的討論可以看這裏 Aborting a fetch #27 ,那已經是 7 年前(2015 年)的事情了,可以看到當時的討論還是比較激烈的。大家感興趣的話可以看看當時大家都主要關注的是哪些特性。

最終,新的規範 出來了,通過 AbortControllerAbortSignal 我們可以方便,快捷,清楚地終止一個 fetch 請求。要注意的是,這個規範是一個 DOM 層面的規範,不是 JavaScript 語言層面的規範。現在絕大多數的瀏覽器環境和新版本的 Node.js 環境也都支持這個特性了。關於 AbortController 的兼容性,大家可以參考這裏 AbortController#browser_compatibility

下面文章中的代碼例子基本上都可以直接複製粘貼到控制檯運行的,所以感興趣的同學閱讀到對應的部分可以直接打開瀏覽器的控制檯去運行一下,然後看看對應的結果。加深一下自己對相關知識點的記憶。

終止正在進行中的單個請求

我們先通過一段代碼來給大家展示一下如何實現這個功能

const ac = new AbortController();
const { signal } = ac;

const resourceUrl = 'https://jsonplaceholder.typicode.com/todos/1';
fetch(resourceUrl, { signal })
    .then(response => response.json())
    .then(json => console.log(json))
    .catch(err ={
        // 不同瀏覽器的返回結果不同
        console.log(err);
    });

// 可以立即終止請求,或者設置一個定時器
// ac.abort();
setTimeout(() ={
    ac.abort();
}, 10);

大家感興趣的話也可以把上面的代碼複製粘貼到瀏覽器的控制檯運行一下,上面代碼的運行結果如下所示:

可以看到控制檯的 Console 的輸出是:DOMException: The user aborted a request. 對應的 Network 展示的是一個取消狀態的請求。這說明我們剛纔發送的請求被終止取消掉了。能夠在一些特定的情況下主動地取消相關的請求對我們應用來說是很重要的,這能夠減少我們用戶的流量使用以及我們應用的內存使用。

AbortController 的深入剖析

接下來我們來講解一下上面的代碼,第一行通過 AbortController 創建了一個 AbortController 類型的實例 ac,這個實例上有一個 abort 方法和一個 AbortSignal 類型的 signal 實例。然後我們通過 fetch 方法去請求一個資源路徑,傳遞給 fetch 的選項把 acsignal 對象傳遞進去。fetch 方法如果獲取到了資源就會把資源打印到控制檯,如果網絡發生了問題,就會捕獲異常,然後把異常打印到控制檯。最後,通過一個 setTimeout 延時,調用 acabort 方法終止 fetch 請求 。

fetchoptions 選項允許我們傳遞一個 signal 對象;fetch 的內部會監測這個對象的狀態,如果這個對象的狀態從未終止的狀態變爲終止的狀態的話,並且 fetch 請求還在進行中的話,fetch 請求就會立即失敗。其對應的 Promise 的狀態就會變爲 Rejected

如何改變 signal 的狀態呢?我們可以通過調用 acabort 方法去改變 signal 的狀態。一旦我們調用了 ac.abort() 那麼與之關聯的 signal 的狀態會立刻從起始狀態(非終止狀態)轉變爲終止狀態。

我們上面只是簡單地使用了 signal 對象,這個對象是 AbortSignal 類的實例,對於 AbortSignal 我們下面會做深入的講解,這裏暫時只需要知道 signal 可以作爲一個信號對象傳遞給 fetch 方法,可以用來終止 fetch 的繼續進行。 另外,在不同的瀏覽器中打印的結果可能略有不同,這個跟不同瀏覽器的內部實現有關係。比如在 Firefox 中的結果如下:

Safari 中的結果如下:

當然如果我們沒有終止 fetch 請求的話,控制檯的打印將會是:

另外大家如果需要一些模擬的數據接口的話可以試試 JSONPlaceholder ,還是很方便使用的。

批量取消多個 fetch 請求

值得注意的是,我們的 signal 對象可以同時傳遞給多個請求,在需要的情況下可以同時取消多個請求;我們來看看如何進行這樣的操作。代碼如下所示:

const ac = new AbortController();
const { signal } = ac;

const resourcePrefix = 'https://jsonplaceholder.typicode.com/todos/';
function todoRequest (id, { signal } = {}) {
    return fetch(`${resourcePrefix}${id}`{ signal })
        .then(response => response.json())
        .then(json => console.log(json))
        .catch(e => console.log(e));
}

todoRequest(1, { signal });
todoRequest(2, { signal });
todoRequest(3, { signal });

// 同時終止多個請求
ac.abort();

運行代碼後可以在控制檯看到如下結果:

如果我們需要同時對多個請求進行終止操作的的話,使用上面這種方式非常簡單方便。

如果我們想自定義終止請求的原因的話,可以直接在 abort 方法裏傳遞我們想要的原因,這個參數可以是任何 JavaScript 類型的值。傳遞的終止的原因會被 signal 接收到,然後放在它的 reason 屬性中。這個我們下面會講到。

AbortController 的相關屬性和方法

詳細介紹 AbortSignal

AbortSignal 的屬性和方法

AbortSignal 接口繼承自 EventTarget ,所以 EventTarget 對應的屬性和方法,AbortSignal 都繼承下來了。當然還有一些自己特有的方法和屬性,我們下面會一一講解到的。需要注意的是,AbortSignal 部分屬性有兼容性問題,具體的兼容性大家可以參考這裏 AbortSignal#browser_compatibility 。

靜態方法 abort 和 timeout

這兩個方法是 AbortSignal 類上的靜態方法,用來創造 AbortSignal 實例。其中 abort 用來創造一個已經被終止的信號對象。我們來看下面的例子:

// ... 省略 todoRequest 函數的定義
// Safari 暫時不支持, Firefox 和 Chrome 支持
// abort 可以傳遞終止的原因
const abortedAS = AbortSignal.abort();
// 再發送之前信號終止,請求不會被髮送
todoRequest(1, { signal: abortedAS });
console.warn(abortedAS);

運行代碼,控制檯的輸出結果如下:

對應的請求甚至都沒有發送出去

我們也可以給 abort 方法傳遞終止的原因,比如是一個對象:

// ...
const abortedAS = AbortSignal.abort({
    type: 'USER_ABORT_ACTION',
    msg: '用戶終止了操作'
});
// ...

那麼輸出的結果就如下圖所示:

signalreason 屬性就變成了我們自定義的值。

同樣的,大家看到 timeout 應該很容易想到是創造一個多少毫秒後會被終止的 signal 對象。代碼如下:

// ... 省略部分代碼
const timeoutAS = AbortSignal.timeout(10);
todoRequest(1, { signal: timeoutAS }).then(() ={
    console.warn(timeoutAS);
});
console.log(timeoutAS);

代碼的運行結果如下:

可以看到我們打印了兩次 timeoutAS,第一次是立即打印的,第二次是等到請求被終止後打印的。可以看到第一打印的時候,timeoutAS 的狀態還是沒有被終止的狀態。當請求被終止後,第二次打印的結果表明 timeoutAS 這個時候已經被終止了,並且 reason 屬性的值表明了這次請求被終止是因爲超時的原因。

屬性 aborted 和 reason

AbortSignal 實例有兩個屬性;一個是 aborted 表示當前信號對象的狀態是否是終止的狀態,false 是起始狀態,表示信號沒有被終止,true 表示信號對象已經被終止了。

reason 屬性可以是任何的 JavaScript 類型的值,如果我們在調用 abort 方法的時候沒有傳遞終止信號的原因,那麼就會使用默認的原因。默認的原因有兩種,一種是通過 abort 方法終止信號對象,並且沒有傳遞終止的原因,那麼這個時候 reason 的默認值就是: DOMException: signal is aborted without reason;如果是通過 timeout 方法終止信號對象,那麼這個時候的默認原因就是:DOMException: signal timed out。如果我們主動傳遞了終止的原因,那麼對應的 reason 的值就是我們傳遞進去的值。

實例方法 throwIfAborted

這個方法通過名稱大家也能猜出來是什麼作用,那就是當調用 throwIfAborted 的時候,如果這個時候 signal 對象的狀態是終止的,那麼就會拋出一個異常,異常的值就是對應 signalreason 值。可以看下面的代碼例子:

const signal = AbortSignal.abort();
signal.throwIfAborted();

// try {
//   signal.throwIfAborted();
// } catch (e) {
//   console.log(e);
// }

運行後在控制檯的輸出如下:

可以看到直接拋出異常,這個時候我們可以通過 try ... catch ... 進行捕獲,然後再進行對應的邏輯處理。這個方法也是很有幫助的,我們在後面會講到。當我們實現一個自定義的可以主動取消的 Promise 的時候這個方法就很有用。

事件監聽 abort

對於 signal 對象來說,它還可以監聽 abort 事件,然後我們就可以在 signal 被終止的時候做一些額外的操作。下面是事件監聽的簡單例子:

const ac = new AbortController();
const { signal } = ac;

// 添加事件監聽
signal.addEventListener('abort'function (e) {
    console.log('signal is aborted');
    console.warn(e);
});

setTimeout(() ={
    ac.abort();
}, 100);

運行後在控制檯的輸出如下:

可以看到在 signal 被終止的時候,我們之前添加的事件監聽函數就開始運行了。其中 e 表示的是接收到的事件對象,然後這個事件對象上的 targetcurrentTarget 表示的就是對應的 signal 對象。

實現一個可以主動取消的 Promise

當我們對 AbortController 以及 AbortSignal 比較熟悉的時候,我們就可以很方便的構造出我們自定義的可以取消的 Promise 了。下面就是一個比較簡單的版本,大家可以看一下:

/**
 * 自定義的可以主動取消的 Promise
 */

function myCoolPromise ({ signal }) {
    return new Promise((resolve, reject) ={
        // 如果剛開始 signal 存在並且是終止的狀態可以直接拋出異常
        signal?.throwIfAborted();

        // 異步的操作,這裏使用 setTimeout 模擬
        setTimeout(() ={
            Math.random() > 0.5 ? resolve('ok') : reject(new Error('not good'));
        }, 1000);

        // 添加 abort 事件監聽,一旦 signal 狀態改變就將 Promise 的狀態改變爲 rejected
        signal?.addEventListener('abort'() => reject(signal?.reason));
    });
}

/**
 * 使用自定義可取消的 Promise
 */

const ac = new AbortController();
const { signal } = ac;

myCoolPromise({ signal }).then((res) => console.log(res)err => console.warn(err));
setTimeout(() ={
    ac.abort();
}, 100); // 可以更改時間看不同的結果

這次的代碼稍微多了一點,不過相信大家還是很容易就知道上面的代碼要表示的是什麼意思。

首先我們自定義了 myCoolPromise 這個函數,然後函數接收一個非必傳的 signal 對象;然後立即返回一個新構建的 Promise,這個 Promise 的內部我們添加了一些額外的處理。首先我們判斷了 signal 是否存在,如果存在就調用它的 throwIfAborted 方法。因爲有可能這個時候 signal 的狀態已經是終止的狀態了,需要立即將 Promise 的狀態變更爲 rejected 狀態。

如果此時 signal 的狀態還沒有改變,那麼我們可以給這個 signal 添加一個事件監聽,一旦 signal 的狀態改變,我們就需要立即去改變 Promise 的狀態。

當我們下面的 setTimeout 的時間設置爲 100 毫秒的時候,上面的 Promise 總是拒絕的狀態,所以會看到控制檯的打印結果如下:

如果我們把這個時間修改爲 2000 毫秒的話,那麼控制檯輸出的結果可能是 ok 也可能是一個 not good 的異常捕獲。

有同學看到這裏可能會說,好像不需要 signal 也可以實現主動取消的 Promise,我可以使用一個普通的 EventTarget 結合 CustomEvent 也可以實現類似的效果。當然我們也可以這樣做,但是一般情況下我們的異步操作是包含網絡請求的,如果網絡請求使用的是 fetch 方法的話,那麼就必須使用 AbortSignal 類型的實例 signal 進行信號的傳遞;因爲 fetch 方法內部會根據 signal 的狀態來判斷到底需不需要終止正在進行的請求。

AbortSignal 的相關屬性和方法:

開發中其他場景的使用舉例

取消事件監聽的一種便捷方法

一般情況下,如果我們對文檔中的某個 DOM 元素添加了事件監聽,那麼當這個元素被銷燬或者移除的時候,也需要相應的把對應的事件監聽函數移除掉,不然很容易出現內存泄漏的問題。所以一般情況下我們會按照下面的方式添加並且移除相關的事件監聽函數。

<button class="event">事件監聽按鈕</button>
<button class="cancel">點擊後取消事件監聽</button>
const evtBtn = document.querySelector('.event');
const cancelBtn = document.querySelector('.cancel');

const evtHandler = (e) ={
    console.log(e);
};
evtBtn.addEventListener('click', evtHandler);
// 點擊 cancelBtn 移除 evtBtn 按鈕的 click 事件監聽
cancelBtn.addEventListener('click'function () {
    evtBtn.removeEventListener('click', evtHandler);
});

這種方式是最通用的方式,但是這種方式需要我們保留對應事件監聽函數的引用,比如上面的 evtHandler。一旦我們丟失了這個引用,那麼後面就沒有辦法取消這個事件監聽了。

另外,有些應用場景需要你給某個元素添加很多事件處理函數,取消的時候就需要一個一個去取消,很不方便。這個時候我們的 AbortSignal 就可以派上用場了,我們可以使用 AbortSignal 來同時取消很多事件的事件監聽函數。就像我們同時取消很多個 fetch 請求一樣。代碼如下:

// ... HTML 部分參考上面的內容

const evtBtn = document.querySelector('.event');
const cancelBtn = document.querySelector('.cancel');

const evtHandler = (e) => console.log(e);
const mdHandler = (e) => console.log(e);
const muHandler = (e) => console.log(e);

const ac = new AbortController();
const { signal } = ac;

evtBtn.addEventListener('click', evtHandler, { signal });
evtBtn.addEventListener('mousedown', mdHandler, { signal });
evtBtn.addEventListener('mouseup', muHandler, { signal });

// 點擊 cancelBtn 移除 evtBtn 按鈕的 click 事件監聽
cancelBtn.addEventListener('click'function () {
    ac.abort();
});

這樣的處理方式是不是就很方便,也非常的清楚明瞭。

addEventListener(type, listener, options);

addEventListener 的第三個參數可以是一個 options 對象,這個對象可以讓我們傳遞一個 signal 對象用來作爲事件取消的信號對象。就像上面我們使用 signal 對象來取消 fetch 請求那樣。

從上面的兼容性來說,這個屬性的兼容性還是可以的;目前只有 Opera AndroidNode.js 暫時還不支持,如果想要使用這個新的屬性,需要針對這兩個平臺和運行環境做一下兼容處理就好了。

一種值得借鑑的處理複雜業務邏輯的方法

我們有時開發中會遇到一些比較複雜的處理操作,比如你要先通過好幾個接口獲取數據,然後組裝數據;然後再把這些數據異步地繪製渲染到頁面上。如果用戶主動取消了這個操作或者因爲超時了,我們要主動取消這些操作。對於這種場景,使用 AbortController 配合 AbortSignal 也有不錯的效果,下面舉一個簡單的例子:

// 多個串行或者並行的網絡請求
const requestUserData = (signal) ={
    // TODO
};
// 異步的繪製渲染操作 裏面包含了 Promise 的處理
const drawAndRenderImg = (signal) ={
    // TODO
};
// 獲取服務端數據並且進行數據的繪製和渲染
function fetchServerDataAndDrawImg ({ signal }) {
    signal?.throwIfAborted();
    // 多個網絡請求
    requestUserData(signal);
    // 組裝數據,開始繪製和渲染
    drawAndRenderImg(signal);
    // ... 一些其他的操作
}

const ac = new AbortController();
const { signal } = ac;

try {
    fetchServerDataAndDrawImg({ signal });
} catch (e) {
    console.warn(e);
}

// 用戶主動取消或者超時取消
setTimeout(() ={
    ac.abort();
}, 2000);

上面是一個簡化的例子,用來表示這種複雜的操作;我們可以看到,如果用戶主動取消或者因爲超時取消操作;我們上面的代碼邏輯可以很方便的處理這種情況。也不會因爲少處理了一些操作而導致可能發生的內存泄漏。

一旦我們想重新開始這個操作,我們只需要再次調用 fetchServerDataAndDrawImg 並且傳遞一個新的 signal 對象就可以了。這樣處理後,重新開始和取消的邏輯就非常清楚了。如果大家在自己的項目中有類似的這種操作,不妨可以試試這種處理方法。

在 Node.js 中的使用

我們不僅可以在瀏覽器環境中使用 AbortControllerAbortSignal,還可以在 Node.js 環境中使用這兩個功能。對於 Node.js 中的 fs.readFilefs.writeFilehttp.requesthttps.requesttimers 以及新版本支持的 Fetch API 都可以使用 signal 來進行操作的取消。下面我們來舉一個簡單的例子,關於讀取文件的操作:

const fs = require('fs');

const ac = new AbortController();
const { signal } = ac;

fs.readFile('data.json'{ signal, encoding: 'utf8' }(err, data) ={
    if (err) {
        console.error(err);
        return;
    }
    console.log(data);
});

ac.abort();

運行代碼可以看到終端的輸出如下:

經常使用 Node.js 進行業務開發的同學可以嘗試使用這個新的特性,應該對開發會很有幫助的。

反饋和建議

這篇文章到這裏就算結束啦,不知道有多少同學堅持讀完了這篇文章;希望讀完的同學都能夠掌握好這篇文章中講解的知識。如果這篇文章幫到了你,或者打開了你的新世界;歡迎點贊轉發。

如果你對這篇文章有什麼建議和意見,歡迎大家在文章下面留言評論,我們一起討論一下,一起進步呀。

往期精彩推薦

參考的相關網址

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/liTMB0sTcl6jkEjUPzCUjA