Promesas

<< Plantillas HTML API Fetch (AJAX) >>

¿Qué es una promesa?

Una promesa es un objeto de la clase Promise, que se crea para resolver una acción asíncrona, es decir, una operación que puede tardar un tiempo considerable y no queremos bloquear el hilo principal del programa mientras se procesa. El constructor recibe una función con dos parámetros, resolve y reject. Estos parámetros a su vez son funciones.

resolve se llama cuando la acción acaba correctamente devolviendo (opcionalmente) algún dato, mientras que reject se usa cuando se produce un error. Una promesa acaba cuando se llama a una de estas dos funciones.

Para procesar el resultado de una promesa, tenemos que suscribirnos a ella llamando al método then, pasándole una función que manejará el resultado. Esta función se ejecutará cuando se resuelva la promesa correctamente (sin errores).

function getPromise() {
    return new Promise((resolve, reject) => {
        console.log("Promesa llamada...");
        setTimeout(function() {
            console.log("Resolviendo la promesa...");
            resolve(); // Promesa resuelta!.
        }, 3000); // Esperamos 3 segundos y acabamos la promesa
    });
}

// Imprimirá el mensaje pasados 3 segundos (la promesa termina)
getPromise().then(() => console.log("La promesa ha acabado!"));

console.log("El programa continúa. No espera que termine la promesa (operación asíncrona)");

La llamada al método then a su vez devuelve otra promesa. Es por ello que podremos concatenar varias llamadas a dicho método. El primero recibe el resultado de la promesa original, mientras que los restantes reciben como resultado lo que devuelva la función del método then anterior. Pueden devolver cualquier tipo de valor, incluyendo otra promesa. El siguiente then recibiría el resultado de dicha promesa una vez se resuelva.

function getPromise() {
    return new Promise((resolve, reject) => {
        setTimeout(function() {
            resolve(1);
        }, 3000); // Después de 3 segundos, termina la promesa
    });
}

getPromise().then(num => {
    console.log(num); // Imprime 1
    return num + 1;
}).then(num => {
    console.log(num); // Imprime 2
}).catch(error => {
    // Si se produce un error en cualquier parte de la cadena de promesas... (o promesa original rechazada)
});

Iremos directamente al bloque catch si lanzamos un error dentro de una sección then. O si la promesa original se rechaza con reject.

getProducts().then(products => {
    if(products.length === 0) {
        throw 'No hay productos!'
    }
    return products.filter(p => p.stock > 0);
}).then(prodsStock => {
    // Imprime los productos con stock en la página
}).catch(error => {
    console.error(error);
});

Opcionalmente, por legibilidad o reutilización de código, podemos separar las funciones del bloque then (o catch) en lugar de usar funciones anónimas o arrow functions.

function filterStock(products) {
    if(products.length === 0) {
        throw 'No hay productos!'
    }
    return products.filter(p => p.stock > 0);
}

function showProducts(prodsStock) {
    // Imprime el producto en la página
}

function showError(error) {
    console.error(error);
}

getProducts()
    .then(filterStock)
    .then(showProducts)
    .catch(showError);

Tanto el método then, como el método catch, pueden devolver un valor directamente u otra promesa. Esto significa que podemos usar el método catch para recuperarnos de un error, obteniendo datos de otro sitio alternativo, por ejemplo. Lo que devuelve un método catch se recogería en un método then que se concatene posteriormente.

Hay otro método llamado finally, que se puede añadir al final y se ejecuta siempre, haya error o no. Se puede usar por ejemplo para ocultar una animación de carga de datos.

promise.then(...).catch(...).finally(() => /*Ocultamos animación*/);

Promise.resolve y Promise.reject

Imaginemos por ejemplo. que tenemos una función de la cual se espera que devuelva los datos dentro de una promesa, llamando a un servicio web por ejemplo. Pero puede ser que tengamos los datos ya cargados previamente y se pueda ahorrar dicha llamada.

Como lo esperable es que devuelva siempre una promesa, para matener la consistencia de tipos, puede devolver inmediatamente dicho valor que ya tenemos almacenado encapsulado en una promesa con Promise.resolve, o una promesa con error usando Promise.reject. De esta forma nos ahorramos crear la promesa manualmente llamando al constructor (solo en el caso de promesas que resuelvan inmediatamente).

function getInstantPromise() {
    return Promise.resolve(25); // Equivale a: return new Promise((resolve, reject) => resolve(25));
}

getInstantPromise.then(val => console.log(val)); // Imprime 25 (inmediatamente)
function getRejectedPromise() {
    return Promise.reject('Error'); // Equivale a: new Promise((resolve, reject) => reject('Error'));
}

getRejectedPromise.catch(error => console.log(error)); // Imprime "Error" (inmediatamente)

Otros métodos de Promise

Promise.all

A veces tenemos que crear varias promesas en paralelo (varias llamadas a un servidor para obtener diferentes datos). Si necesitamos esperar para hacer algo una vez todas terminen en lugar de procesarlas por separado, podemos agruparlas en un array y utilizar el método Promise.all. Una vez haya terminado la última, se ejecutará el método then asociado que recibirá un array con los resultados de cada promesa.

function make3AjaxCalls() {
    let p1 = Http.get(...); // Promesa 1
    let p2 = Http.get(...); // Promesa 2
    let p3 = Http.get(...); // Promesa 3
    return Promise.all([p1, p2, p3]);
}

make3AjaxCalls.then(results => /* results -> array con los 3 valores devueltos */ );

Si alguna de estas promesas es rechazada (error), se ignorará inmediatamente a las promesas pendientes, ejecutando directamente el método catch (si lo hubiera).

Promise.allSettled

Parecido al método anterior. Si queremos esperar a que todas las promesas acaben, aunque una o varias devuelvan un error, tenemos el método Promise.allSettled. En este caso, el método then recibirá un array de objetos (1 por promesa). Cada objeto tendrá 3 posibles propiedades:

  • status: Contendrá los valores "fulfilled" (todo ha ido bien), o "rejected" (error).
  • value: Cuando la promesa tenga el estado "fulfilled", contendrá el valor devuelto.
  • reason: Cuando la promesa tenga el estado "rejected", contendrá el error.
function make3AjaxCalls() {
    let p1 = Http.get(...);
    let p2 = Http.get(...);
    let p3 = Http.get(...);
    return Promise.allSettled([p1, p2, p3]);
}

make3AjaxCalls.then(results => results.forEach(result => {
    if(result.status === 'fulfilled') { // Si todo ha ido bien
        console.log(result.value);
    } else { // result.status === 'rejected' -> Ha habido un error
        console.log(result.reason);
    }
}));

Promise.race

Otro método interesante es Promise.race. En este caso, devuelve el resultado de la primera promesa que termina, ignorando el resto. Útil, por ejemplo, si podemos obtener un dato de varias fuentes diferentes, y queremos la más rápida.

function make3AjaxCalls() {
    let p1 = Http.get(...);
    let p2 = Http.get(...);
    let p3 = Http.get(...);
    return Promise.race([p1, p2, p3]);
}

make3AjaxCalls.then(result => /* Valor de la promesa más rápida */);

Async / Await

Las instrucciones async y await fueron introducidas en la versión ES2017. Se utilizan para que la sintaxis al trabajar con promesas sea más amigable.

await se utiliza para esperar que una promesa termine y nos devuelva su valor. El problema es que eso bloquea la ejecución del siguiente código hasta que la promesa acabe. Esto implicaría bloquear el programa durante un tiempo indeterminado. Por ello, sólo podemos usarlas dentro de funciones de tipo async. Estas funciones se ejecutan de forma asíncrona al resto del código, lo que impide que bloqueen el hilo principal.

Una función async siempre nos devuelve una promesa, que contendrá el valor devuelto por la instrucción return. Si no hay instrucción return, también devuelve una promesa, aunque vacía (Promise<void>).

async getProducts() { // Devuelve una promesa (async)
    const resp = await Http.get(`${SERVER}/products`); // Esperamos la respuesta del servidor (Promesa)
    return resp.products;
}

// Lo mismo sin async/await
getProducts() {
  return Http.get(`${SERVER}/products`).then((response) => {
        return response.products;
  });
}

Import dinámico con promesas

Además de la instrucción import, se puede importar un módulo con la función import(). Esta función devuelve una promesa que se resuelve una vez que el módulo ha sido descargado y procesado, y nos devuelve un objeto que contiene todo lo que el módulo exporta. Esto nos permite hacer imports dinámicos, donde el nombre del módulo puede estar en una variable o simplemente, no cargar un módulo hasta que realmente lo necesitemos por cuestiones de eficiencia y rendimiento.

const lang = /^es\b/.test(navigator.language) ? 'es' : 'en';
import(`./message-${lang}.js`).then(m => console.log(m.message));

Otro uso muy común es el de no cargar un módulo hasta que se necesite, de tal forma que la carga inicial del programa es más rápida si evitamos la carga de módulos (librerías) pesadas como las de tratamiento de imágenes, etc. hasta que no se necesiten.

En resumen: Mientras que la carga de módulos con import es estática, y debe procesar el módulo y cargarlo antes de continuar (si no se ha hecho previamente), la carga con la función import() es asíncrona y se puede hacer en cualquier momento del código. 

 top-level await

Desde ES2020 tenemos top-level await, lo que significa que no tenemos que crear funciones async para poder utilizar await en el código principal. Pero debemos recordar que que la ejecución del resto del código se bloqueará hasta que se resuelva la instrucción await. Esto solo debemos utilizarlo si el resto del código en el módulo actual es totalmente dependiente del resultado de la promesa (en este caso del módulo que cargamos). En caso contrario, o en caso de duda, no lo utilices.

Utilizarlo para cargar módulos de los cuales depende el resto del código del módulo actual, y que no podemos cargar con import (el nombre depende de una variable, o la carga de uno u otro módulo dependen de una condición), está justificado.

const lang = /^es\b/.test(navigator.language) ? 'es' : 'en';
const m = await import(`./message-${lang}.js`)
console.log(m.message);

<< Plantillas HTML API Fetch (AJAX) >>