Recuperación anulable

El problema original de GitHub para "Aborting a fetch" se abrió en 2015. Ahora, si resto 2015 de 2017 (el año actual), obtengo 2. Esto demuestra un error en las matemáticas, ya que 2015 fue, de hecho, hace “mucho tiempo”.

En 2015, comenzamos a explorar la cancelación de recuperaciones en curso y, después de 780 comentarios de GitHub, un par de intentos fallidos y 5 solicitudes de extracción, finalmente tenemos la recuperación abortable en los navegadores, el primero de los cuales es Firefox 57.

Actualización: No, me equivoqué. Edge 16 llegó con compatibilidad con la cancelación. ¡Felicitaciones al equipo de Edge!

Analizaré el historial más adelante, pero primero, la API:

El controlador y la maniobra de señal

Conoce AbortController y AbortSignal:

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

El controlador solo tiene un método:

controller.abort();

Cuando lo haces, se notifica el indicador:

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

El estándar DOM proporciona esta API, y esa es toda la API. Es deliberadamente genérico para que pueda ser usado por otros estándares web y bibliotecas de JavaScript.

Cómo abortar indicadores y recuperar datos

La recuperación puede tomar un AbortSignal. Por ejemplo, a continuación, se muestra cómo establecer un tiempo de espera de recuperación después de 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);
});

Cuando cancelas una recuperación, se cancelan la solicitud y la respuesta, por lo que también se cancela cualquier lectura del cuerpo de la respuesta (como response.text()).

Aquí tienes una demostración. En el momento de escribir este artículo, el único navegador que admite esta función es Firefox 57. Además, prepárate, porque nadie con habilidades de diseño participó en la creación de la demostración.

Como alternativa, el indicador se puede proporcionar a un objeto de solicitud y, luego, pasar a la recuperación:

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

fetch(request);

Esto funciona porque request.signal es un AbortSignal.

Cómo reaccionar a una recuperación abortada

Cuando abortas una operación asíncrona, la promesa se rechaza con un DOMException llamado 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);
    }
});

Por lo general, no es conveniente mostrar un mensaje de error si el usuario canceló la operación, ya que no es un “error” si realizas correctamente lo que solicitó el usuario. Para evitar esto, usa una sentencia if, como la anterior, para controlar los errores de aborto de forma específica.

Este es un ejemplo que le brinda al usuario un botón para cargar contenido y otro para abortar. Si la recuperación falla, se muestra un error, a menos que sea un error de aborto:

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

Aquí tienes una demostración. En el momento de escribir este artículo, los únicos navegadores que admiten esta función son Edge 16 y Firefox 57.

Un indicador, muchas recuperaciones

Se puede usar un solo indicador para abortar muchas recuperaciones a la 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);
}

En el ejemplo anterior, se usa el mismo indicador para la recuperación inicial y para las recuperaciones de capítulos en paralelo. A continuación, te mostramos cómo usar fetchStory:

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

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

En este caso, llamar a controller.abort() abortará las recuperaciones que estén en curso.

El futuro

Otros navegadores

Edge hizo un gran trabajo para lanzar esto primero, y Firefox está muy cerca. Sus ingenieros implementaron desde el paquete de pruebas mientras se escribía la especificación. Para otros navegadores, consulta los siguientes tickets:

En un service worker

Necesito terminar la especificación de las partes del servicio de trabajo, pero este es el plan:

Como mencioné antes, cada objeto Request tiene una propiedad signal. Dentro de un service worker, fetchEvent.request.signal indicará la cancelación si la página ya no está interesada en la respuesta. Como resultado, un código como este simplemente funciona:

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

Si la página aborta la recuperación, fetchEvent.request.signal indica que se abortó, por lo que la recuperación dentro del service worker también se aborta.

Si recuperas algo que no sea event.request, deberás pasar el indicador a tus recuperaciones 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 })
    );
    }
});

Sigue la especificación para hacer un seguimiento de esto. Agregaré vínculos a los tickets del navegador una vez que esté todo listo para la implementación.

El historial

Sí… llevó mucho tiempo que esta API relativamente simple se uniera. Esto se debe a los siguientes motivos:

Discrepancias en la API

Como puedes ver, el debate de GitHub es bastante largo. Hay muchos matices en esa conversación (y algunos que no tienen matices), pero el desacuerdo clave es que un grupo quería que el método abort existiera en el objeto que muestra fetch(), mientras que el otro deseaba una separación entre obtener la respuesta y afectarla.

Estos requisitos son incompatibles, por lo que un grupo no iba a obtener lo que quería. Si es así, te pedimos disculpas. Si te sirve de consuelo, yo también estaba en ese grupo. Sin embargo, ver que AbortSignal se ajusta a los requisitos de otras APIs hace que parezca la opción correcta. Además, permitir que las promesas encadenadas se puedan abortar sería muy complicado, si no imposible.

Si deseas mostrar un objeto que proporcione una respuesta, pero que también pueda abortarse, puedes crear un wrapper simple:

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

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

False comienza en TC39

Se hizo un esfuerzo para que una acción cancelada se distinga de un error. Esto incluía un tercer estado de promesa para indicar “cancelado” y una sintaxis nueva para controlar la cancelación en el código síncrono y asíncrono:

Qué no debes hacer

No es un código real, ya que se retiró la propuesta

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

Lo más común que se hace cuando se cancela una acción es no hacer nada. La propuesta anterior separó la cancelación de los errores para que no fuera necesario controlar los errores de aborto de forma específica. catch cancel te permite conocer las acciones canceladas, pero la mayoría de las veces no es necesario.

Llegó a la etapa 1 en TC39, pero no se alcanzó un consenso y se retiró la propuesta.

Nuestra propuesta alternativa, AbortController, no requería ninguna sintaxis nueva, por lo que no tenía sentido especificarla en TC39. Ya estaba todo lo que necesitábamos de JavaScript, por lo que definimos las interfaces dentro de la plataforma web, específicamente el estándar DOM. Una vez que tomamos esa decisión, el resto se unió con relativa rapidez.

Gran cambio en las especificaciones

XMLHttpRequest se puede abortar desde hace años, pero la especificación era bastante vaga. No estaba claro en qué puntos se podía evitar o finalizar la actividad de red subyacente, ni qué sucedía si había una condición de carrera entre la llamada a abort() y la finalización de la recuperación.

Queríamos hacerlo bien esta vez, pero eso generó un gran cambio en las especificaciones que requirió mucha revisión (es mi culpa, y muchas gracias a Anne van Kesteren y Domenic Denicola por ayudarme con eso) y un conjunto decente de pruebas.

Pero ya estamos aquí. Tenemos una nueva primitiva web para abortar acciones asíncronas, y se pueden controlar varias recuperaciones a la vez. Más adelante, veremos cómo habilitar los cambios de prioridad durante la vida útil de una recuperación y una API de nivel superior para observar el progreso de la recuperación.