Afbreekbare ophaalactie

De oorspronkelijke GitHub-uitgave voor "Aborting a fetch" werd geopend in 2015. Als ik nu 2015 weghaal van 2017 (het huidige jaar), krijg ik er 2. Dit demonstreert een bug in de wiskunde, omdat 2015 in feite "voor altijd" geleden was .

In 2015 zijn we voor het eerst begonnen met het onderzoeken van het afbreken van lopende ophaalacties, en na 780 GitHub-opmerkingen, een paar valse starts en 5 pull-verzoeken, hebben we eindelijk een afbreekbare ophaalactie in browsers, waarvan Firefox 57 de eerste is.

Update: Neeeeee, ik had het mis. Edge 16 landde als eerste met ondersteuning voor afbreken! Proficiat aan het Edge-team!

Ik zal later in de geschiedenis duiken, maar eerst de API:

De controller + signaalmanoeuvre

Maak kennis met de AbortController en AbortSignal :

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

De controller heeft maar één methode:

controller.abort();

Wanneer u dit doet, wordt het signaal weergegeven:

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

Deze API wordt geleverd door de DOM-standaard , en dat is de hele API. Het is opzettelijk generiek, zodat het door andere webstandaarden en JavaScript-bibliotheken kan worden gebruikt.

Signalen afbreken en ophalen

Fetch kan een AbortSignal aannemen. Zo kunt u bijvoorbeeld na vijf seconden een time-out voor het ophalen maken:

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

Wanneer u een ophaalbewerking afbreekt, worden zowel het verzoek als het antwoord afgebroken, zodat het lezen van de antwoordtekst (zoals response.text() ) ook wordt afgebroken.

Hier is een demo . Op het moment van schrijven was Firefox 57 de enige browser die dit ondersteunt. Houd je ook vast, niemand met enige ontwerpvaardigheid was betrokken bij het maken van de demo.

Als alternatief kan het signaal aan een verzoekobject worden gegeven en later worden doorgegeven om op te halen:

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

fetch(request);

Dit werkt omdat request.signal een AbortSignal is.

Reageren op een afgebroken ophaalactie

Wanneer u een asynchrone bewerking afbreekt, wordt de belofte afgewezen met een DOMException met de naam AbortError :

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

U wilt niet vaak een foutmelding weergeven als de gebruiker de bewerking heeft afgebroken, omdat het geen "fout" is als u met succes doet wat de gebruiker heeft gevraagd. Om dit te voorkomen, gebruikt u een if-statement zoals hierboven om abortusfouten specifiek af te handelen.

Hier is een voorbeeld waarbij de gebruiker een knop heeft om inhoud te laden, en een knop om af te breken. Als de ophaalfouten optreden, wordt er een fout weergegeven, tenzij het een afbreekfout is:

// 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 is een demo – Op het moment van schrijven zijn Edge 16 en Firefox 57 de enige browsers die dit ondersteunen.

Eén signaal, veel ophaalacties

Eén enkel signaal kan worden gebruikt om meerdere ophaalacties tegelijk af te breken:

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

In het bovenstaande voorbeeld wordt hetzelfde signaal gebruikt voor het aanvankelijk ophalen en voor het parallel ophalen van hoofdstukken. Hier ziet u hoe u fetchStory gebruikt:

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

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

In dit geval zal het aanroepen van controller.abort() de ophaalbewerkingen die bezig zijn afbreken.

De toekomst

Andere browsers

Edge heeft geweldig werk geleverd door dit als eerste te verzenden, en Firefox is hen op de hielen. Hun ingenieurs implementeerden vanuit de testsuite terwijl de specificaties werden geschreven. Voor andere browsers volgen hier de tickets:

Bij een servicemedewerker

Ik moet de specificatie voor de onderdelen van de servicemedewerker afmaken, maar dit is het plan:

Zoals ik eerder al zei, heeft elk Request object een signal . Binnen een servicemedewerker geeft fetchEvent.request.signal het signaal afbreken als de pagina niet langer geïnteresseerd is in het antwoord. Als gevolg hiervan werkt code als deze gewoon:

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

Als de pagina het ophalen afbreekt, geeft fetchEvent.request.signal het signaal afbreken, zodat het ophalen binnen de servicemedewerker ook wordt afgebroken.

Als u iets anders ophaalt dan event.request , moet u het signaal doorgeven aan uw aangepaste ophaal(sen).

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

Volg de specificaties om dit bij te houden. Ik zal links naar browsertickets toevoegen zodra deze gereed zijn voor implementatie.

De geschiedenis

Ja... het heeft lang geduurd voordat deze relatief eenvoudige API tot stand kwam. Dit is waarom:

API-onenigheid

Zoals je kunt zien, is de GitHub-discussie behoorlijk lang . Er zit veel nuance in die thread (en een gebrek aan nuance), maar het belangrijkste meningsverschil is dat de ene groep wilde dat de abort methode bestond op het object dat werd geretourneerd door fetch() , terwijl de andere een scheiding wilde tussen het verkrijgen van het antwoord en de reactie beïnvloeden.

Deze vereisten zijn onverenigbaar, dus één groep zou niet krijgen wat ze wilden. Als jij dat bent, sorry! Als je je daardoor beter voelt, ik zat ook in die groep. Maar aangezien AbortSignal voldoet aan de vereisten van andere API's, lijkt het de juiste keuze. Bovendien zou het zeer ingewikkeld, zo niet onmogelijk, worden om aan elkaar geketende beloften af ​​te schaffen.

Als u een object wilt retourneren dat een antwoord geeft, maar ook kan afbreken, kunt u een eenvoudige wrapper maken:

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

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

Valse starts in TC39

Er is geprobeerd om een ​​geannuleerde actie te onderscheiden van een fout. Dit omvatte een derde beloftestatus om "geannuleerd" aan te duiden, en een nieuwe syntaxis om annulering in zowel synchronisatie- als asynchrone code af te handelen:

Niet doen

Geen echte code - voorstel is ingetrokken

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

Het meest voorkomende wat u kunt doen als een actie wordt geannuleerd, is niets. Het bovenstaande voorstel scheidde annulering van fouten, zodat u niet specifiek met afbreekfouten hoefde om te gaan. catch cancel laat je horen over geannuleerde acties, maar meestal is dat niet nodig.

Dit bereikte stadium 1 in TC39, maar er werd geen consensus bereikt en het voorstel werd ingetrokken .

Ons alternatieve voorstel, AbortController , vereiste geen nieuwe syntaxis, dus het had geen zin om het binnen TC39 te specificeren. Alles wat we nodig hadden van JavaScript was er al, dus hebben we de interfaces binnen het webplatform gedefinieerd, met name de DOM-standaard . Toen we die beslissing eenmaal hadden genomen, kwam de rest relatief snel bij elkaar.

Grote specificatiewijziging

XMLHttpRequest kan al jaren worden afgebroken, maar de specificaties waren behoorlijk vaag. Het was niet duidelijk op welke punten de onderliggende netwerkactiviteit kon worden vermeden of beëindigd, of wat er zou gebeuren als er een race condition was tussen het aanroepen van abort() en het voltooien van de ophaalactie.

We wilden het deze keer goed doen, maar dat resulteerde in een grote specificatiewijziging die veel herziening vergde (dat is mijn schuld, en hartelijk dank aan Anne van Kesteren en Domenic Denicola voor het feit dat ze me er doorheen hebben gesleept) en een behoorlijke set testen .

Maar we zijn er nu! We hebben een nieuwe webprimitief voor het afbreken van asynchrone acties, en meerdere ophaalacties kunnen tegelijk worden beheerd! Verderop zullen we kijken naar het inschakelen van prioriteitswijzigingen gedurende de levensduur van een ophaalactie, en naar een API op een hoger niveau om de voortgang van het ophalen te observeren .