Busca cancelável

Jake Archibald
Jake Archibald

O problema original do GitHub para "Cancelar uma busca" foi aberto em 2015. Agora, se eu sair de 2015 de 2017 (o ano atual), vou receber dois. Isso demonstra um bug na matemática, porque 2015 foi, na verdade, "para sempre".

Em 2015, começamos a explorar o cancelamento de buscas em andamento. Depois de 780 comentários no GitHub, alguns inícios falsos e cinco solicitações de envio, finalmente temos o destino de busca anulável nos navegadores, o primeiro sendo o Firefox 57.

Atualização:não é bem isso, eu estava errado. O Edge 16 chegou com suporte para cancelamento primeiro. Parabéns à equipe do Edge!

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

Controle e manobra de sinal

Conheça AbortController e AbortSignal:

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

O controlador só tem um método:

controller.abort();

Ao fazer isso, ele notifica o sinal:

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

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

Cancelar indicadores e buscar

A busca pode levar uma AbortSignal. Por exemplo, veja como definir 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ê cancela uma busca, ela cancela a solicitação e a resposta. Portanto, qualquer leitura do corpo da resposta (como response.text()) também é cancelada.

Veja uma demonstração: até o momento, o único navegador com suporte para essa opção é o Firefox 57. Além disso, prepare-se. Ninguém com habilidade de design estava envolvido na criação da demonstração.

Como alternativa, o sinal pode ser dado a um objeto de solicitação e depois transmitido para 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 cancelada

Quando você cancela 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);
    }
});

Muitas vezes, você não quer mostrar uma mensagem de erro se o usuário tiver cancelado a operação, já que não será um "erro" se você fizer o que o usuário pediu. Para evitar isso, use uma declaração "if", como a acima, para lidar especificamente com erros de cancelamento.

Veja um exemplo que dá ao usuário um botão para carregar conteúdo e outro para cancelar. Se houver um erro de busca, um erro será exibido, a menos que seja um erro de cancelamento:

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

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

Um indicador, muitas buscas

Um único sinal pode ser usado para cancelar várias buscas 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 sinal é usado para a busca inicial e para as buscas paralelas de capítulos. Confira como usaria fetchStory:

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

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

Nesse caso, chamar controller.abort() cancelará todas as buscas em andamento.

O futuro

Outros navegadores

Edge fez um ótimo trabalho para 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, estes são os tíquetes a serem seguidos:

Em um service worker

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

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

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

Se a página cancelar a busca, fetchEvent.request.signal sinalizará o cancelamento, de modo que a busca no service worker também seja cancelada.

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 para os ingressos do navegador assim que ele estiver pronto para implementação.

A história

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

Discordo da API

Como você pode notar, a discussão do GitHub é bastante longa (link em inglês). Há muitas nuances nessa linha de execução (e alguma falta de nuances), mas a principal discordância é que um grupo queria que o método abort existisse no objeto retornado por fetch(), enquanto o outro queria a separação entre receber a resposta e afetar a resposta.

Esses requisitos são incompatíveis, então um grupo não conseguiria o que queria. Se for você, sinto muito! Se isso faz você se sentir melhor, eu também estava nesse grupo. Mas ver que a AbortSignal se encaixa nos requisitos de outras APIs faz com que ela pareça a escolha certa. Além disso, permitir que promessas encadeadas se tornem anuláveis seria algo muito complicado, se não impossível.

Para retornar um objeto que fornece uma resposta, mas que também pode ser cancelado, 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 })
    };
}

O valor "False" começa no TC39

Houve um esforço para fazer uma ação cancelada diferente de um erro. Isso incluía 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

O código não é real: a proposta foi cancelada

    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 lidar especificamente com erros de cancelamento. catch cancel permite receber informações sobre ações canceladas, mas na maioria das vezes você não precisa fazer isso.

Isso chegou à etapa 1 do TC39, mas não houve consenso, e a proposta foi retirada.

Nossa proposta alternativa, AbortController, não exigiu nova sintaxe, por isso não fazia sentido especificá-la no TC39. Tudo o que precisávamos no JavaScript já estava lá. Por isso, definimos as interfaces na plataforma da Web, especificamente o padrão DOM. Uma vez que tomamos essa decisão, o resto se juntou relativamente rápido.

Mudança de especificação grande

O método XMLHttpRequest é anulável há anos, mas a especificação era bastante vaga. Não ficou claro em que pontos a atividade de rede poderia ser evitada ou encerrada, ou o que acontecia se havia uma disputa entre a chamada de abort() e a conclusão da busca.

Queríamos acertar desta vez, mas isso resultou em uma grande mudança de especificação que precisava de muita revisão. É minha culpa, e um enorme agradecimento a Anne van Kesteren e Domenic Denicola por me arrastarem) e um bom conjunto de testes.

Mas estamos aqui agora! Temos um novo primitivo da Web para cancelar ações assíncronas, e várias buscas podem ser controladas ao mesmo tempo. Mais adiante, veremos a ativação de mudanças de prioridade durante a vida útil de uma busca e uma API de nível mais alto para observar o progresso da busca.