🔃 Entendiendo la asincronÃa en JavaScript
single-thread, pila de llamadas, callbacks y promesas son conceptos clave para entender y producir código asÃncrono.
Introducción
La asincronÃa en JavaScript es un concepto clave para crear aplicaciones web donde se manejan operaciones que llevan tiempo en concluir, como acceso a bases de datos y consultas a API.
Primero definamos qué es sincronÃa: si investigamos sobre la etimologÃa de la palabra sincronÃa (del griego antiguo syn, ‘con’, ‘juntamente’, ‘a la vez’; y Crono, ‘tiempo’; ) alude a una coincidencia en el tiempo o a la simultaneidad de hechos o fenómenos. Lo contrario serÃa una discrepancia, una diferencia en el tiempo de dos hechos.
En programación, la asincronÃa significa que una parte del código se ejecute en otro tiempo de ejecución (runtime), en otro hilo.
En este artÃculo, exploraremos conceptos fundamentales para entender cómo JavaScript maneja el código asÃncrono, como la pila de llamadas (call stack), el hecho de que JavaScript sea de un solo hilo (single-thread), los callbacks, las promesas y cómo escribir código asÃncrono de manera más legible y fácil de mantener utilizando async/await.
Es recomendable que pruebes y pongas en práctica los ejemplos que se presentan por ti mismo. Además, de manera complementaria, para visualizar las operaciones asÃncronas, puedes ver este video.
La pila de llamadas
Un intérprete, como el utilizado en un navegador web para JavaScript, utiliza una pila de llamadas (call stack) para realizar un seguimiento del orden en que se llaman las funciones. Esta estructura de datos sigue el principio de LIFO (Last In, First Out) o "último en entrar, primero en salir". La pila de llamadas es el lugar donde se almacenan temporalmente las funciones que se están ejecutando en ese momento.
Aquà hay algunas caracterÃsticas importantes de la pila de llamadas:
Cuando se llama a una función en JavaScript, el intérprete la agrega a la pila de llamadas y la ejecuta.
La función en la parte superior de la pila es la que se está ejecutando actualmente.
Las funciones anidadas también se agregan en la parte superior de la pila.
Cuando una función devuelve un valor, el intérprete la elimina de la pila y reanuda la ejecución desde donde se detuvo.
Para entender este concepto es de gran ayuda visualizarlo, por lo que voy a ir incluyendo unos gifs que hacen referencia a estos conceptos.
Hilo único y WebAPI
JavaScript es de un solo hilo, lo que significa que cuando se ejecuta un programa, solo puede ejecutar un comando a la vez en un único hilo de ejecución (que grosso modo es un contexto de ejecución). En la práctica, esto significa que si hay una tarea que tarda mucho en completarse, el hilo de ejecución se bloqueará hasta que se complete dicha tarea.
Para solucionar este problema, se utilizan técnicas como la programación asÃncrona y las promesas, con el objetivo de hacer que el código sea más eficiente y evitar que las operaciones que pueden tardar mucho en completarse bloqueen el flujo del código en JavaScript, delegando parte del código a otro hilo de ejecución (otro contexto) que nos proporciona el navegador.
Cuando realizamos una petición a una API o una base de datos, por ejemplo, el comando puede tardar un tiempo en obtener la información necesaria. ¿Significa esto que el código se bloqueará hasta que la información esté disponible? ¡No necesariamente! Para evitar que el código se bloquee y quede a la espera de la respuesta, utilizamos la asincronÃa para delegar esta tarea al navegador.
Los navegadores disponen de WebAPI's, que son capaces de manejar tareas en segundo plano, como las peticiones o las funciones setTimeout
. El intérprete de JavaScript reconoce estas WebAPI's y les pasa las funciones asÃncronas para que el navegador las gestione en su propio hilo. Una vez que el navegador completa la tarea, devuelve el resultado a la pila de llamadas en forma de callbacks que se añaden a una cola. En este contexto,un callback es simplemente una función que se ejecutará más adelante y no de forma inmediata.
Una analogÃa útil es pensar que mandamos nuestro perro a buscar un objeto que necesitamos para completar una tarea. En lugar de quedarnos esperando a que el perro regrese, delegamos a otra persona la responsabilidad de esperar al perro y continuamos realizando otras partes de nuestra tarea. Cuando el perro regresa con el objeto, esa persona se encarga de entregárnoslo.
De esta manera, la asincronÃa y el uso de WebAPI's permiten que JavaScript maneje tareas largas sin bloquear el hilo de ejecución principal, lo que nos brinda un código más eficiente y una mejor experiencia de usuario.
Evitando el infierno de callbacks
Cuando nos enfrentamos a situaciones en las que necesitamos ejecutar múltiples tareas asÃncronas de forma anidada, es decir, donde la ejecución del siguiente comando asÃncrono depende de la finalización del anterior, corremos el riesgo de caer en el llamado "infierno de los callbacks".
Imaginemos que realizamos una consulta a una base de datos. Debemos anticipar que necesitamos tomar determinadas acciones en función de los posibles resultados:
Éxito: la consulta se completa correctamente y obtenemos la información deseada.
Fallo: la consulta falla o el servidor no está disponible, por lo que no podemos obtener la información.
Para manejar estas situaciones, debemos utilizar funciones callback correspondientes a cada resultado. Sin embargo, esto puede llevar a un código difÃcil de leer y mantener, especialmente cuando hay múltiples consultas anidadas.
// Imaginemos que este es un método integrado, que acepta una función success y failure como callback.
const fetchMethod= (url,success,failure) => {
/* // Establece un tiempo para después brindarlo a setTimeout */
const delay = Math.floor(Math.random() * 2500 ) + 250;
// Simula una consulta asÃncrona, y devuelve una función callback u otra (failure o succes)
setTimeout(() => {
if (delay > 2000 ){
failure ('La petición ha expirado.');
} else {
success(`Datos desde ${url}`);
}
},delay)
}
// La llamada consecutiva al método usando callbacks serÃa la siguiente:
fetchMethod('books.com',(datos) => {
console.log("Ok",datos)
fetchMethod('books.com/page1', (datos) => {
console.log("Ok (2nd)",datos);
fetchMethod('books.com/page2',(datos) => {
console.log("Ok (3rd)",datos);
}, (error) => {
console.log("Error",error)
})
}, (error) => {
console.log("Error",error);
})
}, (error)=> {
console.log("Error",error);
})
Para evitar este problema, se pueden usar técnicas como las Promesas y async/await para escribir código asÃncrono de manera mucho más clara y legible.
Promesas: Simplificando el código asÃncrono
Una promesa es un objeto que representa la finalización o el fracaso de una operación asÃncrona junto con su resultado. Las promesas nos permiten manejar de manera elegante y fácil de leer las operaciones asÃncronas.
En lugar de utilizar callbacks anidados, las promesas nos permiten escribir un código más legible. Una promesa puede tener tres estados posibles:
Pendiente (Pending)
Completada (Fulfilled)
Rechazada (Rejected)
Al crear una promesa, dentro de ella especificamos una función asÃncrona que se ejecutará. Esta función resolverá la promesa con un valor u otro según se haya completado o rechazado. Si la operación asÃncrona se completa correctamente, la promesa se cumple (resolve) con el resultado de la operación. Si la operación falla, la promesa se rechaza (reject) con un error. Estos resultados se devuelven a través del objeto de la promesa.
Podemos utilizar los métodos de callback de las promesas para manejar el resultado una vez que la promesa se haya cumplido o rechazado:
Método
then()
: se ejecuta cuando la promesa se cumple, y recibe el resultado como argumento.Método
catch()
: se ejecuta cuando la promesa es rechazada, y recibe el error como argumento.
Veamos cómo refactorizar el ejemplo anterior para que devuelva una promesa y podamos utilizar los métodos then()
y catch()
que nos proporcionan las promesas:
const requestFunction = (url) => {
//La función devuelve una promesa que dispone de una función resolve y reject
return new Promise ((res,rej) => {
const delay = Math.floor(Math.random() * 2500) + 250;
// Pasa una función asÃncrona a WebAPI y ejecuta una función u otra de la promesa según el delay
setTimeout(() => {
if (delay > 2000){
rej('La petición ha expirado.');
} else {
res(`Datos desde ${url}`)
}
},delay)
})
}
const request = requestFunction('books.com')
.then ( msg => {
console.log('The promise was resolved |',msg)
return requestFunction('books.com/philosophy')
})
.then( msg => {
console.log('The promise was resolved (2) |',msg)
return requestFunction('books.com/philosophy/davidhume')
})
.then ( msg => {
console.log('The promise was resolved (3) |',msg)
})
.catch ((error) => {
console.log('The promise was rejected |',error)
})
Como se puede observar, al devolver una nueva promesa dentro de la función callback pasada al método then()
, podemos encadenarlas y evitar el callback hell. Además, podemos utilizar un solo método catch()
para manejar cualquier error que ocurra en cualquier petición de la cadena. Esto mejora la legibilidad y el mantenimiento del código.
Simplificando el código aún más: funciones asÃncronas con los operadores async/await
Los operadores async
y await
son una forma más limpia de manejar operaciones asÃncronas basadas en promesas. Proporcionan una sintaxis más clara y legible, evitando el anidamiento de promesas o el callback hell.
Async
El operador async
se utiliza para declarar una función asÃncrona. Cuando se declara una función con async
, siempre devuelve una promesa. Si la función devuelve un valor, la promesa se resuelve con ese valor. Si la función arroja una excepción, la promesa se rechaza con el error.
async function miFuncion () {
/* throw ("Se ha producido un error"); */
return "Esto es una función asÃncrona";
};
const miPromesa = miFuncion()
.then((msg) => {
console.log("La promesa se resolvió |", msg);
})
.catch((err) => {
console.log("La promesa fue rechazada |", err);
});
console.log(miPromesa);
Await
El operador await
se utiliza dentro de funciones async
y pausa la ejecución de la función hasta que se resuelva una promesa. Permite esperar el resultado de una promesa de forma sincrónica.
Refactoricemos nuevamente nuestro ejemplo anterior utilizando los operadores async
y await
:
async function requestFunction (url) {
//La función devuelve una promesa que dispone de una función resolve y reject
return new Promise ((res,rej) => {
const delay = Math.floor(Math.random() * 4500) + 250;
// Pasa una función asÃncrona a WebAPI y resuelve o rechaza la petición según el delay
setTimeout(() => {
if (delay > 4000){
rej('La petición ha expirado.');
} else {
res(`Datos desde ${url}`)
}
},delay)
})
}
async function getData(){
const result = await requestFunction("books.com");
console.log(result);
const result2 = await requestFunction("books.com/philosophy");
console.log(result2);
const result3 = await requestFunction("books.com/philosophy/davidhume");
console.log(result3);
}
getData();
Como puedes observar, hemos declarado nuestra función requestFunction
como asÃncrona usando el operador async
. Esta función devuelve una Promesa, lo que nos permite usar el operador await
cuando la llamamos dentro de otra función asÃncrona, en este caso getData
.
En la función getData
, hemos almacenado el resultado de cada llamada a requestFunction
en variables y luego los hemos mostrado por consola. Como puedes ver, las instrucciones de console.log
solo se ejecutan una vez que se ha obtenido una respuesta de requestFunction
. Además, la segunda llamada a requestFunction
solo ocurre una vez que la primera se ha completado, y asà sucesivamente. Esto significa que se espera a que la Promesa se resuelva antes de continuar la ejecución.
Ahora bien, ¿qué ocurre cuando la Promesa es rechazada? ¿Cómo manejamos los errores en una función asÃncrona? Para ello, debemos utilizar los bloques try/catch
.
async function requestFunction (url) {
//La función devuelve una promesa que dispone de una función resolve y reject
return new Promise ((res,rej) => {
const delay = Math.floor(Math.random() * 4500) + 250;
// Pasa una función asÃncrona a WebAPI y resuelve o rechaza la petición según el delay
setTimeout(() => {
if (delay > 4000){
rej('La petición ha expirado.');
} else {
res(`Datos desde ${url}`)
}
},delay)
})
}
async function getData(){
try {
const result = await requestFunction("books.com");
console.log(result);
const result2 = await requestFunction("books.com/philosophy");
console.log(result2);
const result3 = await requestFunction("books.com/philosophy/davidhume");
console.log(result3);
} catch (error) {
console.log(`Su petición no ha podido completarse ${error}`)
}
}
getData();
Ahora, en nuestro código, manejamos los errores y en caso de que la promesa sea rechazada, el código no parará, dado que estamos manejando el error de forma correcta.
Conclusión
Entender la programación asÃncrona en JavaScript es esencial para desarrollar aplicaciones web. Podemos sintetizar lo visto en algunos puntos clave:
La pila de llamadas rastrea el orden de las funciones y permite una ejecución fluida.
Para evitar el problema de los callbacks anidados, las promesas simplifican el código al representar por un objeto la finalización o el fallo de una operación.
Los operadores async/await ofrecen una sintaxis más limpia para manejar operaciones asÃncronas eliminando anidamientos excesivos y facilitando el manejo de errores con try-catch.