</> HTML5Advent
ENFRESDEITPT

// apis · Web Platform Advent #20

AbortController: annullare fetch, timeout e listener di eventi

Un solo controller annulla un fetch, un timeout e una lunga serie di listener di eventi. Come funzionano AbortController e AbortSignal, perché un annullamento manuale e un timeout lanciano errori diversi, e il trucco di pulizia che quasi tutti si perdono.

Una maniglia rossa del freno di emergenza fissata alla parete interna di una carrozza ferroviaria, con la scritta Notbremse

Avviare un'operazione asincrona è facile. Fermarne una che non ti interessa più è la parte che la maggior parte del codice salta, ed è da lì che nascono le perdite di memoria, le race condition e i risultati obsoleti. AbortController è l'unica risposta del browser a tutto questo.

Un controller, un signal

MDN lo definisce senza giri di parole: l'interfaccia AbortController rappresenta un oggetto controller che permette di interrompere una o più richieste Web quando lo si desidera. La forma è sempre la stessa. Crei un controller, ne leggi il signal e passi quel signal a ciò che stai avviando.

La proprietà signal restituisce un'istanza dell'oggetto AbortSignal, che può essere usata per comunicare con un'operazione asincrona, o per interromperla. Il controller è ciò che tieni; il signal è ciò che consegni.

const controller = new AbortController();

const res = await fetch('/api/articles', { signal: controller.signal });

// altrove, più tardi:
controller.abort();

Nota la direzione. Il codice che svolge il lavoro non decide mai di fermarsi; accetta soltanto di ascoltare. È questa separazione a permettere a un singolo chiamante di annullare molte operazioni in una volta sola.

I due errori, e perché conta

Questa è la parte che vale la pena leggere con attenzione, perché lo stesso annullamento può lanciare due cose diverse.

Quando chiami tu stesso abort(), la promise di fetch() viene rifiutata con una DOMException chiamata AbortError. Quando il signal proveniva da AbortSignal.timeout(), la promise di fetch() viene invece rifiutata con una DOMException TimeoutError.

Il controllo abituale è quindi incompleto:

try {
  const res = await fetch(url, { signal });
} catch (err) {
  if (err.name === 'AbortError') {
    // l'utente o il nostro stesso codice ha annullato
  } else if (err.name === 'TimeoutError') {
    // il tempo è scaduto - una situazione del tutto diversa
  } else {
    throw err; // un vero errore di rete o di parsing
  }
}

Far collassare i due casi fa perdere informazione reale. Un utente che lascia la pagina non è un problema; una richiesta che ha esaurito il tempo probabilmente lo è, e merita di essere segnalata o ritentata. Il codice che cerca solo AbortError tratta ogni timeout come un non-evento silenzioso.

Timeout senza impianto idraulico

Lo schema che la maggior parte delle persone scrive ancora abbina un controller a un setTimeout e a un clearTimeout dentro un blocco finally. Funziona, e non serve più.

AbortSignal.timeout() restituisce un'istanza di AbortSignal che si interromperà automaticamente dopo un tempo specificato. Nessun controller, nessun timer da ripulire, niente che possa restare appeso:

// Prima: tre parti in movimento
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
  const res = await fetch(url, { signal: controller.signal });
} finally {
  clearTimeout(timer);
}

// Adesso: una
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });

Tieni d'occhio il nome dell'errore quando fai questo cambio. La vecchia versione lanciava AbortError, la nuova lancia TimeoutError, quindi ogni blocco catch che avevi già andrà aggiornato.

Un pulsante rosso di arresto di emergenza a fungo, consumato, su una scatola gialla, accanto a un interruttore a bilanciere nero, montato su una macchina
Un pulsante di arresto di emergenza consumato su una macchina, accanto al suo interruttore ordinario. Un comando avvia le cose una alla volta; l'altro le ferma tutte insieme, che è esattamente la divisione dei compiti tra un signal e un controller.

Il trucco che quasi tutti si perdono: i listener di eventi

AbortSignal non è una funzionalità di fetch. addEventListener accetta un signal tra le sue opzioni e, quando quel signal si interrompe, il listener viene rimosso per te. Questo sostituisce in silenzio il codice di pulizia più noioso del lavoro front-end.

Confrontalo con la rimozione manuale dei listener, che obbliga a conservare un riferimento a ogni funzione passata:

// Prima: devi conservare ogni handler per poterlo rimuovere
function onScroll() { /* ... */ }
function onResize() { /* ... */ }
window.addEventListener('scroll', onScroll);
window.addEventListener('resize', onResize);

function teardown() {
  window.removeEventListener('scroll', onScroll);
  window.removeEventListener('resize', onResize);
}

Con un signal, gli handler possono tornare a essere inline, e una sola chiamata li rimuove tutti:

const controller = new AbortController();
const { signal } = controller;

window.addEventListener('scroll', () => { /* ... */ }, { signal });
window.addEventListener('resize', () => { /* ... */ }, { signal });
document.addEventListener('keydown', () => { /* ... */ }, { signal });

// una chiamata rimuove tutti e tre
controller.abort();

Lo stesso controller può anche tenere il signal di un fetch in corso. Smontare una vista diventa allora un unico abort() che annulla la richiesta e sgancia tutti i listener insieme.

Combinare i signal e leggerne lo stato

AbortSignal.any() restituisce un AbortSignal che si interrompe quando uno qualsiasi dei signal di interruzione forniti si interrompe. È così che ottieni una scadenza e un annullamento manuale sulla stessa operazione:

const controller = new AbortController();

const signal = AbortSignal.any([
  controller.signal,          // l'utente ha premuto Annulla
  AbortSignal.timeout(10000), // oppure sono passati dieci secondi
]);

const res = await fetch(url, { signal });

Un signal ti dice anche a che punto è. aborted è un booleano che indica se la richiesta o le richieste con cui il signal sta comunicando sono interrotte, e reason è un valore JavaScript che fornisce il motivo dell'interruzione, una volta che il signal si è interrotto. Passare un motivo tuo a abort() è il modo per distinguere le cause in seguito:

controller.abort(new Error('User navigated away'));

// più tardi
if (signal.aborted) console.log(signal.reason.message);

Esiste anche un evento abort, invocato quando le operazioni asincrone con cui il signal sta comunicando vengono interrotte, che permette anche al tuo codice di lunga durata di collaborare, e non solo alle API integrate.

Rendere annullabile la tua funzione

Tutto ciò che accetta un signal entra nello stesso sistema. Lo schema consiste nel controllare aborted prima di partire e nell'ascoltare l'evento abort durante l'esecuzione:

function wait(ms, { signal } = {}) {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) return reject(signal.reason);
    const id = setTimeout(resolve, ms);
    signal?.addEventListener('abort', () => {
      clearTimeout(id);
      reject(signal.reason);
    }, { once: true });
  });
}

Ora anche il tuo helper può essere annullato dallo stesso controller che annulla i tuoi fetch, che è tutto il senso di avere un meccanismo solo invece di parecchi.

Riferimento rapido

ObiettivoChiamata
Annullare manualmentecontroller.abort(), lancia AbortError
Aggiungere una scadenzaAbortSignal.timeout(ms), lancia TimeoutError
Uno qualsiasi dei dueAbortSignal.any([a, b])
Rimuovere un listener in automaticoaddEventListener(type, fn, { signal })
Controllare lo statosignal.aborted
Scoprire il motivosignal.reason
Reagire all'annullamentol'evento abort

AbortController è una piccola API che risolve in silenzio un'ampia categoria di problemi di pulizia. Passa un solo signal alle tue richieste, ai tuoi listener e ai tuoi helper asincroni, poi fermali tutti con una sola chiamata. Ricorda soltanto quale errore stai intercettando, perché un annullamento manuale e una scadenza superata non sono lo stesso evento.

Per il lato richieste, vedi la nostra guida alla Fetch API in JavaScript.

Domande frequenti

A cosa serve AbortController?
MDN lo descrive come un oggetto controller che permette di interrompere una o più richieste Web quando lo si desidera. Crei un controller, passi il suo signal a ciò che hai avviato e chiami abort() quando vuoi fermarlo. Non è limitato a fetch: qualsiasi cosa accetti un AbortSignal può essere annullata allo stesso modo.
Che errore lancia un fetch interrotto?
Dipende da come è stato interrotto, ed è qui che molti inciampano. Quando chiami tu stesso AbortController.abort(), la promise del fetch viene rifiutata con una DOMException chiamata AbortError. Quando il signal proveniva da AbortSignal.timeout(), la promise viene invece rifiutata con una DOMException TimeoutError. Il codice che controlla solo AbortError lascerà passare in silenzio il caso del timeout.
Come aggiungo un timeout a un fetch?
AbortSignal.timeout() restituisce un'istanza di AbortSignal che si interromperà automaticamente dopo un tempo specificato, quindi passi AbortSignal.timeout(5000) come signal e non ti servono né un controller, né setTimeout, né clearTimeout. Ricorda che rifiuta con TimeoutError, non con AbortError.
Un solo controller può annullare più cose insieme?
Sì, ed è proprio questo il senso del nome. Un signal può essere passato a un numero qualsiasi di operazioni, così un'unica chiamata a abort() ferma ogni fetch e rimuove ogni listener di eventi che lo ha ricevuto. È la pulizia più ordinata a disposizione quando si smonta una vista.
Posso combinare un timeout con un annullamento manuale?
AbortSignal.any() restituisce un AbortSignal che si interrompe quando uno qualsiasi dei signal di interruzione forniti si interrompe. Passagli il signal del tuo controller e un signal di timeout, e l'operazione si ferma a qualunque dei due arrivi per primo.