Récupération abortable

Jake Archibald
Jake Archibald

Le problème GitHub d'origine pour "Arrêter une récupération" a été ouvert en 2015. Si je soustrais 2015 à 2017 (l'année en cours), je obtiens 2. Cela démontre un bug dans les mathématiques, car 2015 était en fait "il y a une éternité".

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

Mise à jour:Non, j'avais tort. Edge 16 est le premier navigateur à prendre en charge l'abandon. Félicitations à l'équipe Edge !

Je reviendrai sur l'historique plus tard, mais commençons par l'API:

Manœuvre de contrôleur et de signal

Découvrez les AbortController et AbortSignal:

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

Le contrôleur ne comporte qu'une seule méthode:

controller.abort();

Vous recevez alors une notification du signal:

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

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

Arrêter les signaux et la récupération

La récupération peut prendre un AbortSignal. Par exemple, voici comment définir un délai avant expiration de 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 interrompez une récupération, la requête et la réponse sont interrompues. Par conséquent, toute lecture du corps de la réponse (telle que response.text()) est également interrompue.

Démonstration : au moment de la rédaction de cet article, le seul navigateur compatible est Firefox 57. De plus, préparez-vous : personne n'ayant des compétences en conception n'a été impliqué dans la création de la démonstration.

Vous pouvez également transmettre le signal à un objet de requête, puis le transmettre à la 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 interrompue

Lorsque vous interrompez une opération asynchrone, la promesse est refusé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);
    }
});

Vous ne souhaitez généralement pas afficher de message d'erreur si l'utilisateur a interrompu l'opération, car il ne s'agit pas d'une "erreur" si vous effectuez ce que l'utilisateur a demandé. Pour éviter cela, utilisez une instruction if telle que celle ci-dessus pour gérer spécifiquement les erreurs d'interruption.

Voici un exemple qui fournit à l'utilisateur un bouton pour charger le contenu et un bouton pour l'arrêter. Si la récupération échoue, 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;
});

Démonstration : au moment de la rédaction de cet article, les seuls navigateurs compatibles sont Edge 16 et Firefox 57.

Un signal, plusieurs récupérations

Un seul signal peut être utilisé pour interrompre 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 de 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() interrompra les récupérations en cours.

L'avenir

Autres navigateurs

Edge a fait du bon travail en lançant cette fonctionnalité en premier, et Firefox est sur ses talons. Leurs ingénieurs ont implémenté à partir de la suite de tests pendant la rédaction de la spécification. Pour les autres navigateurs, voici les demandes à suivre:

Dans un service worker

Je dois terminer la spécification des parties du service worker, mais voici le plan:

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

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

Si la page interrompt la récupération, fetchEvent.request.signal signale l'interruption, de sorte que la récupération dans le service worker s'arrête également.

Si vous récupérez un élément autre 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 la spécification pour suivre cela. J'ajouterai des liens vers les demandes de navigateur une fois qu'elles seront prêtes à être implémentées.

L'historique

Oui… Il a fallu beaucoup de temps pour que cette API relativement simple soit finalisée. Voici pourquoi :

Différences concernant l'API

Comme vous pouvez le constater, la discussion GitHub est assez longue. Ce fil de discussion comporte de nombreuses nuances (et un manque de nuances), mais le principal désaccord est que l'un des groupes voulait que la méthode abort existe sur l'objet renvoyé par fetch(), tandis que l'autre voulait séparer l'obtention de la réponse de son impact.

Ces exigences étant incompatibles, un groupe ne pouvait pas obtenir ce qu'il voulait. Si c'est le cas, désolé. Si cela vous rassure, j'étais aussi dans ce groupe. Toutefois, comme AbortSignal répond aux exigences des autres API, il semble être le bon choix. De plus, autoriser les promesses en chaîne à être interrompues deviendrait très compliqué, voire impossible.

Si vous souhaitez renvoyer un objet qui fournit une réponse, mais qui peut également être interrompu, 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 })
    };
}

False commence dans TC39

Nous avons essayé de distinguer une action annulée d'une erreur. Cela incluait un troisième état de promesse pour indiquer "annulé", ainsi qu'une nouvelle syntaxe pour gérer l'annulation à la fois dans le code synchrone et asynchrone:

À éviter

Code non valide : 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 à faire lorsqu'une action est annulée est de ne rien faire. La proposition ci-dessus a séparé l'annulation des erreurs afin que vous n'ayez pas besoin de gérer spécifiquement les erreurs d'abandon. catch cancel vous informe des actions annulées, mais la plupart du temps, vous n'avez pas besoin de le savoir.

Cette proposition a atteint l'étape 1 dans le TC39, mais aucun consensus n'a été atteint, et la proposition a été retirée.

Notre proposition alternative, AbortController, ne nécessitait aucune nouvelle syntaxe. Il n'était donc pas logique de la spécifier dans TC39. Tout ce dont nous avions besoin de JavaScript était déjà là. Nous avons donc défini les interfaces dans la plate-forme Web, en particulier la norme DOM. Une fois cette décision prise, le reste s'est mis en place relativement rapidement.

Modification importante des spécifications

XMLHttpRequest peut être interrompu depuis des années, mais les spécifications étaient assez vagues. Il n'était pas clair à quel moment l'activité réseau sous-jacente pouvait être évitée ou arrêtée, ni ce qui se passait en cas de conflit entre l'appel de abort() et la fin de la récupération.

Nous voulions bien faire cette fois-ci, mais cela a entraîné un grand changement de spécifications qui a nécessité de nombreuses révisions (c'est de ma faute, et un grand merci à Anne van Kesteren et à Domenic Denicola pour m'avoir aidé à le faire) et un ensemble décent de tests.

Mais nous y sommes maintenant. Nous disposons d'une nouvelle primitive Web pour interrompre les actions asynchrones, et plusieurs récupérations peuvent être contrôlées en même temps. Par la suite, nous verrons comment activer les modifications de priorité tout au long de la durée de vie d'une récupération et une API de niveau supérieur pour observer la progression de la récupération.