Récupération abortable

Jake Archibld
Jake Archibald

Le problème d'origine sur GitHub lié à l'annulation d'une extraction a été ouvert en 2015. Maintenant, si je éloigne 2015 de 2017 (l'année en cours), j'obtiens 2. Cela illustre un bug en mathématiques, car 2015 était en fait il y a "indéfiniment".

C'est en 2015 que nous avons commencé à explorer l'abandon des récupérations en cours. Après 780 commentaires sur GitHub, quelques faux démarrages et cinq demandes d'extraction, nous proposons enfin une récupération abortive dans les navigateurs, le premier étant Firefox 57.

Information:Non, je me trompais. Edge 16 a d'abord été lancé avec la prise en charge de l'abandon. Félicitations à l'équipe Edge !

Je reviendrai plus tard sur l'historique, mais tout d'abord, l'API:

Manœuvre de contrôle et de signalisation

Découvrez AbortController et AbortSignal:

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

La manette ne possède qu'une seule méthode:

controller.abort();

Dans ce cas, il envoie une notification:

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

Cette API est fournie par la norme DOM. Il s'agit de l'API complète. Il est délibérément générique afin de pouvoir être utilisé par d'autres normes Web et bibliothèques JavaScript.

Annuler les signaux et récupérer

La récupération peut prendre un AbortSignal. Par exemple, voici comment définir un délai avant expiration pour la récupération au bout de cinq secondes:

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

Lorsque vous annulez une extraction, la requête et la réponse sont annulées. Par conséquent, toute lecture du corps de la réponse (par exemple, response.text()) est également annulée.

Voici une démonstration : au moment de la rédaction de ce document, le seul navigateur compatible est Firefox 57. De plus, accrochez-vous : personne n'a participé à la création de la démonstration, même sans aucune compétence en conception.

Le signal peut également être transmis à un objet de requête et transmis ultérieurement pour récupération:

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

fetch(request);

Cela fonctionne, car request.signal est un AbortSignal.

Réagir à une récupération annulée

Lorsque vous annulez une opération asynchrone, la promesse est rejetée avec un DOMException nommé 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);
    }
});

Il n'est pas recommandé d'afficher un message d'erreur si l'utilisateur a annulé l'opération, car il ne s'agit pas d'une "erreur" si vous réussissez à répondre à la demande de l'utilisateur. Pour éviter cela, utilisez une instruction "if" telle que celle ci-dessus afin de gérer spécifiquement les erreurs d'abandon.

Voici un exemple qui donne à l'utilisateur un bouton pour charger du contenu et un bouton pour annuler. Si les erreurs de récupération se produisent, une erreur s'affiche, sauf s'il s'agit d'une erreur d'abandon:

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

Voici une démonstration : au moment de la rédaction de ce document, les seuls navigateurs compatibles sont Edge 16 et Firefox 57.

Un seul signal, de nombreuses extractions

Un seul signal peut être utilisé pour annuler plusieurs récupérations à la fois:

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

Dans l'exemple ci-dessus, le même signal est utilisé pour la récupération initiale et pour les récupérations des chapitres parallèles. Voici comment utiliser fetchStory:

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

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

Dans ce cas, l'appel de controller.abort() annule les extractions en cours.

L'avenir

Autres navigateurs

Edge a fait un excellent travail pour l'expédier en premier, et Firefox sont sur leur piste. Ses ingénieurs ont procédé à l'implémentation à partir de la suite de tests pendant l'écriture de la spécification. Pour les autres navigateurs, voici les tickets à suivre:

Dans un service worker

Je dois terminer les spécifications des pièces pour service worker, mais voici le programme:

Comme indiqué précédemment, chaque objet Request possède une propriété signal. Dans un service worker, fetchEvent.request.signal signale un abandon si la page n'est plus intéressée par la réponse. Par conséquent, un code de ce type fonctionne parfaitement:

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

Si la page abandonne la récupération, les signaux fetchEvent.request.signal s'abandonnent. Par conséquent, la récupération dans le service worker aussi.

Si vous récupérez autre chose que event.request, vous devez transmettre le signal à vos récupérations personnalisées.

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

Suivez les spécifications pour en effectuer le suivi. J'ajouterai des liens vers les demandes de navigateur dès que l'implémentation sera prête.

L'histoire

Oui, la création de cette API relativement simple a pris beaucoup de temps. Pourquoi ?

Conflit avec l'API

Comme vous pouvez le voir, la discussion sur GitHub est assez longue. Il y a beaucoup de nuances dans ce thread (et une certaine manque de nuances), mais le principal désaccord est qu'un groupe voulait que la méthode abort existe sur l'objet renvoyé par fetch(), tandis que l'autre voulait une séparation entre l'obtention de la réponse et l'incidence sur la réponse.

Ces exigences sont incompatibles, donc un groupe n’allait pas obtenir ce qu’il voulait. Si c'est votre cas, désolé ! Si ça te permet de te sentir mieux, j'étais aussi dans ce groupe. Toutefois, comme AbortSignal répond aux exigences d'autres API, il semble être le bon choix. De plus, permettre à des promesses enchaînées de devenir annulables deviendrait très compliqué, voire impossible.

Si vous souhaitez renvoyer un objet qui fournit une réponse, mais que vous pouvez également annuler, vous pouvez créer un wrapper simple:

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

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

Faux départs dans TC39

Nous avons essayé de créer une action annulée distincte d'une erreur. Il incluait un troisième état de promesse pour indiquer "cancelled" (annulé) et une nouvelle syntaxe pour gérer l'annulation dans le code synchronisé et asynchrone:

À éviter

Il ne s'agit pas d'un véritable code : la proposition a été retirée

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

La chose la plus courante lorsqu'une action est annulée est de ne rien faire. La proposition ci-dessus a séparé l'annulation des erreurs. Vous n'avez donc pas besoin de gérer ces erreurs spécifiquement. catch cancel vous informent des actions annulées, mais la plupart du temps, cela n'est pas nécessaire.

Ils ont atteint l'étape 1 de l'initiative TC39, mais aucun consensus n'a été obtenu et la proposition a été retirée.

Notre autre proposition, AbortController, ne nécessitait pas de nouvelle syntaxe. Il n'était donc pas logique de la spécifier dans TC39. Tout ce dont nous avions besoin en JavaScript était déjà là. Nous avons donc défini les interfaces dans la plate-forme Web, et plus particulièrement la norme DOM. Une fois que nous avons pris cette décision, le reste s'est réuni assez rapidement.

Modification importante des spécifications

XMLHttpRequest est obsolète depuis des années, mais les spécifications étaient assez vagues. Nous n'avions pas compris à quels moments l'activité réseau sous-jacente pouvait être évitée ou arrêtée, ni ce qui se passait en cas de condition de concurrence entre l'appel de abort() et la fin de la récupération.

Nous voulions faire les bons choix cette fois-ci, mais cela a entraîné une modification importante des spécifications qui nécessitait beaucoup d'examens (c'est de ma faute, et un grand merci à Anne van Kesteren et Domenic Denicola pour m'avoir fait glisser) et à un bon ensemble de tests.

Mais nous y sommes maintenant ! Nous disposons d'une nouvelle primitive Web pour l'annulation des actions asynchrones, et plusieurs récupérations peuvent être contrôlées simultanément. Plus tard, nous verrons comment activer les changements de priorité tout au long de la durée d'une extraction, ainsi qu'une API de niveau supérieur permettant d'observer la progression de la récupération.