Recupero interrotto

Jake Archibald
Jake Archibald

Il problema originale di GitHub relativo all'interruzione di un recupero è stato aperto nel 2015. Ora, se tolgo il 2015 dal 2017 (l'anno in corso), ottengo 2. Questo dimostra un bug in matematica, perché il 2015 è stato "per sempre" fa.

Nel 2015 abbiamo iniziato a esplorare l'interruzione dei recuperi in corso e, dopo 780 commenti GitHub, un paio di false avvii e 5 richieste di pull, finalmente abbiamo raggiunto il recupero interrotto nei browser, il primo è Firefox 57.

Aggiornamento: Noooope, mi sbagliavo. Edge 16 è arrivato con il supporto per l'interruzione prima! Congratulazioni al team di Edge!

Analizzerò la cronologia più avanti, ma, prima, vediamo l'API:

Controllore e manovra del segnale

Scopri AbortController e AbortSignal:

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

Il controller ha un solo metodo:

controller.abort();

In questo caso, il segnale viene avvisato:

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

Questa API è fornita dallo standard DOM, che rappresenta l'intera API. È deliberatamente generico, quindi può essere utilizzato da altri standard web e librerie JavaScript.

Interrompi indicatori e recupera

Il recupero può richiedere 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, pertanto viene interrotta anche qualsiasi lettura del corpo della risposta, ad esempio response.text().

Ecco una demo: al momento della scrittura, l'unico browser che supporta questa funzionalità è Firefox 57. Inoltre, preparati perché non è stato coinvolto nessuno con abilità di progettazione nella creazione della demo.

In alternativa, l'indicatore può essere fornito a un oggetto di richiesta e poi passato al recupero:

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

fetch(request);

Funziona perché request.signal è un AbortSignal.

Reazione 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 è consigliabile mostrare un messaggio di errore se l'utente ha interrotto l'operazione, poiché non si tratta di un "errore" se l'utente esegue correttamente la richiesta. Per evitare che ciò accada, utilizza un'istruzione if come quella riportata sopra per gestire in modo specifico gli errori di interruzione.

Di seguito è riportato un esempio che offre all'utente un pulsante per caricare i contenuti e un pulsante per interrompere l'operazione. Se gli errori di recupero vengono visualizzati, viene mostrato 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 scrittura, gli unici browser che supportano questa funzionalità sono Edge 16 e Firefox 57.

Un indicatore, molti recuperi

È possibile utilizzare un unico segnale per interrompere più 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 il recupero 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 al numero controller.abort() interromperà qualsiasi recupero in corso.

Il futuro

Altri browser

Edge ha fatto un ottimo lavoro per spedirlo prima e Firefox sono sulla strada giusta. I loro ingegneri hanno implementato la suite di test mentre venivano scritte le specifiche. Per gli altri browser, ecco i biglietti da seguire:

In un service worker

Devo completare le specifiche per i componenti del service worker, ma il piano è il seguente:

Come accennato prima, ogni oggetto Request ha una proprietà signal. All'interno di un service worker, fetchEvent.request.signal segnalerà l'interruzione se la pagina non è più interessata alla risposta. Di conseguenza, il codice funziona come questo:

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

Se la pagina interrompe il recupero, fetchEvent.request.signal segnala l'interruzione, in modo che il recupero all'interno del service worker venga interrotto.

Se recuperi qualcosa di diverso da event.request, dovrai passare l'indicatore ai recuperi 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 monitorarlo. Aggiungerò i link ai ticket del browser quando sono pronti per l'implementazione.

La storia

Sì... ci è voluto molto tempo prima che questa API relativamente semplice si unisse. per i seguenti motivi:

Disaccordo API

Come puoi vedere, la discussione su GitHub è piuttosto lunga. Questo thread presenta molte sfumature (e alcune prive di sfumature), ma il disaccordo principale è che un gruppo voleva che il metodo abort esistesse sull'oggetto restituito da fetch(), mentre l'altro voleva una separazione tra la ricezione e l'influenza sulla risposta.

Questi requisiti non sono compatibili, quindi un gruppo non riusciva a ottenere ciò che voleva. Se sei tu, scusa! Se ti fa sentire meglio, anch'io ero in quel gruppo. Tuttavia, vedere che AbortSignal soddisfa i requisiti di altre API ci sembra la scelta giusta. Inoltre, consentire l'interruzione delle promesse concatenate diventerà molto complicato, se non impossibile.

Se vuoi restituire un oggetto che fornisce una risposta, ma che può anche essere interrotto, puoi creare un wrapper semplice:

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

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

False partenze in TC39

Si è verificato un tentativo di distinguere un'azione annullata da un errore. Ciò includeva un terzo stato della promessa per indicare "cancelled" e alcune nuove sintassi per gestire l'annullamento sia nel codice di sincronizzazione che in quello asincrono:

Cosa non fare

Codice non reale: 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 viene annullata un'azione è nulla. La proposta precedente separava l'annullamento dagli errori in modo da non dover gestire in modo specifico gli errori relativi all'interruzione. catch cancel ti consente di ricevere informazioni sulle azioni annullate, ma nella maggior parte dei casi non ti serve.

Questo è arrivato alla fase 1 nel TC39, ma il consenso non è stato raggiunto e la proposta è stata ritirata.

La nostra proposta alternativa, AbortController, non ha richiesto alcuna nuova sintassi, quindi non aveva senso specificarla in TC39. Tutto ciò di cui avevamo bisogno in JavaScript era già presente, quindi abbiamo definito le interfacce all'interno della piattaforma web, in particolare lo standard DOM. Una volta presa questa decisione, il resto si è riunito relativamente rapidamente.

Grande modifica alle specifiche

XMLHttpRequest è stato interrotto per anni, ma le specifiche erano piuttosto vaghe. Non era chiaro in quali punti l'attività di rete sottostante poteva essere evitata o terminata oppure non era chiaro cosa succedeva se si verificasse una condizione di gara tra la chiamata di abort() e il completamento del recupero.

Volevamo farlo nel modo giusto, ma questa volta ha portato a un grande cambiamento alle specifiche che ha richiesto molte revisioni (è colpa mia, grazie mille ad Anne van Kesteren e Domenic Denicola per avermi seguito) e una discreta serie di test.

Ma siamo qui ora! È disponibile una nuova primitiva web per l'interruzione delle azioni asincrone ed è possibile controllare più recuperi contemporaneamente. Più avanti in futuro, vedremo l'attivazione delle modifiche prioritarie durante la vita di un recupero e un'API di livello superiore per osservare l'avanzamento del recupero.