Busca cancelável

Jake Archibald
Jake Archibald

O problema original do GitHub para "Aborting a fetch" foi aberto em 2015. Agora, se eu tirar 2015 de 2017 (o ano atual), vou ter 2. Isso demonstra um bug na matemática, porque 2015 foi "há muito tempo".

Começamos a estudar o aborto de transferências em andamento em 2015. Depois de 780 comentários no GitHub, algumas tentativas malsucedidas e cinco solicitações de pull, finalmente temos uma página de destino de transferências abortáveis nos navegadores, sendo o primeiro o Firefox 57.

Atualização:não, eu estava errado. O Edge 16 foi lançado com suporte a abortar primeiro. Parabéns à equipe do Edge!

Vou me aprofundar na história mais tarde, mas primeiro, a API:

O controle + manobra de sinal

Conheça os AbortController e AbortSignal:

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

O controlador tem apenas um método:

controller.abort();

Ao fazer isso, o sinal é notificado:

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

Essa API é fornecida pelo padrão DOM, e é a API inteira. Ele é genérico de propósito para que possa ser usado por outros padrões da Web e bibliotecas JavaScript.

Interromper os indicadores e buscar

A busca pode receber um AbortSignal. Por exemplo, veja como fazer um tempo limite de busca após 5 segundos:

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 você aborta uma busca, a solicitação e a resposta são abortadas. Assim, qualquer leitura do corpo da resposta (como response.text()) também é abortada.

Confira uma demonstração: no momento da redação deste artigo, o único navegador que oferece suporte a isso é o Firefox 57. Além disso, prepare-se, ninguém com habilidade de design foi envolvido na criação da demonstração.

Como alternativa, o indicador pode ser fornecido a um objeto de solicitação e depois transmitido para a busca:

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

fetch(request);

Isso funciona porque request.signal é um AbortSignal.

Como reagir a uma busca abortada

Quando você aborta uma operação assíncrona, a promessa é rejeitada com um DOMException chamado 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);
    }
});

Não é recomendável mostrar uma mensagem de erro se o usuário abortar a operação, porque não é um "erro" se você fizer o que o usuário pediu. Para evitar isso, use uma instrução if, como a acima, para processar erros de interrupção especificamente.

Confira um exemplo que oferece ao usuário um botão para carregar conteúdo e outro para abortar. Se a busca falha, um erro é mostrado, a menos que seja um erro de interrupção:

// 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;
});

Confira uma demonstração: no momento em que este artigo foi escrito, os únicos navegadores compatíveis eram o Edge 16 e o Firefox 57.

Um indicador, várias buscas

Um único indicador pode ser usado para interromper várias transferências de uma só vez:

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);
}

No exemplo acima, o mesmo indicador é usado para a busca inicial e para as buscas paralelas de capítulos. Confira como usar fetchStory:

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

fetchStory({ signal }).then(chapters => {
    console.log(chapters);
});

Nesse caso, chamar controller.abort() vai interromper as buscas em andamento.

O futuro

Outros navegadores

O Edge fez um ótimo trabalho ao lançar isso primeiro, e o Firefox está no caminho certo. Os engenheiros implementaram a partir do pacote de testes enquanto a especificação estava sendo escrita. Para outros navegadores, siga estes tickets:

Em um worker de serviço

Preciso concluir a especificação das partes do service worker, mas este é o plano:

Como mencionei antes, cada objeto Request tem uma propriedade signal. Em um service worker, fetchEvent.request.signal vai sinalizar o aborto se a página não estiver mais interessada na resposta. Como resultado, um código como este funciona:

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

Se a página interromper a busca, o fetchEvent.request.signal vai sinalizar a interrupção, e a busca no service worker também será interrompida.

Se você estiver buscando algo diferente de event.request, será necessário transmitir o indicador para suas buscas personalizadas.

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 })
    );
    }
});

Siga a especificação para acompanhar isso. Vou adicionar links aos tickets do navegador quando estiver tudo pronto para implementação.

O histórico

Sim… levou muito tempo para criar essa API relativamente simples. Veja o motivo:

Conflito de API

Como você pode ver, a discussão do GitHub é bem longa. Há muitas nuances nessa discussão (e algumas falta de nuances), mas o principal desacordo é que um grupo queria que o método abort existisse no objeto retornado por fetch(), enquanto o outro queria uma separação entre receber a resposta e afetá-la.

Esses requisitos são incompatíveis, então um grupo não vai conseguir o que quer. Se for o caso, sentimos muito. Se isso te faz se sentir melhor, eu também estava nesse grupo. No entanto, o fato de AbortSignal atender aos requisitos de outras APIs faz com que pareça a escolha certa. Além disso, permitir que promessas conectadas se tornem abortáveis se tornaria muito complicado, se não impossível.

Se você quiser retornar um objeto que forneça uma resposta, mas também possa ser abortado, crie um wrapper simples:

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

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

False começa em TC39

Foi feito um esforço para diferenciar uma ação cancelada de um erro. Isso incluiu um terceiro estado de promessa para indicar "cancelado" e uma nova sintaxe para processar o cancelamento no código de sincronização e assíncrono:

O que não fazer

Código não real: a proposta foi retirada

    try {
      // Start spinner, then:
      await someAction();
    }
    catch cancel (reason) {
      // Maybe do nothing?
    }
    catch (err) {
      // Show error message
    }
    finally {
      // Stop spinner
    }

A coisa mais comum a fazer quando uma ação é cancelada é nada. A proposta acima separou o cancelamento dos erros para que você não precisasse processar erros de interrupção especificamente. O catch cancel informa sobre ações canceladas, mas, na maioria das vezes, você não precisa fazer isso.

Isso chegou à fase 1 na TC39, mas o consenso não foi alcançado e a proposta foi retirada.

Nossa proposta alternativa, AbortController, não exigia nenhuma nova sintaxe, então não fazia sentido especificá-la no TC39. Tudo o que precisávamos do JavaScript já estava lá, então definimos as interfaces na plataforma da Web, especificamente o padrão DOM. Depois que tomamos essa decisão, o resto aconteceu relativamente rápido.

Grande mudança de especificação

XMLHttpRequest pode ser abortado há anos, mas a especificação era bastante vaga. Não estava claro em quais pontos a atividade de rede subjacente poderia ser evitada ou encerrada ou o que aconteceria se houvesse uma condição de corrida entre a chamada de abort() e a conclusão da busca.

Queríamos fazer certo dessa vez, mas isso resultou em uma grande mudança de especificação que exigiu muita revisão (a culpa é minha, e um grande agradecimento a Anne van Kesteren e Domenic Denicola por me ajudar) e um conjunto decente de testes.

Mas já estamos aqui! Temos uma nova primitiva da Web para interromper ações assíncronas, e várias transferências podem ser controladas de uma só vez. Mais adiante, vamos analisar como ativar as mudanças de prioridade ao longo da vida de uma busca e uma API de nível mais alto para observar o progresso da busca.