Abbrechbarer Abruf

Archibald
Jake Archibald

Das ursprüngliche GitHub-Problem zu „Abbruch eines Abrufs“ wurde 2015 veröffentlicht. Wenn ich jetzt 2015 von 2017 (das aktuelle Jahr) wegbeziehe, bekomme ich 2. Dies deutet auf einen Fehler in der Mathematik hin, da 2015 in Wirklichkeit „für immer“ vergangen ist.

2015 begannen wir damit, den Abbruch laufender Abrufe zu untersuchen. Nach 780 GitHub-Kommentaren, ein paar Fehlstarts und 5 Pull-Anfragen haben wir endlich ein abgebrochenes Abruf-Landing in Browsern erschlossen. Das erste war Firefox 57.

Update:Nein, ich lag falsch. Edge 16 landete zuerst mit Unterstützung für Abtreibungen! Herzlichen Glückwunsch an das Edge-Team!

Ich komme später auf den Verlauf zu, aber zuerst geht es um die API:

Steuer- und Signalbewegungen

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. Da er allgemein gehalten ist, kann er von anderen Webstandards und JavaScript-Bibliotheken verwendet werden.

Signale abbrechen und abrufen

Abruf kann ein AbortSignal aufnehmen. So würden Sie zum Beispiel ein Abrufzeitlimit nach 5 Sekunden festlegen:

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, sodass auch das Lesen des Antworttexts (z. B. response.text()) abgebrochen wird.

Demo – Aktuell wird diese Funktion nur von Firefox 57 unterstützt. An der Demo war niemand mit Designkenntnissen beteiligt.

Alternativ kann das Signal an ein Anfrageobjekt übergeben und später zum Abrufen ü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.

Reaktion auf einen abgebrochenen Abruf

Wenn Sie einen asynchronen Vorgang abbrechen, lehnt das Versprechen mit einem DOMException namens AbortError ab:

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

Es ist nicht sinnvoll, eine Fehlermeldung anzuzeigen, wenn der Nutzer den Vorgang abgebrochen hat, da es kein "Fehler" ist, wenn Sie erfolgreich tun, was der Nutzer verlangt hat. Um dies zu vermeiden, verwenden Sie eine if-Anweisung wie die obige speziell für Abbruchfehler.

Im folgenden Beispiel sehen Nutzer eine Schaltfläche zum Laden von Inhalten und eine Schaltfläche zum Abbrechen. Wenn die Abruffehler 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 – Zum Zeitpunkt der Entstehung dieses Artikels unterstützen Edge 16 und Firefox 57 nur die Browser, die diese Funktion unterstützen.

Ein Signal, viele Abrufe

Ein einzelnes Signal kann verwendet werden, um viele Abrufe gleichzeitig abzubrechen:

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. fetchStory:

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

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

In diesem Fall werden durch das Aufrufen von controller.abort() alle laufenden Abrufe abgebrochen.

Die Zukunft

Andere Browser

Edge hat dies als Erstes versendet, und Firefox ist gerade angesagt. Die Entwickler haben diese während der Erstellung der Spezifikation aus der Testsuite implementiert. Für andere Browser gelten die folgenden Tickets:

In einem Service Worker

Ich muss noch die Spezifikation für die Service Worker-Teile abschließen, aber hier ist der Plan:

Wie bereits erwähnt, hat jedes Request-Objekt eine signal-Eigenschaft. Innerhalb eines Service Workers signalisiert fetchEvent.request.signal einen Abbruch, 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, wird das Signal fetchEvent.request.signal abgebrochen, sodass der Abruf innerhalb des Service Workers ebenfalls abgebrochen wird.

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

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 dieser Spezifikation, um dies zu verfolgen. Ich füge Links zu Browser-Tickets hinzu, sobald sie für die Implementierung bereit sind.

Verlauf

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

Meinungsverschiedenheiten zu APIs

Wie Sie sehen, ist die GitHub-Diskussion ziemlich lang. In diesem Thread gibt es viele Nuancen (und einige mangelnde Nuancen), aber der wichtigste Unterschied ist, dass eine Gruppe wollte, dass die abort-Methode für das von fetch() zurückgegebene Objekt vorhanden ist, während die andere eine Trennung zwischen dem Abrufen der Antwort und der Beeinflussung der Antwort wünschte.

Diese Anforderungen sind nicht kompatibel, sodass eine Gruppe nicht das erhielt, was sie wollte. Wenn Sie das sind, tut mir leid! Wenn du dich dadurch besser fühlst, war ich auch in dieser Gruppe. Da AbortSignal jedoch die Anforderungen anderer APIs erfüllt, scheint es die richtige Wahl zu sein. Außerdem wäre es sehr kompliziert, wenn nicht gar unmöglich, verkettete Versprechen zuzulassen, dass sie abgebrochen werden können.

Wenn Sie ein Objekt zurückgeben möchten, das eine Antwort bereitstellt, 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 })
    };
}

Falsche Starts in TC39

Es wurde versucht, eine abgebrochene Aktion von einem Fehler zu unterscheiden. Dies beinhaltete einen dritten Promise-Status, um "cancelled" zu kennzeichnen, sowie eine neue Syntax, um den Abbruch sowohl im synchronen als auch im asynchronen Code zu verarbeiten:

Don'ts

Kein echter 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
    }

Die häufigste Vorgehensweise, wenn eine Aktion abgebrochen wird, ist nichts. Im obigen Vorschlag wurden Stornierungen von Fehlern getrennt, sodass Sie Abbruchfehler nicht speziell behandeln mussten. Mit catch cancel erhalten Sie Informationen zu abgebrochenen Aktionen. In den meisten Fällen ist das aber nicht erforderlich.

Dies erreichte Phase 1 in TC39, aber es wurde kein Konsens erreicht, sodass der Vorschlag zurückgezogen wurde.

Für unseren alternativen Vorschlag AbortController war keine neue Syntax erforderlich, sodass eine Spezifikation innerhalb von TC39 nicht sinnvoll war. Alles, was wir von JavaScript benötigten, war bereits vorhanden. Deshalb haben wir die Schnittstellen innerhalb der Webplattform definiert, insbesondere den DOM-Standard. Nachdem wir diese Entscheidung getroffen hatten, kam der Rest relativ schnell zusammen.

Große Änderung der Spezifikationen

XMLHttpRequest kann schon seit Jahren abgebrochen werden, aber die Spezifikationen waren ziemlich vage. Es war nicht klar, an welchen Punkten die zugrunde liegende Netzwerkaktivität vermieden oder beendet werden konnte oder was passierte, wenn es eine Race-Bedingung zwischen dem Aufruf von abort() und dem Abschluss des Abrufs gab.

Wir wollten es dieses Mal richtig machen, aber das führte zu einer umfangreichen Spezifikationsänderung, die eine Menge Überprüfung erforderte (das ist meine Schuld und vielen Dank an Anne van Kesteren und Domenic Denicola, die mich durch die Prüfung gezogen haben) und eine anständige Reihe von Tests.

Aber wir sind jetzt da! Es gibt eine neue Web-Primitive zum Abbrechen asynchroner Aktionen. Außerdem können mehrere Abrufe gleichzeitig gesteuert werden. Später werden wir Prioritätsänderungen während der gesamten Lebensdauer eines Abrufs aktivieren und eine übergeordnete API verwenden, um den Abruffortschritt zu beobachten.