El blog de QA en español de Redsauce

Javascript asíncrono y promesas

Artículo de Kenan Rhoton

Javascript asíncrono y promesas

Si has formado parte de algún tipo de proyecto de tamaño medio o grande usando JavaScript en una empresa de software, habrás tenido que lidiar con el Javascript asíncrono de alguna forma u otra.


La mayoría de nosotros lo hemos pasado mal al principio, estamos acostumbrados a escribir código que funciona mayoritariamente de forma secuencial, como si escribiéramos

doSomething();
doOtherThing();
thingItOn();

Tendemos a asumir que el orden en que se llaman las funciones es el orden en que se ejecutarán, resolverán y devolverán. El Javascript asíncrono no funciona así.


Si todas estas funciones son asíncronas, significa que no podemos predecir el orden en que se ejecutarán o resolverán. Tal vez la primera se ejecute 10 segundos después de finalizar el tiempo de espera de la petición. Tal vez la segunda tarde sólo 5 millisegundos en leer algo del disco. Y de este modo, en el mundo del desarrollo de software basado en el manejo de un ordenador para que haga exactamente lo que queremos que haga, hemos instaurado el caos.


¿O quizás no?

Eventos, el primer acercamiento a Javascript asíncrono

Para hacer un primer acercamiento al mundo del Javascript asíncrono, debemos remontarnos a la historia de las operaciones asíncronas, encontraremos sistemas de eventos en sus inicios. O, más concretamente, sistemas de interrupción.


Un sistema de interrupción es, básicamente, un conjunto de funciones al que tu sistema operativo puede llamar en cualquier momento en respuesta a algo, interrumpiendo la ejecución actual.


Algunos ejemplos serían las funciones que gestionan la pulsación de teclas, el movimiento del ratón, el reloj o incluso una nueva conexión del puerto USB.

Obviamente, es imposible que el programador sepa de antemano cuándo se activarán estas funciones.


Vemos algo muy parecido en JavaScript. El ejemplo obvio son los atributos onclick, onchange, etc. que pueden tener muchos elementos. El menos obvio es cómo ciertas APIs (como XMLHttpRequest) registran listeners para eventos para que el desarrollador pueda acceder a los datos.

let xhr = new XMLHttpRequest();
xhr.open("POST", '/server', true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xhr.onreadystatechange = function() {
if (this.readyState === XMLHttpRequest.DONE && this.status === 200) {
console.log(xhr.responseText);
}
}
xhr.send("foo=bar&lorem=ipsum");

Aquí asignamos una función a onreadystatechange que se ejecutará cada vez que el estado ready cambie (como su nombre indica). Si esto te parece un poco feo, estoy de acuerdo, por eso...

Callbacks en Javascript asíncrono

Los callbacks no son una idea nueva. De hecho, son literalmente anteriores a la computación. Esta idea tan antigua de pasar una función como parámetro es en realidad una API más limpia para el sistema de eventos anterior, simplemente pasando una función para que se ejecute "cuando termines de hacer lo que estás haciendo".


El mismo concepto para la petición POST anterior podría representarse de la siguiente manera cuando se utiliza una API callback:

post({url: "/server", params: {foo: "bar", lorem: "ipsum"}}, function(result, err) {
if (err !== null) {
console.log(err);
} else {
console.log(result);
}
});

Esto es mucho más usable. Quizá no tan bonito como podría ser, pero es mucho más fácil de entender: la función toma un objeto como primer parámetro con todas las opciones y una llamada de retorno como segundo parámetro que se lanzará cuando la petición haya terminado, ya sea con éxito (con un error nulo) o sin éxito (con algún mensaje de error).


Los callbacks han sido ampliamente adoptados. Su secreto ha sido usar únicamente eventos por debajo suyo, mostrándolos al programador de una manera mucho más limpia.


Así que podemos pensar en los callbacks como el registro de un único evento que se disparará cada vez que ocurra algo con nuestra petición y llamará a la función que se le haya pasado, lo que nos hace perder algo de granularidad fina, pero siendo realistas casi nunca nos preocupamos por tal granularidad.


Los callbacks, sin embargo, tienen un problema. Bienvenido al infierno de las callbacks:

post({url: "/login", params: {user: "yo", pass: "dude"}}, function (result, err) {
if (err !== null) {
console.log(err);
} else {
get({url: "/tasks", params: {auth: result.token}}, function(result, err) {
if (err !== null) {
console.log(err);
} else {
result.tasks.filter((t) => t.tags.includes("sports"))
.forEach((t) => patch({url: `/task/${t.id}`, params: {complete: true}}, function (result, err) {
// At this point you can see
// this is getting silly
});
}
});
}
});

Sí, anidar callbacks es un fastidio, especialmente cuando no necesitas decir con tanto detalle dónde estaba el fallo. Tienes un montón de indentaciones que son difíciles de seguir. Generalmente, esto se solucionaba definiendo funciones con nombre en otro lugar y pasándolas como las devoluciones de llamada, pero ahora tu lógica está por todas partes y tienes un montón de funciones que se parecen a esto:

function getTasksAfterLoggingIn(result, err) {
if (err !== null) {
console.log(err);
} else {
get({url: "/tasks", params: {auth: result.token}}, completeSportsTasksAfterTaskList);
}
}

Buena suerte con el seguimiento de todas las funciones que funcionan en un orden específico y no en otro. Así que para los problemas complejos, las callbacks parecen no funcionar tan bien.


Afortunadamente, aparecieron las promesas.

Promesas en Javascript asíncrono

Las promesas de JavaScript asíncrono tienen una premisa simple: ¿qué pasaría si pudiéramos tener todos los beneficios de las callbacks y ninguno de sus inconvenientes? Un buen argumento de marketing, ¿no?. Veamos primero cómo se vería el ejemplo anterior con una API Promise:

post({url: "/login", params: {user: "yo", pass: "dude"}})
.then(function (result) {
return get({url: "/tasks", params: {auth: result.token}})
}).then(function(result) {
return Promise.all(result.tasks.filter((t) => t.tags.includes("sports"))
.map((t) => patch({url: `/task/${t.id}`, params: {complete: true}})))
}).catch(function(err) {
console.log(err);
});

¡Ahora hemos avanzado! Hay una llamada a Promise.all para múltiples llamadas patch, pero aparte de eso, queda bastante limpio. ¿Cómo funciona esto? Primero tenemos que entender qué es una promesa y cómo se hace:

new Promise(function (resolve, reject) {
setTimeout(function () {
if (getColor(user.profile) === "orange") {
resolve("Yes! Color is orange!");
} else {
reject("Infidel! Orange is the best color!");
}
}, 1000);
});

Una promesa es en realidad una clase de JavaScript, que a su vez es un objeto con algo de encanto. Lo interesante es lo que hace esta clase por debajo suyo: proporciona una API para gestionar las acciones a realizar después de que se complete una operación asíncrona, ya sea con éxito o sin éxito.


El funcionamiento es muy sencillo: el objeto se crea con un parámetro de función que toma dos argumentos, resolve y reject. Estos parámetros son a su vez funciones que deben ser llamadas en caso de éxito o fracaso respectivamente. El propio objeto expone dos métodos: then y catch.


El método then toma un argumento de la función y añade esa función como un listener del evento que se activa cuando se llama a resolve, que tomará los argumentos que se le pasen a resolve. Lo mismo ocurre con el método catch y reject. Sencillo, ¿verdad? Pero hay algunos detalles que lo hacen especial:

  1. Devolver una promesa desde dentro de then hace que then devuelva una nueva promesa que combina cualquiera de las Promesas anteriores, permitiéndonos "encadenarlas".

  2. Se llama a catch si se rechaza cualquier promesa de la cadena, lo que simplifica el manejo de errores.

  3. Al igual que then, catch también puede devolver una promesa para iniciar una nueva cadena de promesas.

Esto permite todo tipo de patrones que facilitan el uso de operaciones en javascript asíncrono:

asyncOp1()
.then((result) => asyncOp2(result))
.then((result) => asyncOp3(result))
.catch((err) => logFail(err))
.then((result) => alwaysDo());

Y esto es sólo la punta del iceberg. Puedes utilizar herramientas como Promise.all que toma un conjunto de promesas como argumento y resuelve sólo si todas las promesas se resuelven y rechaza si cualquiera de ellas es rechazada.


Sin embargo, como programadores siempre queremos más. A pesar de la potencia de esta API, sigue requiriendo que se piense deliberadamente en las operaciones asíncronas. ¿No sería increíble si pudiéramos fingir que las operaciones asíncronas fueran... síncronas?

Async / Await en Javascript asíncrono

Puede que hayas oído hablar de la sintaxis async/await en la vida cotidiana. Es principalmente azúcar sintáctico para las promesas, pero puede hacer maravillas para la legibilidad en los casos correctos, permitiéndote usar promesas de una manera casi sincrónica. Por ejemplo:

async function getSportsTasks() {
const tasks = await get({url: "/tasks", params: {token: SECRET}});
return tasks.filter((t) => t.tags.includes("sports"))
}

es prácticamente equivalente a:

function getSportsTasks() {
return new Promise(function(resolve, reject) {
get({url: "/tasks", params: {token: SECRET}})
.then((tasks) => resolve(
tasks.filter((t) => t.tags.includes("sports))))
.catch((err) => throw new Error())
});
}

Así que async/await principalmente consigue hacer tu código más legible, aunque una vez que necesitas gestionar los estados de error de forma algo específica se vuelve incómodo, ya que en lugar de tratar con un reject que se maneja en un catch, tienes que rodear los awaits con un bloque try-catch, así:

async function getSportsTasks() {
let tasks;
try {
tasks = await get({url: "/tasks", params: {token: SECRET}});
} catch (err) {
console.log(err);
return []
}
return tasks.filter((t) => t.tags.includes("sports"))
}

La regla importante a recordar con async/await es esta: sólo se permite usar await directamente dentro de una función async, lo que significa que esto es erróneo:

async function doSomething() {
const subjects = await getSubjects();
subjects.forEach((subject) => {
// this next line is illegal
const students = await getStudents(subject);
console.log(students);
)};
}

¿Javascript asíncrono? Todo son eventos

Así que recuerda:

  • Async/await es sólo azúcar sintáctico para las Promesas

  • Las promesas son sólo una API mejorada para los retornos de llamada

  • Las retornos de llamada son sólo wrappers sobre los eventos

  • En realidad, todo son eventos.

image


Si te ha gustado el post, no te pierdas el ebook gratuito de Redsauce. Descubre estos y muchos otros consejos para transformar tu desarrollo:


image

Sobre nosotros

Has llegado al blog de Redsauce, un equipo de expertos en QA y desarrollo de software. Aquí hablaremos sobre testing ágil, automatización, programación, ciberseguridad… ¡Bienvenido!