🔃 Entendiendo la asincronía en JavaScript

🔃 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.

·

10 min read

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:

  1. Cuando se llama a una función en JavaScript, el intérprete la agrega a la pila de llamadas y la ejecuta.

  2. La función en la parte superior de la pila es la que se está ejecutando actualmente.

  3. Las funciones anidadas también se agregan en la parte superior de la pila.

  4. 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:

  1. Éxito: la consulta se completa correctamente y obtenemos la información deseada.

  2. 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:

  1. Pendiente (Pending)

  2. Completada (Fulfilled)

  3. 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:

  1. Método then(): se ejecuta cuando la promesa se cumple, y recibe el resultado como argumento.

  2. 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:

  1. La pila de llamadas rastrea el orden de las funciones y permite una ejecución fluida.

  2. 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.

  3. 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.

Â