Il problema originale di GitHub relativo all'interruzione di un recupero è stato aperto nel 2015. Ora, se sottraggo 2015 da 2017 (l'anno corrente), ottengo 2. Questo dimostra un bug nella matematica, perché il 2015 è passato "da un'eternità".
Nel 2015 abbiamo iniziato a esplorare l'interruzione dei recuperi in corso e, dopo 780 commenti di GitHub, un paio di false partenze e 5 richieste pull, abbiamo finalmente implementato il recupero annullabile nei browser, il primo dei quali è Firefox 57.
Aggiornamento: no, mi sbagliavo. Edge 16 è stato il primo browser a supportare l'interruzione. Congratulazioni al team di Edge.
Analizzerò la cronologia più avanti, ma prima vediamo l'API:
Il controller + la manovra di indicazione
Scopri AbortController
e AbortSignal
:
const controller = new AbortController();
const signal = controller.signal;
Il controller ha un solo metodo:
controller.abort();
In questo modo, il segnale viene informato:
signal.addEventListener('abort', () => {
// Logs true:
console.log(signal.aborted);
});
Questa API è fornita dallo standard DOM e rappresenta l'intera API. È deliberatamente generico in modo da poter essere utilizzato da altri standard web e librerie JavaScript.
Interrompere l'invio di indicatori e il recupero
La funzione Fetch può accettare un AbortSignal
. Ad esempio, ecco come impostare un timeout di recupero dopo 5 secondi:
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);
});
Quando interrompi un recupero, vengono interrotte sia la richiesta sia la risposta, quindi viene interrotta anche la lettura del corpo della risposta
(ad esempio response.text()
).
Ecco una demo: al momento della stesura di questo articolo, l'unico browser che supporta questa funzionalità è Firefox 57. Inoltre, preparati: nessuno con competenze di design è stato coinvolto nella creazione della demo.
In alternativa, l'indicatore può essere assegnato a un oggetto request e poi passato a fetch:
const controller = new AbortController();
const signal = controller.signal;
const request = new Request(url, { signal });
fetch(request);
Questo funziona perché request.signal
è un AbortSignal
.
Reagire a un recupero interrotto
Quando interrompi un'operazione asincrona, la promessa viene rifiutata con un DOMException
denominato 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);
}
});
Spesso non è opportuno mostrare un messaggio di errore se l'utente ha interrotto l'operazione, in quanto non si tratta di un "errore" se esegui correttamente ciò che l'utente ha richiesto. Per evitare questo, utilizza un'istruzione if come quella riportata sopra per gestire in modo specifico gli errori di interruzione.
Ecco un esempio che offre all'utente un pulsante per caricare i contenuti e un pulsante per annullare l'operazione. Se il recupero non va a buon fine, viene visualizzato un errore, a meno che non si tratti di un errore di interruzione:
// 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;
});
Ecco una demo: al momento della stesura di questo articolo, gli unici browser che supportano questa funzionalità sono Edge 16 e Firefox 57.
Un indicatore, molti recuperi
Un singolo indicatore può essere utilizzato per interrompere molti recuperi contemporaneamente:
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);
}
Nell'esempio precedente, lo stesso indicatore viene utilizzato per il recupero iniziale e per i recuperi dei capitoli paralleli. Ecco come utilizzare fetchStory
:
const controller = new AbortController();
const signal = controller.signal;
fetchStory({ signal }).then(chapters => {
console.log(chapters);
});
In questo caso, la chiamata a controller.abort()
interromperà i recuperi in corso.
Il futuro
Altri browser
Edge ha fatto un ottimo lavoro a lanciare questa funzionalità per primo e Firefox è alle sue spalle. I loro ingegneri li hanno implementati dalla suite di test durante la scrittura della specifica. Per gli altri browser, ecco i ticket da seguire:
In un service worker
Devo completare la specifica per le parti del service worker, ma ecco il piano:
Come ho detto prima, ogni oggetto Request
ha una proprietà signal
. In un worker di servizio,
fetchEvent.request.signal
segnalerà l'interruzione se la pagina non è più interessata alla risposta.
Di conseguenza, il codice come questo funziona:
addEventListener('fetch', event => {
event.respondWith(fetch(event.request));
});
Se la pagina interrompe il recupero, fetchEvent.request.signal
segnala l'interruzione, quindi viene interrotta anche la ricerca all'interno del servizio worker.
Se stai recuperando qualcosa di diverso da event.request
, dovrai passare l'indicatore ai tuoi recupero personalizzati.
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 })
);
}
});
Segui le specifiche per monitorare questo aspetto. Aggiungerò i link ai ticket del browser quando sarà tutto pronto per l'implementazione.
La cronologia
Sì… ci è voluto molto tempo per mettere insieme questa API relativamente semplice. per i seguenti motivi:
Mancata corrispondenza dell'API
Come puoi vedere, la discussione su GitHub è piuttosto lunga.
Il thread è molto articolato (e in parte privo di sfumature), ma il principale disaccordo è che un gruppo voleva che il metodo abort
esistesse nell'oggetto restituito da fetch()
, mentre l'altro voleva una separazione tra l'ottenimento della risposta e l'influenza sulla risposta.
Questi requisiti sono incompatibili, quindi un gruppo non avrebbe ottenuto ciò che voleva. Se è così, mi dispiace. Se ti fa sentire meglio, anche io facevo parte di quel gruppo. Tuttavia, dato che AbortSignal
soddisfa i requisiti di altre API, sembra la scelta giusta. Inoltre, consentire alle promesse incatenate di essere interrotte diventerebbe molto complicato, se non impossibile.
Se vuoi restituire un oggetto che fornisca una risposta, ma che possa anche interrompersi, puoi creare un semplice wrapper:
function abortableFetch(request, opts) {
const controller = new AbortController();
const signal = controller.signal;
return {
abort: () => controller.abort(),
ready: fetch(request, { ...opts, signal })
};
}
False start in TC39
È stato fatto un tentativo per distinguere un'azione annullata da un errore. È stato incluso un terzo stato della promessa per indicare "annullato" e una nuova sintassi per gestire l'annullamento sia nel codice sincrono sia in quello asincrono:
Codice non valido: la proposta è stata ritirata
try { // Start spinner, then: await someAction(); } catch cancel (reason) { // Maybe do nothing? } catch (err) { // Show error message } finally { // Stop spinner }
La cosa più comune da fare quando un'azione viene annullata è non fare nulla. La proposta di cui sopra separava la cancellazione dagli errori, quindi non era necessario gestire specificamente gli errori di interruzione. catch cancel
ti informa delle azioni annullate, ma nella maggior parte dei casi non è necessario.
Questa proposta è arrivata alla fase 1 del TC39, ma non è stato raggiunto il consenso e la proposta è stata ritirata.
La nostra proposta alternativa, AbortController
, non richiedeva una nuova sintassi, quindi non aveva senso specificarla all'interno del TC39. Tutto ciò di cui avevamo bisogno da JavaScript era già disponibile, quindi abbiamo definito le interfacce all'interno della piattaforma web, in particolare lo standard DOM. Una volta presa questa decisione,
il resto è stato fatto relativamente rapidamente.
Modifica sostanziale delle specifiche
XMLHttpRequest
è abortabile da anni, ma la specifica era piuttosto vaga. Non era chiaro in quali punti l'attività di rete sottostante poteva essere evitata o interrotta o cosa succedeva se si verificava una condizione di gara tra la chiamata di abort()
e il completamento del recupero.
Volevamo fare le cose per bene questa volta, ma ciò ha comportato una grande modifica delle specifiche che ha richiesto molto lavoro di revisione (è colpa mia e un enorme grazie ad Anne van Kesteren e Domenic Denicola per avermi aiutato) e un buon numero di test.
Ma ora siamo qui. Abbiamo una nuova primitiva web per l'interruzione delle azioni asincrone e possiamo controllare più richieste contemporaneamente. In futuro, valuteremo la possibilità di attivare le modifiche della priorità durante il ciclo di vita di un recupero e di un'API di livello superiore per osservare l'avanzamento del recupero.