Asincronía
La asincronía es un concepto que se refiere a la ejecución de tareas de forma independiente y no secuencial.
En JavaScript, la asincronía es un concepto fundamental, ya que nos permite realizar tareas que pueden tardar en ejecutarse sin bloquear la ejecución del programa. Esto es de suma importancia, ya que nos permite realizar tareas como la lectura de archivos, la realización de peticiones a un servidor, la ejecución de consultas a una base de datos, etc., sin tener que esperar a que estas tareas se completen para continuar con la ejecución del programa.
Para entender mejor este concepto, vamos a ver una pequeña comparativa entre la ejecución de código síncrono y asíncrono.
Código síncrono
El código síncrono es aquel que se ejecuta de forma secuencial, es decir, una sentencia se ejecuta después de haberse completado la anterior.
1tarea_1();2tarea_2();3tarea_3();
En el ejemplo anterior, las tareas (tasks) se ejecutarán secuencialmente, es decir, la tarea_1
se ejecutará antes que la tarea_2
, y esta a su vez antes que la tarea_3
. Lógicamente, para ejecutar una función esta debe esperar a que termine la ejecución de las anteriores.
Gráficamente, podemos representar el flujo de ejecución de la siguiente manera:
Este flujo de ejecución es el que convencionalmente empleamos para ejecutar tareas en JavaScript. No obstante, esto puede bloquear la ejecución del programa, ya que si una sentencia tarda mucho en ejecutarse, el resto de sentencias no se ejecutarán hasta que esta termine su ejecución. Así en el gráfico anterior, si la tarea_2
es de larga duración entonces la tarea_3
podría verse afectada, interfiriendo con la experiencia del usuario.
En un ejemplo más práctico, supongamos que necesitamos renderizar un mapa en una página web con el camino más corto hacia un restaurante. Para para simplificar el problema podemos decir que necesitamos lo siguiente:
- La ubicación del restaurante, la cual supondremos que ya tenemos como dato.
- La ubicación actual del usuario, la cual debemos obtener mediante el uso de la geolocalización del navegador.
- Calcular la ruta más corta entre la ubicación del usuario y la del restaurante.
- Renderizar el mapa con la ruta más corta.
Como vemos hay cierta dependencia entre las tareas, ya que necesitamos la ubicación del usuario para calcular la ruta más corta. No obstante, si empleamos código síncrono, el mapa no se renderizará hasta que se haya completado los pasos anteriores. Esto puede resultar en una mala experiencia de usuario, ya que no podrá ver el mapa de inmediato.
Pero, por qué no renderizar el mapa mientras se obtiene la ubicación del usuario y luego dibujar la ruta más corta cuando se haya calculado. Cabe resaltar que no bastará con emplear código síncrono y condicionar la ejecución de las tareas, ya que la ejecución de una tarea síncrona bloqueará la ejecución de las demás sentencias.
Código asíncrono
Por otro lado, el código asíncrono es aquel que se ejecuta de manera independiente y no secuencial, permitiendo que una tarea de larga duración se ejecute mientras el programa continúa con la ejecución de las demás sentencias, eventos o tareas.
Esto supone que la ejecución de una tarea asíncrona no está restringida por el flujo de ejecución secuencial de las demás sentencias síncronas.
1tarea_1();2tarea_asincrona();3tarea_3();
En esta ocasion, la tarea_1
se ejecutará antes que la tarea_asincrona
. Sin embargo, la tarea_asincrona
no se ejecutará antes que la tarea_3
. Esto se debe a que la tarea_asincrona
quedará en pendiente y retomará su ejecución cuando el programa haya terminado de ejecutar las demás sentencias.
En este sentido, podemos notar que la ejecución de tareas asíncronas no bloquea la ejecución del programa, y nos permite realizar tareas que pueden tardar en ejecutarse sin tener que esperar a que estas se completen para continuar con la ejecución del programa.
Volviendo al ejemplo anterior, si empleamos código asíncrono veremos que los navegadores modernos nos proveen de una API de geolocalización que nos permite obtener la ubicación del usuario de forma asíncrona. Entonces mientras se solicita mediante una petición HTTP la ubicación del usuario, podemos renderizar el mapa y luego dibujar la ruta más corta cuando se haya obtenido la ubicación.
En JavaScript, estas tareas o sentencias asíncronas no son más que funciones, las cuales pueden llevarse a cabo mediante el uso de callbacks y/o promesas.
Callbacks
Una callback, o función de retrollamada, es una función que se pasa como argumento a otra función, y que se ejecuta cuando esta última finaliza su ejecución.
Por ejemplo, la función setTimeout
es una función asíncrona que permite ejecutar una tarea después de que haya transcurrido un tiempo determinado. La función pasada como argumento a setTimeout
es una callback.
1function callback() {2 console.log("Han pasado 3 segundos");3}4
5setTimeout(callback, 3000);
Como vemos, por si sola la función asíncrona, no es compleja de entender. Sin embargo, cuantas más tareas (sínconas y/o asíncronas) se añadan, más complejo será el flujo de ejecución del programa.
Para comprender mejor este concepto, estudiaremos el modelo de ejecución de programas de JavaScript, el cual se basa en un bucle de eventos y una serie de elementos que permiten gestionar todas las tareas que se ejecutan en un programa.
Conceptos de un programa en ejecución
JavaScript es un lenguaje de programación monohilo y asíncrono. Esto quiere decir que JavaScript ejecuta una sola tarea a la vez, y que las tareas asíncronas no bloquean la ejecución del programa.
Para gestionar las tareas asíncronas, JavaScript emplea un bucle de eventos que se encarga de gestionar las tareas asíncronas y de encolar las tareas asíncronas en una cola de tareas.
Debemos entender en primer lugar el modelo teórico que implementa JavaScript para la ejecución de un programa.
Pila de llamadas
Las llamadas a una función forman una pila de frames, donde cada frame encapsula la información de la llamada como el contexto de ejecución, las variables locales y globales, entre otros. Esta pila de llamadas se conoce como pila de llamadas o call stack. Por ejemplo, si tenemos el siguiente código:
1function multiply(a, b) {2 return a * b;3}4
5function showSquare(x) {6 let y = multiply(x, x);7 console.log(y);8}9
10showSquare(5);
Cuando se llama a la función showSquare
, se añade un frame a la pila de llamadas, el cual contiene los argumentos y las variables locales de la función.
Luego, cuando showSquare
llama a la función multiply
, se añade otro frame a la pila de llamadas, el cual es colocado encima del frame de showSquare
. De la misma manera, showSquare
invoca a console.log
, añadiendo otro frame a la pila de llamadas.
Cuando console.log
termina su ejecución, se saca su frame de la pila de llamadas. Luego, se procede a sacar el frame de multiply
y finalmente el frame de showSquare
. De esta manera, la pila de llamadas queda vacía.
Montículo
Esta sección del modelo teórico de JavaScript establece un espacio de memoria donde, todos los objetos son almacenados. Este espacio de memoria se conoce como montículo o heap, y no tiene una estructura definida, ya que la disposición de los objetos en el montículo es dinámica y depende de la ejecución del programa.
Cola de tareas
Todo programa en JavaScript tiene una cola de tareas o task queue (también llamada callback queue), la cual se encarga de establecer el orden de ejecución de las tareas asíncronas. Esta cola contiene mensajes referentes a las tareas que deben ser ejecutadas, es decir, cada invocación de una función es asociada a un mensaje.
Cuando la pila de llamadas está vacía, el bucle de eventos se encarga de sacar el primer mensaje de la cola de tareas y procesarlo. El procesamiento de un mensaje implica la llamar a la función asociada al mensaje, lo cual añade un frame a la pila de llamadas. Por lo general, la función invocada es una callback.
Bucle de eventos
El bucle de eventos, o más conocido como event loop, es un mecanismo que se encarga de gestionar y encolar las tareas asíncronas en la cola de tareas. Entonces, mientras la pila de llamadas esté vacía, el bucle de eventos se encargará de sacar el primer mensaje de la cola de tareas y procesarlo.
Hay que tener en cuenta que no todas las callbacks son encoladas directamente en la cola de tareas. Por ejemplo, las callbacks de setTimeout
deben pasar primero por la Web API una pila similar a la pilla de llamadas pero que no es gestionada por JavaScript, sino por el navegador.
Este último punto es importante, ya que todas las tareas que se cargan en la Web API deben esperar a que la pila de llamadas esté vacía para ser encoladas en la cola de tareas.
Método de ejecución
Cada mensaje es procesado completamente antes que cualquier otro mensaje sea procesado, es decir, una tarea se ejecuta hasta completarse.
En este aspecto se aplica a todas las tareas, ya sean síncronas o asíncronas. Motivo por el cual, establecimos que únicamente manejando tareas síncronas podríamos bloquear la ejecución del programa si una tarea tarda mucho en ejecutarse. Al gestionar las tareas asíncronas mediante una pila diferente y emplear la cola de tareas tenemos un mecanismo que evita el bloqueo de funciones específicas.
Añadir mensajes
Como sabemos, en los navegadores modernos los mensajes que se añaden dependen de los eventos que ocurren y de los listeners que se han añadido. Por ejemplo, si un usuario hace clic en un botón, y este botón tiene un listener asociado, entonces se añadirá un mensaje a la cola de tareas.
Cero retraso
Hay un detalle que tiende a confundir en tareas asínconas, y es que estas no se ejecutan inmediatamente. Por ejemplo, si empleamos setTimeout
con un tiempo de 0
milisegundos, la tarea no necesariamente se ejecutará de inmediato.
En este punto, sabemos que existen muchos factores que pueden impedir la ejecución inmediata de una tarea asíncrona, como la cantidad de mensajes en la cola de tareas y la cantidad de tareas en la pila de llamadas. Lo que nos indica el retraso de 0
milisegundos en setTimeout
es cuando se añadirá el mensaje a la Web API y posteriormente a la cola de tareas.
A continuación, veremos una demostración de cómo se ejecutan los programas en JavaScript, y cómo se gestionan las tareas asíncronas mediante el bucle de eventos y la cola de tareas.
Asincronía con callbacks
Las callbacks fueron una de las primeras formas de manejar la asincronía en JavaScript.
Como sabemos hasta el momento, las funciones asíncronas se encolan gracias al bucle de eventos en la cola de tareas.
Sin embargo, puesto que el momento en el que se ejecutará una tarea asíncrona depende de la cantidad de mensajes en la cola de tareas, en algunas ocasiones no podremos determinar cuándo se completará su ejecución. Razón por la cual, si necesitamos del resultado de una tarea asíncrona para realizar otra tarea, nos encontraríamos con un problema.
Para resolver este problema de dependencia entre tareas, podemos emplear callbacks. Así, podemos garantizar que una tarea se ejecute siempre y cuando otra haya terminado su ejecución.
Por ejemplo, supongamos que necesitamos obtener los datos de un servidor mediante una petición HTTP y mostrarlos en una lista. Para ello, podemos emplear una función obtenerDatos
que recibe una función callback como argumento, y que se encarga de realizar la petición HTTP y llamar a la función callback con los datos obtenidos.
1<!DOCTYPE html>2<html>3 <head>4 <title>Asincronía con Callbacks</title>5 <style>6 #loading {7 display: none;8 }9 </style>10 </head>11 <body>12 <h1>Asincronía con Callbacks</h1>13 <div id="loading">Cargando datos...</div>14 <ul id="lista"></ul>15 <script>16 function obtenerDatos(callback) {17 document.getElementById("loading").style.display = "block";18
19 var xhr = new XMLHttpRequest();20 xhr.open(21 "GET",22 "https://jsonplaceholder.typicode.com/users",23 true24 );25 xhr.onreadystatechange = function () {26 if (xhr.readyState === 4) {27 document.getElementById("loading").style.display =28 "none";29 if (xhr.status === 200) {30 var data = JSON.parse(xhr.responseText);31 callback(null, data);32 } else {33 document.getElementById("loading").innerText =34 "Error cargando datos.";35 callback(new Error(xhr.statusText), null);36 }37 }38 };39 xhr.send();40 }41
42 function mostrarDatos(error, datos) {43 if (error) {44 alert("Error cargando datos.");45 return;46 }47 const lista = document.getElementById("lista");48 datos.forEach((dato) => {49 const item = document.createElement("li");50 item.textContent = `Nombre: ${dato.name}, Email: ${dato.email}`;51 lista.appendChild(item);52 });53 }54
55 obtenerDatos(mostrarDatos);56 </script>57 </body>58</html>
En este ejemplo, la función mostrarDatos
requiere de los datos obtenidos mediante la petición HTTP para mostrarlos en una lista. De esta manera, la función obtenerDatos
se encarga de realizar la petición HTTP y llamar a la función mostrarDatos
con los datos obtenidos.
Desventajas de las callbacks
A pesar de que las callbacks son una herramienta útil para manejar la asincronía en JavaScript, estas presentan algunas desventajas que pueden dificultar el mantenimiento y la legibilidad del código.
Este problema de legibilidad y mantenimiento se conoce como callback hell, y se produce cuando anidamos muchas callbacks dentro de otras, lo que puede dificultar la comprensión del código.
Por ejemplo, supongamos que tenemos una función tarea_1
que debe ejecutarse antes que tarea_2
, y esta a su vez antes que tarea_3
. Todas ellas son funciones asíncronas que reciben una función callback como argumento.
1function tarea_1(callback) {2 setTimeout(() => {3 console.log("Tarea 1 completada");4 callback();5 }, 2000);6}7
8function tarea_2(callback) {9 setTimeout(() => {10 console.log("Tarea 2 completada");11 callback();12 }, 2000);13}14
15function tarea_3(callback) {16 setTimeout(() => {17 console.log("Tarea 3 completada");18 callback();19 }, 2000);20}21
22tarea_1(() => {23 tarea_2(() => {24 tarea_3(() => {25 console.log("Todas las tareas completadas");26 });27 });28});
Esto quiere decir que, la función callback de tarea_1
será la encargada de ejecutar la función callback de tarea_2
, y esta a su vez la de tarea_3
.
Como podemos observar, el código se vuelve difícil de leer y mantener a medida que anidamos más callbacks. Esto se debe a que las callbacks se anidan unas dentro de otras para garantizar que una tarea se ejecute después de que otra haya terminado su ejecución.
Para resolver este problema, se han propuesto otras soluciones, como las promesas y las funciones async/await.
Promesas
Una promesa es un objeto de la clase Promise
devuelto por una función asíncrona. La misma representa el estado actual de una operación, y puede encontrarse en uno de los siguientes estados:
- Pendiente: estado inicial, la operación aún no ha sido completada.
- Cumplida: la operación ha sido completada con éxito.
- Rechazada: la operación ha sido completada con un error.
Dependiendo del estado en el que se encuentre la promesa, se ejecutará alguno de los siguientes métodos:
then(resolve)
: ejecutará una función callback (resolve
) si la promesa ha sido cumplida.catch(reject)
: ejecutará una función callback (reject
) si la promesa ha sido rechazada.finally(end)
: ejecutará una función callback (end
) sin importar el estado en el que se encuentre la promesa.
Veremos que al emplear promesas, en lugar de únicamente callbacks evita el callback hell. Pero antes, veamos como consumir promesas.
Consumir promesas
Recordando los temas vistos en la materia anterior sobre APIs RESTful, veremos que ya hemos trabajado con promesas, mediante el uso de fetch
.
Como sabemos, este método nos permite realizar peticiones a un servidor, y nos devuelve la respuesta del servidor. Sin embargo, ahora que conocemos las promesas, podemos comprender mejor su funcionamiento.
La función fetch
es una función asíncrona con la que podemos realizar peticiones a un servidor. Esta función devuelve una promesa, la cual representa el estado de la petición. Por ende, podemos emplear los métodos then
, catch
y finally
para manejar el estado de la promesa. Por ejemplo, empleando la API de JSONPlaceholder, podemos realizar una petición GET
a la URL https://jsonplaceholder.typicode.com/posts/1
para obtener un post específico.
1fetch("https://jsonplaceholder.typicode.com/posts/1")2 .then(function(response) {3 return response.json();4 })5 .then(function(data) {6 console.log(data);7 })8 .catch(function(error) {9 console.error(error);10 })11 .finally(function() {12 console.log("Petición completada");13 });
Es importante resaltar que, el estado HTTP de la respuesta no determina si la promesa ha sido cumplida o rechazada.
Es decir, ya sea que la respuesta sea 200 OK
, 404 Not Found
, 500 Internal Server Error
, etc., la promesa siempre será cumplida. Por lo tanto, debemos emplear el método then
para manejar el estado de la promesa y no catch
como podríamos pensar.
La promesa devuelta por fetch
puede ser rechazada por alguna de las siguientes razones:
-
Errores de conexión: si existe un problema de red que impide que la solicitud llegue al servidor o que la respuesta regrese al cliente, como un error de DNS, problemas de conectividad a Internet, o que el servidor esté inalcanzable, la promesa será rechazada y
catch
se ejecutará. -
Solicitudes abortadas: si la solicitud es abortada manualmente, por ejemplo, si el usuario cierra la pestaña del navegador antes de que la solicitud se complete, la promesa será rechazada y
catch
se ejecutará. Si la url de la solicitud es inválida,fetch
lanzará un error y la promesa será rechazada. -
Errores en la ejecución de
then
: si ocurre un error durante la ejecución de uno de los bloquesthen
anteriores. Por ejemplo, si la respuesta HTTP no es un JSON válido,response.json()
lanzará un error y la promesa será rechazada. Esto significa que cualquier error que ocurra en un bloquethen
se propagará hacia abajo y puede ser capturado porcatch
. Esto significa quecatch
no solo captura fallos en la red, sino también errores de programación que ocurren en las cadenas de promesas.
Encadenar promesas
Por otro lado, si nos fijamos con atención en el ejemplo anterior, podemos observar que el método then
es encadenado, es decir, el resultado de un then
es pasado como argumento a otro then
.
Esto es posible gracias a que el primer then
devuelve otra promesa. En este caso, la promesa devuelta por response.json()
representa el estado de la operación de serialización del cuerpo de la respuesta a JSON a un objeto JavaScript.
1fetch("https://jsonplaceholder.typicode.com/posts/1")2 .then(function(response) {3 return response.json();4 })5 .then(function(data) {6 console.log(data);7 })8 ...
Por lo tanto, podemos encadenar otro then
para manejar el estado la nueva promesa devuelta por response.json()
y así sucesivamente.
De esta manera, podemos encadenar promesas para realizar operaciones asíncronas en lugar de anidar callbacks dentro de otros callbacks. Esto nos permite escribir un código más legible y mantenible.
1fetch("https://jsonplaceholder.typicode.com/posts/1")2 .then(response => response.json())3 .then(data => console.log(data))4 .catch(error => console.error(error))5 .finally(() => console.log("Petición completada"));
Crear promesas
Ahora que hemos aprendido lo que son las promesas y cómo consumirlas, veremos como crear nuestras propias promesas.
Para crear una promesa, empleamos el constructor Promise
. Este constructor recibe como argumento una función que a su vez recibe otras dos funciones como argumentos: resolve
y reject
, las callbacks que representan el estado de la promesa de las que hablamos anteriormente.
resolve
: es una función que se ejecuta si la operación ha sido completada con éxito.reject
: es una función que se ejecuta si la operación ha sido completada con un error.
Las promesas que creamos pueden relacionarse con cualquier operación asíncrona, como la lectura de un archivo, la realización de una petición a un servidor, la ejecución de una consulta a una base de datos, o algo tan trivial como esperar un tiempo determinado. Siempre que la operación sea asíncrona, podemos emplear una promesa.
Por ejemplo, podemos crear una tarea asíncrona que almacene un dato en el almacenamiento local del navegador, y que devuelva una promesa que represente el estado de la operación.
1function guardarDatoAsincrono(clave, valor) {2 return new Promise((resolve) => {3 localStorage.setItem(clave, valor);4 resolve(`Dato guardado: ${clave} = ${valor}`);5 });6}
En este caso, la función guardarDatoAsincrono
recibe dos argumentos: clave
y valor
. La función devuelve una promesa que representa el estado de la operación de almacenamiento.
Por otro lado, la promesa intentará almacenar un valor en localStorage
. Si se completa con éxito, la función resolve
se ejecutará, y la promesa será cumplida. En este caso, la función resolve
recibe un argumento que representa el resultado de la operación.
De esta manera, podemos emplear el método then
para manejar el estado de la promesa y obtener el resultado de la operación.
A continuación, veremos un ejemplo de cómo podemos emplear la función guardarDatoAsincrono
para almacenar un dato en el almacenamiento local del navegador, y cómo podemos manejar el estado de la promesa.
1<!DOCTYPE html>2<html lang="es">3<head>4 <meta charset="UTF-8">5 <title>Ejemplo de Promesas con localStorage</title>6 <script>7 // Función para simular una operación asíncrona de almacenamiento8 function guardarDatoAsincrono(clave, valor) {9 return new Promise((resolve) => {10 localStorage.setItem(clave, valor);11 resolve(`Dato guardado: ${clave} = ${valor}`);12 });13 }14
15 // Función para simular una operación asíncrona de recuperación16 function recuperarDatoAsincrono(clave) {17 return new Promise((resolve) => {18 setTimeout(() => {19 const valor = localStorage.getItem(clave);20 resolve(valor);21 }, 1000); // Simula una demora de 1 segundo22 });23 }24
25 // Ejecutar las funciones26 window.onload = function() {27 guardarDatoAsincrono('usuario', 'JuanPerez')28 .then(resultado => {29 console.log(resultado);30 return recuperarDatoAsincrono('usuario');31 })32 .then(valor => console.log(`Valor recuperado: ${valor}`))33 .catch(err => console.error(err));34 }35 </script>36</head>37<body>38 <h1>Ejemplo de Uso de Promesas con localStorage</h1>39 <p>Abre la consola del navegador para ver los resultados.</p>40</body>41</html>
En resumen, hemos aprendido que las promesas son una forma de manejar la asincronía en JavaScript. Estas nos permiten manejar el estado de una operación asíncrona, y nos permiten encadenar promesas para realizar operaciones asíncronas en lugar de anidar callbacks dentro de otros callbacks.
async
/await
A partir de ES8 - 2017, se introdujeron las palabras clave async
y await
como una alternativa a las promesas para manejar la asincronía en JavaScript.
Estas no son más que azúcar sintáctico que nos permite escribir código asíncrono más conciso y legible.
No obstante, emplear async
/await
trae consigo algunos cambios relevantes:
- No encadenamos promesas mediante el método
then
, sino que empleamos la palabra claveawait
para esperar a que una promesa sea cumplida o rechazada. - A diferencia de las promesas, las funciones que emplean
async/await
se consideran código bloqueante, es decir, el programa se detiene a esperar a que la operación asíncrona sea completada.
Sentencia await
Para comprender el funcionamiento de await
, veamos nuevamente el ejemplo de fetch
que vimos anteriormente a la API de JSONPlaceholder.
1fetch("https://jsonplaceholder.typicode.com/posts/1")2 .then(response => response.json())3 .then(data => console.log(data))4 .catch(error => console.error(error))5 .finally(() => console.log("Petición completada"));
Ahora, veremos que podemos manejar la promesa que devuelve fetch
mediante await
en lugar de then
.
1const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");
En este caso, al anteponer la palabra clave await
a la llamada a fetch
, el programa se detendrá a esperar a que la promesa sea cumplida o rechazada. Es decir, se bloqueará la ejecución del programa hasta que la promesa devuelta por fetch
sea completada.
Una vez terminada la operación, el resultado de la promesa será almacenado en la variable response
.
Como aclaramos anteriormente, al serializar el cuerpo de la respuesta a JSON, response.json()
devuelve otra promesa. Por lo tanto, podemos emplear await
nuevamente.
1const data = await response.json();2console.log(data);
Por supuesto, en la mayoría de los casos, necesitaremos modularizar el código y querremos que este código se ejecute como una función.
1function fetchPost() {2 const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");3 const data = await response.json();4 console.log(data);5}
Sin embargo, si intentamos ejecutar este código, obtendremos un error.
1Uncaught SyntaxError: await is only valid in async functions and the top level bodies of modules
Esto se debe a que await
solo puede ser empleado en funciones asíncronas, pero la función que hemos definido no lo es.
Sentencia async
Por lo tanto, para poder usar await
, debemos emplearla en una función asíncrona anteponiendo la palabra clave async
a la función que la emplea.
1async function fetchPost() {2 const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");3 const data = await response.json();4 console.log(data);5}
Ahora, podemos ejecutar la función fetchPost
sin problemas. Recordando que fetchPost
es una función asíncrona, con lo cual podemos emplear también await
al llamarla.
1await fetchPost();
Por último, debemos tener en cuenta que ahora que no empleamos then
, no podemos manejar los errores con el método catch
. Por este motivo, nos veremos en la necesidad de emplear la estructura try
/catch
para manejar los errores.
Sentencia try
/catch
La estructura try
/catch
nos permite manejar los errores que puedan ocurrir en el bloque de código que se encuentra dentro de try
.
Entonces, podemos declarar un bloque try
para manejar el estado de la promesa, y un bloque catch
para manejar los errores que puedan ocurrir.
1async function fetchPost() {2 try {3 const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");4 const data = await response.json();5 console.log(data);6 } catch (error) {7 console.error(error);8 }9}
Por supuesto, podemos emplear la sentencia finally
para ejecutar código después de que el bloque try
o catch
haya terminado su ejecución.
1async function fetchPost() {2 try {3 const response = await fetch("https://jsonplaceholder.typicode.com/posts/1");4 const data = await response.json();5 console.log(data);6 } catch (error) {7 console.error(error);8 } finally {9 console.log("Petición completada");10 }11}
En general, vemos que async
/await
es una forma de manejar la asincronía en JavaScript evitando el factor declarativo que veíamos previamente. Su uso es méramente opcional, y dependerá de la preferencia del programador.
Macro tareas y micro tareas
Por último, es importante mencionar que las tareas asíncronas en JavaScript pueden ser clasificadas en dos tipos: macro tareas y micro tareas.
Saber diferenciar entre estos dos tipos de tareas es fundamental para comprender cómo se gestionan las tareas asíncronas y evitar errores comunes en la programación asíncrona.
- Bloque de eventos: al crear demasiadas micro tareas o macro tareas, el bucle de eventos puede verse saturado y causar problemas de rendimiento. Así como también, retrasar tareas importantes.
- Crear condiciones de carrera: al no tener en cuenta la prioridad de las tareas, podemos encontrarnos con problemas de sincronización entre tareas asíncronas. Es decir, que una tarea dependa de otra que aún no ha sido completada.
Como sabemos, las tareas asíncronas se encolan en la cola de tareas y son gestionadas por el bucle de eventos. Sin embargo, un detalle que no hemos mencionado es que existen dos colas de tareas, cada una responsable de gestionar un tipo de tarea en particular.
La diferencia fundamental entre macro tareas y micro tareas radica en la prioridad con la que son ejecutadas. Las micro tareas tienen una prioridad más alta que las macro tareas, lo que significa que las micro tareas se ejecutan antes que las macro tareas.
Micro tareas
Las micro tareas son tareas asíncronas que se encolan en la cola de micro tareas (microtask queue) o también conocida como job queue.
Las tarea que se encolan en la cola de micro tareas se ejecutan inmediatamente después de que la pila de llamadas esté vacía, es decir, antes de que se ejecute cualquier otra tarea asíncrona.
Entre las micro tareas más comunes se encuentran las callbacks de operaciones urgentes o importantes:
- Callbacks de promesas (
then
,catch
,finally
). MutationObserver
API. Objeto que permite observar cambios en el DOM.process.nextTick
en Node.js. Una función que permite encolar una tarea en la cola de micro tareas.queueMicrotask
en el navegador. Una función que permite encolar una tarea en la cola de micro tareas.- Expresiones
await
contenidas en funcionesasync
.
Macro tareas
Por otro lado, las macro tareas son tareas asíncronas que se encolan en la cola de macro tareas (macrotask queue) o también conocida como task queue. Estructura que hemos analizado anteriormente.
Las tareas que se encolan en la cola de macro tareas se ejecutan después de que la pila de llamadas esté vacía y después de que se hayan ejecutado todas las micro tareas.
Entre las macro tareas se encuentran las callbacks de operaciones menos urgentes o importantes:
- Callbacks de temporizadores (
setTimeout
,setInterval
,setImmediate
, entre otras). - Manipulación del DOM y renderizado de la interfaz de usuario. Por ejemplo,
appendChild
,removeChild
,scroll
,resize
, entre otros. - Operaciones de entrada/salida (I/O). Como la lectura y escritura de archivos.
- Operaciones de red (
fetch
,XMLHttpRequest
). - Manejadores de eventos (
addEventListener
).
Recomendaciones
Para evitar los problemas mencionados anteriormente, existen buenas prácticas que podemos seguir al programar tareas asíncronas:
-
Usar promesas o
async
/await
en lugar de únicamente callbacks (si es posible). El código será más legible y mantenible, además de permitirnos detectar errores más fácilmente. -
Existen métodos como
queueMicrotask
que nos permiten encolar micro tareas en lugar de establecer temporizadores con intervalos muy cortos. Esto nos permite establecer prioridades en las tareas y evitar problemas de sincronización.Por ejemplo, si necesitamos ejecutar una tarea después de que se haya completado otra, podemos emplear
queueMicrotask
para encolar la tarea.1queueMicrotask(() => {2console.log("Micro tarea 1");3});45queueMicrotask(() => {6console.log("Micro tarea 2");7});89console.log("Tarea síncrona");El orden de ejecución de las tareas será:
Terminal window 1Tarea síncrona2Micro tarea 13Micro tarea 2Vemos que se respeta el orden en que agregamos las micro tareas a la job queue.
Finalmente, con todos estos conceptos en mente, podemos abordar un ejemplo completo del modelo de ejecución de programas en JavaScript.