中止可能な取得

Jake Archibald 氏
Jake Archibald

GitHub での「フェッチの中止」に関する最初の問題は、2015 年にオープンされました。さて、2017 年(現在の年)から 2015 年を除くと、2 になります。2015 年は実際には「永遠に」前であったため、これは数学のバグを示しています。

2015 年に、進行中のフェッチの中止を初めて検討し始めました。780 件の GitHub コメント、いくつかの誤スタート、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 ライブラリでも使用できるように、意図的に汎用的なものとなっています。

シグナルの中止とフェッチ

取得には 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 の要件に適していると考えると、適切な選択であるように思えます。また、チェーン Promise を中断可能にすることは、不可能ではないにしても非常に複雑になります。

レスポンスを提供するオブジェクトを返すが、中止もできるようにするには、単純なラッパーを作成します。

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