Hamburger Icon
Tutorial de asincronía en JavaScript

Tutorial de asincronía en JavaScript

Javascript solo permite un hilo de ejecución, pero eso no nos impide ejecutar código asíncrono en segundo plano mientras se realizan otras tareas. Vamos a ver cómo funciona la asincronía en JavaScript.

El código síncrono es ese en el que cada instrucción se ejecuta después de que termine la anterior. Así funciona JavaScript, las líneas de código de tu script se ejecutarán una detrás de la otra, porque es un lenguaje que no permite múltiples hilos de ejecución. En Destripando JavaScript - Parte 2 te cuento algún detalle más al respecto.

Asincronía en JavaScript

En JavaScript podemos conseguir ejecutar código en paralelo, o lo que es lo mismo, de forma asíncrona, mediante varios mecanismos. Históricamente se usaban la funciones de retorno para conseguirlo, seguramente las hayas visto por el nombre de callbacks. Un ejemplo muy tonto:

console.log("Vamos a contar un poco:")
console.log("1")
console.log("2")
console.log("3")

setTimeout(() => {
  console.log("4")
}, 1000)

console.log("5")

/* 
El resulado por consola sería este:
"1"
"2"
"3"
"5"
"4"
*/

¿Por qué sale antes el 5 que el 4? Porque hemos usado la función setTimeout, a la que le hemos pasado dos parámetros: una función callback y una cantidad de milisegundos. setTimeout ejecutará la callback una vez transcurrido ese tiempo, pero mientras esto sucede, la ejecución continúa imprimiendo el número 5. Recuerda revisar el enlace que te he puesto más arriba si tienes curiosidad para entender cómo JavaScript hace todo esto siendo mono hilo.

Este es un ejemplo muy sencillo, el problema de es cuando hay que sincronizar mútiples tareas siguiendo un orden numérico y temporal. Podemos llegar a producir lo que se llama un callback hell. Mira este ejemplo, donde hay 5 tareas que se ejecutan en orden, con la excepción de que las tareas 2 y 3 se pueden ejecutar simultáneamente:

setTimeout(() => {
  console.log("Empezamos la ejecución de tareas")
  setTimeout(() => {
    console.log("Completando tarea 1...")
    setTimeout(() => {
      console.log("Completando tarea 2...")
      setTimeout(() => {
        console.log("Completando tarea 4...")
        setTimeout(() => {
          console.log("Completando tarea 5...")
          console.log("¡Tareas completadas!")
        }, 1000)
      }, 500)
    }, 2000)
    console.log("Completando tarea 3...")
  }, 1000)
}, 0)

/*
El resultado sería algo así:

Empezamos la ejecución de tareas 
Completando tarea 1... 
Completando tarea 3... 
Completando tarea 2... 
Completando tarea 4... 
Completando tarea 5... 
¡Tareas completadas! 
*/

¿Lioso verdad? Esto es el callback hell. Imagina cómo debe ser en un programa que haga algo más complejo. Las promesas JavaScript se crearon para solventar esto, para que nuestro código sea más legible y podamos gestionar este tipo de tareas de forma más sencilla.

Promesas JavaScript

Cuando se ejecuta una promesa, en primer lugar pasa a estado pendiente, mientras se intenta ejecutar el código que contiene. Pueden pasar dos cosas: o bien se resuelve la promesa, o bien se rechaza.

Si se resuelve, podemos encadenar otro bloque de código que necesariamente se ejecute una vez esto haya sucedido. Si se rechaza, ya sea por un error o porque provocamos ese rechazo por alguna condición, entonces podemos ejecutar un bloque de código diferente.

Un ejemplo:

// resolve y reject son funciones,
// nos permiten resolver o rechazar la promesa
new Promise((resolve, reject) => {
  if (/* una condición cualquiera */) {
    resolve(true)
  } else {
    reject(console.log("Promesa incumplida"))
  }
})

Esta es la estructura básica. No es necesario un bloque condicional, nuestro código puede hacer lo que sea que necesitemos y luego ejecutamos el método resolve o reject según sea conveniente.

Tanto a resolve como a reject le podemos pasar una función como parámetro. Una vez vista la estructura básica, vamos a ver este otro ejemplo más completo:

// creamos una función que devuelve una promesa
const suma = (num1, num2) => {
  // esta función auxiliar hace los cálculos
  const calcular = (a, b) => {
    return a + b
  }

  return new Promise((resolve, reject) => {
    if (typeof num1 === "number" && typeof num2 === "number") {
      resolve(calcular(num1, num2))
    } else {
      reject("Esta función necesita dos números")
    }
  })
}

suma(2, 5)
  .then((resultado1) => {
    console.log(resultado1)
  })
  .catch((error) => {
    console.log(error)
  })

Primero llamamos a la función suma pasándole los dos parámetros. Anidamos la llamada a esta función con el método then() y éste con el método catch(). Si todo va bien, es decir que si la promesa se resuelve, se ejecutará el código dentro del then. En caso contrario, si la promesa se rechaza, se ejecutará el código dentro del catch.

Podemos anidar tantos then() como necesitemos, pero es importante que en este caso siempre tengan un valor de retorno:

suma(2, 5)
  .then((resultado1) => {
    console.log(resultado1)
    return suma(4, 4)
  })
  .then((resultado2) => {
    console.log(resultado2)
    return suma(10, 5)
  })
  .then((resultado3) => {
    console.log(resultado3)
    return suma("10", 5)
  })
  .catch((error) => {
    console.log(error)
  })

Como puedes ver, el método then() recibe como parámetro una función. Esta función, como parámetros tendrá lo que devuelva la promesa inicial o el then() anterior. Si ocurre un error en algún punto de la cadena, entonces entra en acción el catch(). Cuando una promesa se rechaza, los then() posteriores ya no se ejecutan y se va directamente al catch().

Además, podemos usar el método finally() para ejecutar un bloque de código al final, tanto si ha habido algún error como si no:

suma(2, 5)
  .then((resultado1) => {
    console.log(resultado1)
    return suma(4, 4)
  })
  .then((resultado2) => {
    console.log(resultado2)
    return suma(10, 5)
  })
  .then((resultado3) => {
    console.log(resultado3)
    return suma("10", 5)
  })
  .catch((error) => {
    console.log(error)
  })
  .finally(() => {
    console.log("Fin de los cálculos")
  })

/* 
La salida en la consola sería algo así:

7
8
15
Esta función necesita dos números 
Fin de los cálculos 
*/

Estas serían las bases para trabajar con promesas, con un poco de práctica se le pilla el truco enseguida.

Async / Await

Podemos trabajar con promesas JavaScript de una forma más simplificada, con las abstracciones introducidas con los mecanismos async y await. Usamos la palabra reservada async delante de la definición de una función para transformarla en una promesa, mientras que await lo pondremos delante de la llamada a dicha función para indicar que queremos esperar a que termine su ejecución antes de continuar.

Cuando usamos este mecanismo, es importante que usemos un bloque try/catch/finally para poder manejar errores. Siguiendo con el ejemplo anterior, podríamos hacer esto:

// función que devuelve una promesa
const suma = (num1, num2) => {
  const calcular = (a, b) => {
    return a + b
  }

  return new Promise((resolve, reject) => {
    if (typeof num1 === "number" && typeof num2 === "number") {
      resolve(calcular(num1, num2))
    } else {
      reject("Esta función necesita dos números")
    }
  })
}

// gestionamos la ejecución con try/catch/finally
// usamos una función para poder hacerlo
const funcionPrincipal = async () => {
  try {
    console.log(await suma(2, 5))
    console.log(await suma(4, 4))
    console.log(await suma(10, 5))
    console.log(await suma("10", 5))
  } catch (error) {
    console.log(error)
  } finally {
    console.log("Fin de los cálculos")
  }
}

// ejecutamos la función principal
funcionPrincipal()

/* 
La salida en la consola sería la misma que antes:

7
8
15
Esta función necesita dos números 
Fin de los cálculos 
*/

Hemos puesto el bloque try/catch/finally dentro de una función que hemos marcado con async para poder usar await. La palabra reservada await siempre se tiene que usar dentro de una función asíncrona.

Cuando se ejecuta la primera línea del try, se imprime por pantalla lo que devuelve la función suma(), pero el await hará que el código quede bloqueado hasta que termine de ejecutarse y devuelva el valor, luego pasará a la siguiente línea.

Con este mecanismo obtenemos un código más compacto y quizás más entendible, pero es muy importante comprender cómo funcionan las promesas para usarlo correctamente.

Un último ejemplo que voy a poner es el de una llamada a una API externa. Para eso, podemos usar el método fetch(). Este método devuelve una promesa, puedes mirar su documentación en MDN.

Vamos a comparar el código necesario usando promesas y usando async/await:

// usando encadenamiento de promesas
fetch("https://catfact.ninja/fact")
  .then((response) => {
    return response.json()
  })
  .then((data) => {
    console.log(data.fact)
  })
  .catch((error) => {
    console.log(error)
  })
// usando async/await
const getCatFact = async () => {
  try {
    const response = await fetch("https://catfact.ninja/fact")

    if (response.ok) {
      const data = await response.json()
      console.log(data.fact)
    }
  } catch (error) {
    console.log(error)
  }
}

getCatFact()

¿Cuál prefieres? Yo personalmente suelo optar por el async/await. Espero que esta introducción a la asincronía en JavaScript te haya sido útil. A mi desde luego me viene de perlas para repasar todos estos conceptos. Si quieres algo un poco más avanzado, para cuando las promesas se quedan cortas, puedes consultar el post sobre observables, otra forma más de asincronía.