Busca cancelável

Jake Archibald
Jake Archibald

O problema original do GitHub para "Cancelar uma busca" foi inaugurado em 2015. Agora, se eu tirar 2015 de 2017 (o ano atual), receberei dois. Isso demonstra na matemática, porque 2015 foi "para sempre" atrás

Em 2015, começamos a analisar o cancelamento de buscas em andamento e, após 780 comentários no GitHub, uma algumas inicializações falsas e cinco solicitações de envio, finalmente temos um destino de busca abortável nos navegadores, sendo o Firefox 57 o primeiro.

Atualização:Nãoooope, eu estava errado. O Edge 16 chegou primeiro com o suporte para cancelamento. Parabéns ao Equipe do Edge!

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

Manobra do controle + 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 indicador:

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

Essa API é fornecida pelo padrão DOM e é a API inteira. Está deliberadamente 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ê aborta uma busca, ela aborta a solicitação e a resposta. Portanto, qualquer leitura do corpo da resposta (como response.text()) também é cancelada.

Veja uma demonstração – No momento, o único navegador que oferece suporte para isso, é o Firefox 57. Além disso, prepare-se, ninguém com nenhuma habilidade de design esteve envolvido para criar a demonstração.

Como alternativa, o sinal pode ser dado a um objeto de solicitação e depois passado para buscar:

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

Geralmente, não convém mostrar uma mensagem de erro se o usuário cancelar a operação, já que isso não é um "erro" se você fez o que o usuário pediu. Para evitar isso, use uma instrução if como a do acima para lidar especificamente com erros de cancelamento.

Confira um exemplo que oferece ao usuário um botão para carregar conteúdo e um botão para cancelar. Se a busca erros, 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: atualmente, os únicos navegadores que têm o Edge 16 e o Firefox 57.

Um indicador, muitas buscas

Um único indicador pode ser usado para cancelar muitas 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 o capítulo paralelo busca. 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 cancelar as buscas em andamento.

O futuro

Outros navegadores

O Edge fez um ótimo trabalho ao lançar esse recurso primeiro, e o Firefox está no caminho certo. Os engenheiros da empresa implementado do pacote de testes enquanto a especificação era que está sendo escrito. Para outros navegadores, veja os ingressos que você pode seguir:

Em um service worker

Preciso terminar a especificação das peças do service worker, mas aqui está o plano:

Como mencionei antes, cada objeto Request tem uma propriedade signal. Em um service worker, fetchEvent.request.signal vai sinalizar o cancelamento se a página não tiver mais interesse 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 sinais serão cancelados, de modo que a busca dentro da o service worker também faz o cancelamento.

Se você estiver buscando algo diferente de event.request, será necessário transmitir o sinal para sua 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 rastrear isso. Vou adicionar links para de tíquetes do navegador quando ele estiver pronto para implementação.

A história

Sim... demorou muito para que essa API relativamente simples fosse criada. Veja o motivo:

Discordância com a API

Como você pode ver, a discussão do GitHub é bem longa (link em inglês). Há muitas nuances nessa conversa (e alguma falta de nuances), mas a principal divergência é uma queria que o método abort existisse no objeto retornado por fetch(), enquanto o outro queria uma separação entre obter a resposta e afetar sua resposta.

Esses requisitos são incompatíveis, então um grupo não conseguia o que queria. Se isso é desculpe! Se isso faz você se sentir melhor, eu também estava nesse grupo. Mas ver AbortSignal se encaixar requisitos de outras APIs faz parecer a escolha certa. Além disso, permitir que promessas encadeadas se tornar abortável se tornaria muito complicado, se não impossível.

Se você quisesse retornar um objeto que fornecesse uma resposta, mas também pudesse cancelar, poderia criar uma wrapper simples:

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

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

Falso começa em TC39

Houve um esforço para diferenciar uma ação cancelada de um erro. Isso incluía uma terceira promessa estado para indicar "cancelado" e uma nova sintaxe para lidar com o cancelamento, tanto de sincronização quanto de sincronização código:

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 cancelamento de erros para que você não precisasse lidar especificamente com erros de cancelamento. catch cancel permitem ouvir sobre ações canceladas, mas na maioria das vezes você não precisaria fazer isso.

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

Nossa proposta alternativa, AbortController, não exigia nenhuma sintaxe nova, por isso não fazia sentido para especificá-lo no TC39. Tudo o que precisávamos de JavaScript já estava lá, então definimos o na plataforma da Web, especificamente o padrão DOM. Depois de tomarmos essa decisão, e o restante se reuniu relativamente rápido.

Grande mudança nas especificações

O método XMLHttpRequest pode ser cancelado há anos, mas as especificações eram bastante vagas. Não ficou claro em pontos em que a atividade de rede subjacente poderia ser evitada ou encerrada, ou o que aconteceu se houve uma disputa entre a chamada de abort() e a conclusão da busca.

Queríamos acertar dessa vez, mas isso resultou em uma grande mudança na especificação que precisou de muita analisando (culpa minha e muito obrigado a Anne van Kesteren e Domenic Denicola por me arrastar) e um bom conjunto de testes.

Mas estamos aqui! Temos um novo primitivo da Web para cancelar ações assíncronas, e várias buscas podem ser controladas de uma só vez! Mais adiante, veremos como ativar mudanças de prioridade durante a vida útil de uma busca e um nível API para observar o progresso da busca.