Recuperación anulable

Jake Archibald
Jake Archibald

El problema original de GitHub relacionado con “Anular una recuperación” se abrió en 2015. Ahora, si quito el 2015 de 2017 (el año en curso), obtendré 2. Esto demuestra un error en las matemáticas porque el año 2015 fue "para siempre".

En 2015 comenzamos a explorar la anulación de recuperaciones en curso y, después de 780 comentarios de GitHub, un par de inicios falsos y 5 solicitudes de extracción, finalmente tuvimos una recuperación anulable en los navegadores. El primero fue Firefox 57.

Actualización: Noooope, estaba equivocado. Edge 16 llegó primero con la compatibilidad para anular la suscripción ¡Felicitaciones al equipo de Edge!

Luego veremos la historia, pero primero, la API:

Maniobra de la señal y el control

Conoce a AbortController y AbortSignal:

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

El control solo tiene un método:

controller.abort();

Cuando lo hagas, notificará al indicador:

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

El estándar del DOM proporciona esta API, y esa es toda la API. Es intencionalmente genérico, por lo que otros estándares web y bibliotecas de JavaScript pueden usarlo.

Anular indicadores y recuperar

La recuperación puede tomar un AbortSignal. Por ejemplo, a continuación, se muestra cómo realizar 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 anulas una recuperación, se anulan la solicitud y la respuesta, por lo que también se anula cualquier lectura del cuerpo de la respuesta (como response.text()).

Esta es una demostración: En el momento de la redacción, el único navegador que admite esta función es Firefox 57. Además, prepárate: 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, se puede pasar para 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 anulada

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

Es posible que no quieras mostrar un mensaje de error si el usuario anuló la operación, ya que no se trata de un "error" si haces correctamente lo que el usuario solicitó. A fin de evitar esto, usa una sentencia if, como la anterior, para controlar específicamente los errores de anulación.

A continuación, se muestra un ejemplo que le proporciona al usuario un botón para cargar contenido y un botón para anular. Si se produce un error de recuperación, se muestra un error, a menos que sea un error de anulación:

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

Esta es una demostración: Al momento de escribir, los únicos navegadores que lo admiten son Edge 16 y Firefox 57.

Una señal, muchas recuperaciones

Se puede usar un solo indicador para anular 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 la misma señal para la recuperación inicial y para las recuperaciones de capítulos paralelos. 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, si llamas a controller.abort(), se anularán las recuperaciones en curso.

El futuro

Otros navegadores

Edge hizo un gran trabajo al lanzar esta versión primero, y Firefox está en su camino. Sus ingenieros realizaron la implementación desde el paquete de pruebas mientras se escribía la especificación. Para otros navegadores, estos son los tickets que debes seguir:

En un service worker

Necesito finalizar las especificaciones de las partes del service worker, pero este es el plan:

Como mencioné antes, cada objeto Request tiene una propiedad signal. En un service worker, fetchEvent.request.signal indicará que la página está anulada si la respuesta ya no está interesada. Como resultado, un código como el siguiente simplemente funciona:

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

Si la página anula la recuperación, los indicadores fetchEvent.request.signal anulan la recuperación, por lo que también se anula la recuperación dentro del service worker.

Si recuperas elementos que no sean event.request, deberás pasar el indicador a las 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 las especificaciones para realizar un seguimiento de esto: agregaré vínculos a los tickets del navegador una vez que esté lista para la implementación.

La historia

Sí... Esta API relativamente simple tardó mucho tiempo en unirse. Esto se debe a los siguientes motivos:

Discrepancia en la API

Como puedes ver, la conversación en GitHub es bastante larga. Hay muchos matices en ese subproceso (y algunos vacíos), 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 quería una separación entre obtener la respuesta y afectarla.

Estos requisitos son incompatibles, por lo que un grupo no obtendría lo que deseaba. Si eres tú, lo siento. Si te hace sentir mejor, también estuve en ese grupo. Sin embargo, ver que AbortSignal se ajusta a los requisitos de otras APIs hace que parezca la elección correcta. Además, permitir que las promesas encadenadas se anulen sería muy complicado, sino imposible.

Si quisieras mostrar un objeto que proporcione una respuesta, pero que también puedas anularlo, podrías 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 })
    };
}

Inicios falsos en TC39

Se hizo un esfuerzo para diferenciar la acción cancelada de un error. Se incluyó un tercer estado de promesa para significar "cancelada" y una sintaxis nueva a fin de controlar la cancelación en el código síncrono y asíncrono:

Qué no debes hacer

Código no real: 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 debe hacer cuando se cancela una acción no es nada. La propuesta anterior separaba la cancelación de los errores, por lo que no tuviste que controlar los errores de anulación de forma específica. catch cancel te permite obtener información sobre las acciones canceladas, pero la mayoría de las veces no es necesario que lo hagas.

Esto llegó a la etapa 1 en TC39, pero no se llegó a un consenso, por lo que se retiró la propuesta.

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

Gran cambio de especificaciones

XMLHttpRequest ha estado anulada durante años, pero la especificación era bastante imprecisa. 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 de especificación que requería muchas revisiones (es mi culpa, y muchas gracias a Anne van Kesteren y Domenic Denicola por arrastrarme) y un conjunto aceptable de pruebas.

Pero ahora estamos aquí. Tenemos una nueva primitiva web para anular 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 de una recuperación y una API de nivel superior para observar el progreso de la recuperación.