</> HTML5Advent
ENFRESDEITPT

// apis · Web Platform Advent #20

AbortController: cancelar fetches, tempos limite e listeners de eventos

Um único controlador cancela um fetch, um tempo limite e uma pilha de listeners de eventos. Como funcionam o AbortController e o AbortSignal, por que um cancelamento manual e um tempo limite lançam erros diferentes, e o truque de limpeza que quase todo mundo deixa passar.

Uma alavanca vermelha de freio de emergência fixada na parede interna de um vagão de trem, com a inscrição Notbremse

Iniciar uma operação assíncrona é fácil. Parar uma com a qual você já não se importa é a parte que a maioria do código pula, e é daí que vêm os vazamentos de memória, as condições de corrida e os resultados desatualizados. O AbortController é a resposta única do navegador para tudo isso.

Um controlador, um signal

A MDN define isso sem rodeios: a interface AbortController representa um objeto controlador que permite abortar uma ou mais requisições Web quando você quiser. O formato é sempre o mesmo. Você cria um controlador, lê o seu signal e entrega esse signal ao que está iniciando.

A propriedade signal retorna uma instância do objeto AbortSignal, que pode ser usada para se comunicar com uma operação assíncrona, ou para abortá-la. O controlador é o que você guarda; o signal é o que você entrega.

const controller = new AbortController();

const res = await fetch('/api/articles', { signal: controller.signal });

// em outro lugar, mais tarde:
controller.abort();

Repare na direção. O código que faz o trabalho nunca decide parar; ele apenas concorda em ouvir. É essa separação que permite a um único chamador cancelar muitas operações de uma só vez.

Os dois erros, e por que isso importa

Esta é a parte que vale ler com atenção, porque o mesmo cancelamento pode lançar duas coisas diferentes.

Quando você mesmo chama abort(), a promise de fetch() é rejeitada com uma DOMException chamada AbortError. Quando o signal veio de AbortSignal.timeout(), a promise de fetch() é rejeitada com uma DOMException TimeoutError em vez disso.

Então a verificação de sempre está incompleta:

try {
  const res = await fetch(url, { signal });
} catch (err) {
  if (err.name === 'AbortError') {
    // o usuário ou o nosso próprio código cancelou
  } else if (err.name === 'TimeoutError') {
    // o prazo expirou - uma situação completamente diferente
  } else {
    throw err; // uma falha real de rede ou de parsing
  }
}

Juntar os dois casos faz perder informação real. Um usuário que sai da página não é um problema; uma requisição que estourou o tempo provavelmente é, e vale a pena registrá-la ou tentar de novo. O código que só procura AbortError trata cada tempo limite como um não-evento silencioso.

Tempos limite sem encanamento

O padrão que a maioria ainda escreve combina um controlador com um setTimeout e um clearTimeout num bloco finally. Funciona, e já não é necessário.

AbortSignal.timeout() retorna uma instância de AbortSignal que abortará automaticamente após um tempo especificado. Sem controlador, sem temporizador para limpar, nada que possa vazar:

// Antes: três peças móveis
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
  const res = await fetch(url, { signal: controller.signal });
} finally {
  clearTimeout(timer);
}

// Agora: uma
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });

Fique de olho no nome do erro ao fazer essa troca. A versão antiga lançava AbortError; a nova lança TimeoutError, então qualquer bloco catch que você já tivesse vai precisar ser atualizado.

Um botão vermelho de parada de emergência em formato de cogumelo, desgastado, sobre uma caixa amarela, ao lado de um interruptor basculante preto, montado numa máquina
Um botão de parada de emergência desgastado numa máquina, ao lado do seu interruptor comum. Um comando inicia as coisas uma de cada vez; o outro para tudo de uma vez, que é exatamente a divisão de trabalho entre um signal e um controlador.

O truque que quase todo mundo deixa passar: os listeners de eventos

O AbortSignal não é um recurso do fetch. addEventListener aceita um signal entre suas opções e, quando esse signal aborta, o listener é removido para você. Isso substitui discretamente o código de limpeza mais tedioso do trabalho de front-end.

Compare com remover listeners na mão, o que exige guardar uma referência para cada função que você passou:

// Antes: você precisa guardar cada handler para removê-lo
function onScroll() { /* ... */ }
function onResize() { /* ... */ }
window.addEventListener('scroll', onScroll);
window.addEventListener('resize', onResize);

function teardown() {
  window.removeEventListener('scroll', onScroll);
  window.removeEventListener('resize', onResize);
}

Com um signal, os handlers podem voltar a ser inline, e uma única chamada remove todos eles:

const controller = new AbortController();
const { signal } = controller;

window.addEventListener('scroll', () => { /* ... */ }, { signal });
window.addEventListener('resize', () => { /* ... */ }, { signal });
document.addEventListener('keydown', () => { /* ... */ }, { signal });

// uma chamada remove os três
controller.abort();

O mesmo controlador também pode estar segurando o signal de um fetch em andamento. Desmontar uma view vira então um único abort() que cancela a requisição e desliga todos os listeners junto.

Combinar signals e ler o estado

AbortSignal.any() retorna um AbortSignal que aborta quando qualquer um dos signals de aborto fornecidos aborta. É assim que você consegue um prazo e um cancelamento manual na mesma operação:

const controller = new AbortController();

const signal = AbortSignal.any([
  controller.signal,          // o usuário apertou Cancelar
  AbortSignal.timeout(10000), // ou passaram dez segundos
]);

const res = await fetch(url, { signal });

Um signal também diz em que pé está. aborted é um booleano que indica se a requisição ou requisições com as quais o signal está se comunicando estão abortadas, e reason é um valor JavaScript que fornece o motivo do aborto, depois que o signal abortou. Passar o seu próprio motivo para abort() é como você distingue as causas mais tarde:

controller.abort(new Error('User navigated away'));

// mais tarde
if (signal.aborted) console.log(signal.reason.message);

Há também um evento abort, invocado quando as operações assíncronas com as quais o signal está se comunicando são abortadas, o que permite que o seu próprio código de longa duração coopere, e não apenas as APIs nativas.

Tornar a sua própria função abortável

Tudo o que aceita um signal entra no mesmo sistema. O padrão é verificar aborted antes de começar e ouvir o evento abort durante a execução:

function wait(ms, { signal } = {}) {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) return reject(signal.reason);
    const id = setTimeout(resolve, ms);
    signal?.addEventListener('abort', () => {
      clearTimeout(id);
      reject(signal.reason);
    }, { once: true });
  });
}

Agora o seu próprio utilitário pode ser cancelado pelo mesmo controlador que cancela os seus fetches, que é justamente o motivo de existir um mecanismo só em vez de vários.

Referência rápida

ObjetivoChamada
Cancelar manualmentecontroller.abort(), lança AbortError
Adicionar um prazoAbortSignal.timeout(ms), lança TimeoutError
Qualquer um dos doisAbortSignal.any([a, b])
Remover um listener automaticamenteaddEventListener(type, fn, { signal })
Verificar o estadosignal.aborted
Descobrir o motivosignal.reason
Reagir ao cancelamentoo evento abort

O AbortController é uma API pequena que resolve discretamente uma grande categoria de problemas de limpeza. Entregue um signal às suas requisições, aos seus listeners e aos seus próprios utilitários assíncronos, e depois pare todos eles com uma única chamada. Só lembre qual erro você está capturando, porque um aborto manual e um prazo expirado não são o mesmo evento.

Para o lado das requisições, veja o nosso guia da Fetch API em JavaScript.

Perguntas frequentes

Para que serve o AbortController?
A MDN o descreve como um objeto controlador que permite abortar uma ou mais requisições Web quando você quiser. Você cria um controlador, entrega o signal dele ao que iniciou e chama abort() quando quer parar. Não se limita ao fetch: qualquer coisa que aceite um AbortSignal pode ser cancelada da mesma forma.
Que erro um fetch abortado lança?
Depende de como ele foi abortado, e é aí que muita gente tropeça. Quando você mesmo chama AbortController.abort(), a promise do fetch é rejeitada com uma DOMException chamada AbortError. Quando o signal veio de AbortSignal.timeout(), a promise é rejeitada com uma DOMException TimeoutError em vez disso. O código que só verifica AbortError vai ignorar silenciosamente o caso do tempo limite.
Como adiciono um tempo limite a um fetch?
AbortSignal.timeout() retorna uma instância de AbortSignal que abortará automaticamente após um tempo especificado, então você passa AbortSignal.timeout(5000) como signal e não precisa de controlador, nem de setTimeout, nem de clearTimeout. Lembre-se de que ele rejeita com TimeoutError, e não com AbortError.
Um controlador pode cancelar várias coisas de uma vez?
Sim, e é justamente esse o sentido do nome. Um signal pode ser entregue a quantas operações você quiser, de modo que uma única chamada a abort() para todos os fetches e remove todos os listeners de eventos que o receberam. É a limpeza mais organizada disponível quando uma view é desmontada.
Posso combinar um tempo limite com um cancelamento manual?
AbortSignal.any() retorna um AbortSignal que aborta quando qualquer um dos signals de aborto fornecidos aborta. Passe a ele o signal do seu controlador e um signal de tempo limite, e a operação para no que acontecer primeiro.