無法擷取

Jake Archibald
Jake Archibald

「Aborting a fetch」 的原始 GitHub 問題是在 2015 年提出。現在,如果我將 2017 年 (目前年份) 減去 2015 年,得到的結果是 2。這顯示了數學中的錯誤,因為 2015 年實際上是「永遠」之前。

我們在 2015 年首次開始研究如何中止目前的擷取作業,在收到 780 則 GitHub 留言、幾次誤判和 5 個提取要求後,我們終於在瀏覽器中實現可中止的擷取功能,第一個支援的瀏覽器是 Firefox 57。

更新:我錯了。Edge 16 推出了中止支援功能!恭喜 Edge 團隊!

我稍後會深入探討歷史記錄,但首先,請看一下 API:

控制器 + 信號機動

歡迎認識 AbortControllerAbortSignal

const controller = new AbortController();
const signal = controller.signal;

控制器只有一個方法:

controller.abort();

執行這項操作時,系統會通知信號:

signal.addEventListener('abort', () => {
    // Logs true:
    console.log(signal.aborted);
});

這個 API 是由 DOM 標準提供,也是整個 API。我們刻意讓這項功能保持通用,以便其他網路標準和 JavaScript 程式庫使用。

中止信號和擷取

Fetch 可以使用 AbortSignal。舉例來說,以下是如何在 5 秒後讓擷取作業逾時:

const controller = new AbortController();
const signal = controller.signal;

setTimeout(() => controller.abort(), 5000);

fetch(url, { signal }).then(response => {
    return response.text();
}).then(text => {
    console.log(text);
});

當您中斷擷取作業時,系統會同時中斷要求和回應,因此任何回應內文的讀取作業 (例如 response.text()) 也會中斷。

以下是示範:在撰寫本文時,只有 Firefox 57 支援此功能。另外,請做好心理準備,沒有任何設計技能的人參與了示範影片的製作。

或者,您也可以將信號提供給要求物件,稍後再傳遞至擷取:

const controller = new AbortController();
const signal = controller.signal;
const request = new Request(url, { signal });

fetch(request);

這是因為 request.signalAbortSignal

回應中斷的擷取作業

當您中斷非同步作業時,承諾會拒絕使用名為 AbortErrorDOMException

fetch(url, { signal }).then(response => {
    return response.text();
}).then(text => {
    console.log(text);
}).catch(err => {
    if (err.name === 'AbortError') {
    console.log('Fetch aborted');
    } else {
    console.error('Uh oh, an error!', err);
    }
});

如果使用者中斷作業,您通常不想顯示錯誤訊息,因為如果您成功執行使用者要求的操作,這並非「錯誤」。為避免這種情況,請使用上述 if 陳述式,專門處理中斷錯誤。

以下範例會為使用者提供按鈕,讓他們載入內容,以及按鈕讓他們中止作業。如果擷取錯誤,系統會顯示錯誤,除非是中止錯誤:

// This will allow us to abort the fetch.
let controller;

// Abort if the user clicks:
abortBtn.addEventListener('click', () => {
    if (controller) controller.abort();
});

// Load the content:
loadBtn.addEventListener('click', async () => {
    controller = new AbortController();
    const signal = controller.signal;

    // Prevent another click until this fetch is done
    loadBtn.disabled = true;
    abortBtn.disabled = false;

    try {
    // Fetch the content & use the signal for aborting
    const response = await fetch(contentUrl, { signal });
    // Add the content to the page
    output.innerHTML = await response.text();
    }
    catch (err) {
    // Avoid showing an error message if the fetch was aborted
    if (err.name !== 'AbortError') {
        output.textContent = "Oh no! Fetching failed.";
    }
    }

    // These actions happen no matter how the fetch ends
    loadBtn.disabled = false;
    abortBtn.disabled = true;
});

以下是示範:在撰寫本文時,只有 Edge 16 和 Firefox 57 支援這項功能。

一個信號,多個擷取

單一信號可用於一次中止多個擷取作業:

async function fetchStory({ signal } = {}) {
    const storyResponse = await fetch('/story.json', { signal });
    const data = await storyResponse.json();

    const chapterFetches = data.chapterUrls.map(async url => {
    const response = await fetch(url, { signal });
    return response.text();
    });

    return Promise.all(chapterFetches);
}

在上述範例中,初始擷取和平行章節擷取都使用相同的信號。fetchStory 的使用方式如下:

const controller = new AbortController();
const signal = controller.signal;

fetchStory({ signal }).then(chapters => {
    console.log(chapters);
});

在這種情況下,呼叫 controller.abort() 會中斷任何進行中的擷取作業。

未來

其他瀏覽器

Edge 率先推出這項功能,Firefox 也緊追在後。他們的工程師在規格書撰寫期間,從測試套件實作。如果是其他瀏覽器,請參閱以下問題單:

在服務工作者中

我需要完成服務工作站部分的規格,但以下是計畫:

如前所述,每個 Request 物件都有 signal 屬性。如果網頁不再對回應感興趣,服務工作者會在 fetchEvent.request.signal 中發出中止訊號。因此,像這樣的程式碼就能正常運作:

addEventListener('fetch', event => {
    event.respondWith(fetch(event.request));
});

如果網頁中斷擷取作業,fetchEvent.request.signal 會傳送中斷信號,因此服務工作站中的擷取作業也會中斷。

如果您擷取的不是 event.request,就必須將信號傳遞至自訂擷取作業。

addEventListener('fetch', event => {
    const url = new URL(event.request.url);

    if (event.request.method == 'GET' && url.pathname == '/about/') {
    // Modify the URL
    url.searchParams.set('from-service-worker', 'true');
    // Fetch, but pass the signal through
    event.respondWith(
        fetch(url, { signal: event.request.signal })
    );
    }
});

請按照規格追蹤這項問題。一旦可實作,我會在瀏覽器的支援單中加入連結。

歷史

是的,這個相對簡單的 API 花了好長一段時間才完成。原因如下:

API 不一致

如您所見,GitHub 討論內容相當長。該主題討論中有很多細微差異 (以及一些缺乏細微差異的情況),但主要的爭議點在於,一組人希望 abort 方法存在於 fetch() 傳回的物件上,而另一組人則希望取得回應和影響回應之間保持分離。

這些規定不相容,因此其中一個群組無法取得所需內容。如果是這種情況,很抱歉!如果你覺得這樣比較好,我也是這個群組的成員。不過,由於 AbortSignal 符合其他 API 的要求,因此似乎是正確的選擇。此外,允許鏈結的承諾變成可中斷的情況會變得非常複雜,甚至無法實現。

如果您想傳回提供回應但也可中止的物件,可以建立簡單的包裝函式:

function abortableFetch(request, opts) {
    const controller = new AbortController();
    const signal = controller.signal;

    return {
    abort: () => controller.abort(),
    ready: fetch(request, { ...opts, signal })
    };
}

在 TC39 中啟動 False

我們曾試圖讓取消的動作與錯誤有所區別。這包括第三個承諾狀態,用來表示「已取消」,以及一些新的語法,可在同步和非同步程式碼中處理取消作業:

錯誤做法

不是實際程式碼 - 提案已撤銷

    try {
      // Start spinner, then:
      await someAction();
    }
    catch cancel (reason) {
      // Maybe do nothing?
    }
    catch (err) {
      // Show error message
    }
    finally {
      // Stop spinner
    }

在動作取消時,最常見的做法是什麼都不做。上述提案將取消與錯誤分開,因此您不需要特別處理中止錯誤。catch cancel 可讓您聽取已取消的動作,但大多數情況下您不需要這麼做。

這項提案在 TC39 階段達到第 1 階段,但未達成共識,因此提案遭到撤回

我們提出的替代方案 AbortController 不需要任何新的語法,因此在 TC39 中規範這項方案並不合理。我們需要的所有 JavaScript 元素都已存在,因此我們在網路平台中定義了介面,特別是 DOM 標準。做出這個決定後,其他事情就比較順利地解決了。

大量規格變更

XMLHttpRequest 多年以來一直可中斷,但規格相當模糊。我們不清楚在哪個時機可以避免或終止基礎網路活動,或是在 abort() 呼叫和擷取完成之間發生競爭條件時會發生什麼情況。

我們這次想確保一切正確無誤,但這導致需要大量審查的重大規格變更 (這是我的錯,在此要向 Anne van KesterenDomenic Denicola 致上萬分感謝,感謝你們幫我解決這個問題),以及一組不錯的測試

但我們現在就在這裡!我們推出了新的網頁原始碼,可用於中斷非同步動作,而且可以一次控制多個擷取作業!接下來,我們將探討如何在擷取期間啟用優先順序變更,以及更高層級的 API 來觀察擷取進度