Abbrechbarer Abruf

Jake Archibald
Jake Archibald

Das ursprüngliche GitHub-Problem „Abbruch eines Abrufs“ wurde 2015 eröffnet. Wenn ich jetzt 2015 von 2017 (dem aktuellen Jahr) abziehe, erhalte ich 2. Das zeigt einen Fehler in der Mathematik, denn 2015 ist in der Tat schon „ewig“ her.

2015 haben wir damit begonnen, die Abbruchfunktion für laufende Abrufe zu untersuchen. Nach 780 GitHub-Kommentaren, einigen Fehlstarts und 5 Pull-Anfragen ist die Funktion jetzt endlich in Browsern verfügbar. Firefox 57 war der erste Browser, in dem sie implementiert wurde.

Update:Nein, ich lag falsch. Edge 16 unterstützt zuerst die Abbruchfunktion. Glückwunsch an das Edge-Team!

Ich werde später auf die Geschichte eingehen, aber zuerst zur API:

Der Controller und das Signalmanöver

AbortController und AbortSignal:

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

Der Controller hat nur eine Methode:

controller.abort();

Dadurch wird das Signal benachrichtigt:

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

Diese API wird vom DOM-Standard bereitgestellt und das ist die gesamte API. Es ist bewusst generisch, damit es von anderen Webstandards und JavaScript-Bibliotheken verwendet werden kann.

Abbruchsignale und Abruf

Für „Abrufen“ kann ein AbortSignal verwendet werden. So legen Sie beispielsweise ein Abrufzeitlimit von 5 Sekunden fest:

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);
});

Wenn Sie einen Abruf abbrechen, werden sowohl die Anfrage als auch die Antwort abgebrochen. Das Lesen des Antworttexts (z. B. response.text()) wird ebenfalls abgebrochen.

Hier ist eine Demo. Derzeit ist nur Firefox 57 dafür geeignet. Und seien Sie gewarnt: Niemand mit Designkenntnissen war an der Erstellung der Demo beteiligt.

Alternativ kann das Signal einem Anfrageobjekt übergeben und später an „fetch“ übergeben werden:

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

fetch(request);

Das funktioniert, weil request.signal ein AbortSignal ist.

Auf einen abgebrochenen Abruf reagieren

Wenn Sie einen asynchronen Vorgang abbrechen, wird das Versprechen mit einer DOMException namens AbortError abgelehnt:

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);
    }
});

Sie sollten nicht oft eine Fehlermeldung anzeigen, wenn der Nutzer den Vorgang abgebrochen hat, da es sich nicht um einen „Fehler“ handelt, wenn Sie die vom Nutzer angeforderte Aktion erfolgreich ausführen. Um dies zu vermeiden, verwenden Sie eine If-Bedingung wie die oben genannte, um Abbruchfehler speziell zu behandeln.

Hier ein Beispiel, in dem der Nutzer eine Schaltfläche zum Laden von Inhalten und eine Schaltfläche zum Abbrechen hat. Wenn beim Abrufen Fehler auftreten, wird ein Fehler angezeigt, es sei denn, es handelt sich um einen Abbruchfehler:

// 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;
});

Hier ist eine Demo. Derzeit unterstützen nur Edge 16 und Firefox 57 diese Funktion.

Ein Signal, viele Abrufe

Mit einem einzigen Signal können viele Abrufe gleichzeitig abgebrochen werden:

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);
}

Im obigen Beispiel wird dasselbe Signal für den ersten Abruf und für die parallelen Kapitelabrufe verwendet. So verwenden Sie fetchStory:

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

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

In diesem Fall werden durch den Aufruf von controller.abort() alle laufenden Abrufe abgebrochen.

Die Zukunft

Andere Browser

Edge hat gute Arbeit geleistet, diese Funktion zuerst zu veröffentlichen, und Firefox ist ihm dicht auf den Fersen. Die Entwickler haben die Testsuite während der Erstellung der Spezifikation implementiert. Für andere Browser findest du hier die entsprechenden Tickets:

In einem Service Worker

Ich muss die Spezifikation für die Service Worker-Teile fertigstellen, aber hier ist der Plan:

Wie bereits erwähnt, hat jedes Request-Objekt eine signal-Property. Innerhalb eines Service Workers wird mit fetchEvent.request.signal ein Abbruch signalisiert, wenn die Seite nicht mehr an der Antwort interessiert ist. Daher funktioniert Code wie dieser einfach:

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

Wenn die Seite den Abruf abbricht, signalisiert fetchEvent.request.signal den Abbruch, sodass auch der Abruf im Service Worker abgebrochen wird.

Wenn Sie etwas anderes als event.request abrufen, müssen Sie das Signal an Ihre benutzerdefinierten Abrufe weitergeben.

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 })
    );
    }
});

Folge der Spezifikation, um den Fortschritt zu verfolgen. Ich füge Links zu Browser-Tickets hinzu, sobald die Implementierung möglich ist.

Die Geschichte

Ja, es hat lange gedauert, bis diese relativ einfache API fertig war. Das hat mehrere Gründe:

Abweichungen bei der API

Wie Sie sehen, ist die GitHub-Diskussion ziemlich lang. Dieser Thread enthält viele Nuancen (und einige Unklarheiten), aber die Hauptabweichung besteht darin, dass eine Gruppe wollte, dass die abort-Methode für das von fetch() zurückgegebene Objekt vorhanden ist, während die andere Gruppe eine Trennung zwischen dem Abrufen der Antwort und dem Beeinflussen der Antwort wollte.

Diese Anforderungen sind nicht kompatibel, sodass eine Gruppe nicht das bekommen konnte, was sie wollte. Tut mir leid, wenn das auf dich zutrifft. Wenn es Ihnen hilft: Ich war auch in dieser Gruppe. Da AbortSignal jedoch den Anforderungen anderer APIs entspricht, scheint es die richtige Wahl zu sein. Außerdem wäre es sehr kompliziert, wenn verschachtelte Versprechen abgebrochen werden könnten, wenn nicht gar unmöglich.

Wenn Sie ein Objekt zurückgeben möchten, das eine Antwort liefert, aber auch abgebrochen werden kann, können Sie einen einfachen Wrapper erstellen:

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

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

Fehlstarts in TC39

Wir haben versucht, eine abgebrochene Aktion von einem Fehler zu unterscheiden. Dazu gehörten ein dritter Versprechensstatus, der „abgebrochen“ bedeutet, und eine neue Syntax, um die Abbruchbehandlung sowohl in synchronem als auch in asynchronem Code zu ermöglichen:

Don'ts

Kein gültiger Code – Angebot wurde zurückgezogen

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

In den meisten Fällen müssen Sie nichts weiter tun, wenn eine Aktion abgebrochen wird. Im obigen Vorschlag wurden Abbruch und Fehler getrennt, sodass Sie Abbruchfehler nicht speziell behandeln mussten. catch cancel informiert Sie über abgebrochene Aktionen, aber in den meisten Fällen ist das nicht erforderlich.

Dieser Vorschlag erreichte Phase 1 in TC39, aber es wurde kein Konsens erzielt und der Vorschlag wurde zurückgezogen.

Für unseren alternativen Vorschlag, AbortController, war keine neue Syntax erforderlich. Daher war es nicht sinnvoll, ihn in TC39 zu spezifizieren. Alles, was wir von JavaScript benötigten, war bereits vorhanden. Daher haben wir die Schnittstellen innerhalb der Webplattform definiert, insbesondere den DOM-Standard. Nachdem wir diese Entscheidung getroffen hatten, ging der Rest relativ schnell.

Große Spezifikationsänderung

XMLHttpRequest kann seit Jahren abgebrochen werden, aber die Spezifikation war ziemlich vage. Es war nicht klar, an welchen Stellen die zugrunde liegende Netzwerkaktivität vermieden oder beendet werden konnte oder was passiert, wenn es zu einer Race-Bedingung zwischen dem Aufruf von abort() und dem Abschluss des Abrufs kam.

Wir wollten es dieses Mal richtig machen, aber das führte zu einer großen Spezifikationsänderung, die viel Überarbeitung erforderte (das ist meine Schuld, und ich möchte mich ganz herzlich bei Anne van Kesteren und Domenic Denicola bedanken, die mich durch diese Phase begleitet haben) und eine ganze Reihe von Tests.

Aber wir sind jetzt da. Wir haben eine neue Web-Primitivfunktion zum Abbrechen von asynchronen Aktionen. Außerdem können mehrere Abrufe gleichzeitig gesteuert werden. Später werden wir uns damit befassen, wie Prioritätsänderungen während des gesamten Abrufs aktiviert werden können, und eine API der höheren Ebene, um den Abruffortschritt zu beobachten.