中止可能な取得

「フェッチの強制終了」に関する元の GitHub の問題は 2015 年に報告されました。2017 年(今年)から 2015 年を引くと 2 になります。これは数学のバグを示しています。2015 年は実際には「永遠」前です。

2015 年に、進行中の取得の中止を初めて検討し、GitHub での 780 件のコメント、数回の失敗、5 件の pull リクエストを経て、ついにブラウザで中止可能な取得が実現しました。最初は Firefox 57 です。

更新: 私の予想は外れました。中断サポートは、Edge 16 で初めて導入されました。Edge チーム、おめでとうございます。

歴史については後で説明しますが、まずは API について説明します。

コントローラとシグナルによる操作

AbortControllerAbortSignal について説明します。

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

コントローラには次の 1 つのメソッドのみがあります。

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 であるためです。

中断された取得への対応

非同期オペレーションを中止すると、Promise は AbortError という名前の DOMException で拒否されます。

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 のみです。

1 つのシグナルで複数のフェッチ

1 つのシグナルを使用して、複数の取得を一度に中止できます。

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 がそれに続きました。エンジニアは、仕様書の作成中にテストスイートから実装しました。他のブラウザについては、次のチケットをご覧ください。

Service Worker 内

Service Worker 部分の仕様を完成させる必要がありますが、計画は次のとおりです。

前述のとおり、すべての Request オブジェクトには signal プロパティがあります。Service Worker 内で、ページがレスポンスに興味を失った場合、fetchEvent.request.signal は中止を通知します。その結果、次のようなコードが機能します。

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

ページが取得を中止すると、fetchEvent.request.signal が中止を通知するため、Service Worker 内の取得も中止されます。

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 のディスカッションは非常に長くなっています。このスレッドには多くのニュアンス(およびニュアンスの欠如)がありますが、主な意見の相違は、一方のグループは fetch() によって返されたオブジェクトに abort メソッドが存在することを望み、もう一方のグループはレスポンスを取得することとレスポンスを変更することの分離を望んだことです。

これらの要件は互換性がないため、どちらかのグループが希望する結果を得ることはできません。該当する場合は、ご不便をおかけしますが、あらかじめご了承ください。私もそのグループにいました。ただし、AbortSignal が他の API の要件を満たしていることから、AbortSignal が適切な選択肢であると考えられます。また、チェーンされたプロミスを中断可能にすると、不可能ではないにしても非常に複雑になります。

レスポンスを提供し、中止も可能なオブジェクトを返す場合は、単純なラッパーを作成できます。

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

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

TC39 での誤った開始

キャンセルされたアクションをエラーと区別できるようにする取り組みが行われました。これには、「キャンセル済み」を示す 3 番目の Promise 状態と、同期コードと非同期コードの両方でキャンセルを処理するための新しい構文が含まれています。

すべきでないこと

実際のコードではない - 提案が取り下げられました

    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 について説明します。