Introduites en 2017, et disponible dans la plupart des navigateurs depuis mars 2019, les APIs AbortController et AbortSignal nous fournissent des outils très pratiques pour annuler des requêtes fetch et bien plus encore.
Dans cet article, nous verrons d'autres usages, ainsi que la manière d’implémenter des APIs "abortables"
AbortController ? AbortSignal ? Késako ?
Tout d'abord, reprenons les bases. Un AbortController nous fournit un objet permettant d'annuler des opérations asynchrones, comme par exemple des requêtes fetch, via des AbortSignal. L'exemple le plus utilisé en général est celui d'un abort d'une requête fetch, comme illustré dans ce bout de code sur MDN.
let controller;
const url = "video.mp4";
const downloadBtn = document.querySelector(".download");
const abortBtn = document.querySelector(".abort");
downloadBtn.addEventListener("click", fetchVideo);
abortBtn.addEventListener("click", () => {
if (controller) {
controller.abort();
console.log("Download aborted");
}
});
async function fetchVideo() {
controller = new AbortController();
const signal = controller.signal;
try {
const response = await fetch(url, { signal });
console.log("Download complete", response);
// process response further
} catch (err) {
console.error(`Download error: ${err.message}`);
}
}Exemple classique d'utilisation d'un AbortController avec fetch
Comme on peut le voir, l'utilisation d'un AbortController se fait en trois temps :
- Instanciation du controller
- Récupération et passage de son abort signal à la méthode que l'on veut rendre annulable
- Appel de la méthode
.abort()
Jusque là, rien d'extraordinaire. Si on lit plus loin dans l'article, on peut voir que si la requête fetch est déjà partie et revenue, la tentative de lire le corps de la réponse va lever une erreur.
C'est dans les méthodes statiques de l'abort signal que les fonctionnalités commencent à être utiles:
AbortSignal.timeout(millis)renvoie un signal qui va être annulé au bout de l'intervalle défini. Très pratique pour implémenter des timeouts dans toutes les APIs qui peuvent accepter un signal.AbortSignal.any([signal1, signal2, ...])nous permet de combiner plusieurs signaux, en étant annulé dès que le premier de la liste est annulé.
De plus, un abort signal est également une instance de EventTarget. C'est d'ailleurs grâce à ce mécanisme que l'on va pouvoir créer nos fonctions annulables.
function myCoolPromiseAPI(/* …, */ { signal }) {
return new Promise((resolve, reject) => {
// If the signal is already aborted, immediately throw in order to reject the promise.
if (signal.aborted) {
reject(signal.reason);
return;
}
// Perform the main purpose of the API
// Call resolve(result) when done.
// Watch for 'abort' signals
signal.addEventListener("abort", () => {
// Stop the main operation
// Reject the promise with the abort reason.
reject(signal.reason);
});
});
}Utilisation d'un abort signal sur une méthode custom
Exemple d'utilisation des abort controllers dans le monde React
Saviez-vous que l'API EventTarget était compatible avec les abort signals ? En particulier, la méthode .addEventListener peut accepter un abort signal, et va retirer le listener quand le signal est annulé. C'est très pratique dans le monde React pour poser des listeners d'événements dans des useEffect.
function MyComponent(){
const [x, setX] = useState();
const [y, setY] = useState();
useEffect(() => {
const controller = new AbortController();
window.addEventListener('mousemove', (e) => {
setX(e.offsetX);
setY(e.offsetY);
}, {signal: controller.signal});
return () => controller.abort();
}, []);
return <div>
<h1>Position de la souris</h1>
<ul>
<li>X: {x}</li>
<li>Y: {y}</li>
</ul>
</div>
}Comme on peut le constater, l'utilisation d'un abort controller simplifie grandement l'utilisation des event listeners dans le monde React. Il suffit de définir un abort controller, de passer son signal à tous les .addEventListener, et de renvoyer une fonction qui fait un .abort(). Si on a besoin de poser plusieurs listeners, on peut tout annuler avec une simple méthode, et plus besoin de se poser de questions de garder une référence vers une instance de la fonction pour pouvoir appeler le .removeEventListener correspondant !
Comme la majorité des exemples utilise fetch comme API annulable, je me dois de faire ce petit rappel d'utilité publique:
Utilisez des libs dédiées comme @tanstack/react-query, @reduxjs/toolkit, ou le nouveau hook use de react.
Des grands acteurs d'Internet se sont déjà fait avoir (article de blog Cloudflare); et même si le fait de faire un fetch dans un useEffect n'est pas la cause de l'outage Cloudflare, il y a grandement contribué.
Implémentation d'une API compatible : wrapper pour Microsoft SignalR
Récemment, j'ai utilisé @microsoft/signalr pour faire de la communication client/server bidirectionnelle. Si vous ne connaissez pas SignalR, c'est l'équivalent Microsoft de SocketIO: une librairie qui fournit une communication bidirectionnelle abstraite de la sérialisation des messages (JSON, MessagePack), et du medium de transport sous-jacent: websockets, server-sent events, ou long polling. Le medium de transport et la sérialisation des messages sont négociés entre le client et le serveur.
La classe principale qui va nous intéresser ici est la classe HubConnection. C'est la classe principale qui porte notre "client" signalR, et qui va nous permettre d'envoyer des messages au serveur (aussi appelé hub) via la méthode .invoke, ou d'en recevoir, via la méthode .on(eventName, callback). On peut enregistrer plusieurs callbacks via .on, et on peut/doit les supprimer via la méthode .off(eventName, callback). Pour que la méthode off fonctionne, il faut passer l'instance exacte du handler. On peut aussi appeler .off(eventName) , ce qui dé-enregistre toutes les callbacks pour cet événement.
Si vous avez déjà utilisé jQuery cette interface vous est probablement familière. C'est en effet l'interface que propose jQuery au dessus des abstractions navigateurs. C'est la première interface pour écouter des événements qui est la même sur les vieilles (et désormais infâmes) versions de Internet Explorer (6, 7 et 8).
Le fait de devoir garder une référence exacte vers la callback n'est pas très pratique, alors implémentons ensemble une meilleure version qui supporte les abort signals.
function registerSignalRHandler(connection, eventName, callback, options?){
// On pourrait aussi utiliser options.signal?.throwIfAborted()
// pour lever une exception, mais on choisit de ne rien faire
if(options.signal && options.signal.aborted) return;
connection.on(eventName, callback);
if(options.signal){
options.signal.addEventListener('abort', () => {
connection.off(eventName, callback);
})
}
}Implémentation d'une API compatible avec les abort signals
Et voilà, vous pouvez maintenant utiliser cette nouvelle fonction pour enregistrer vos listeners SignalR !
En résumé, les API AbortController et AbortSignal nous fournissent des mécanismes simples et standards pour annuler des tâches asynchrones. Elles fournissent des primitives simples pour annuler de multiples tâches en batch, ainsi que pour implémenter des timeouts.