En el desarrollo moderno de JavaScript, async/await ha revolucionado la forma en que manejamos el código asíncrono. Esta combinación de palabras clave proporciona una sintaxis más legible y mantenible, acercando el código asíncrono a la estructura síncrona. A continuación, exploraremos sus fundamentos, ventajas y aplicaciones prácticas.
Entendiendo async y su uso básico
La palabra clave async se utiliza para declarar una función que devuelve una Promise. Esto significa que cualquier valor retornado se envuelve automáticamente en una Promise resuelta. Por ejemplo:
const obtenerValor = async () => {
return { dato: 'Ejemplo' };
};
console.log(obtenerValor()); // Promise { { dato: 'Ejemplo' } }
Al usar async, garantizamos que la función devuelva una Promise, permitiendo el encadenamiento con métodos como .then().
El papel de await en combinación con async
¿Qué espera await?
await solo puede usarse dentro de funciones async. Suspende la ejecución hasta que la Promise se resuelva y devuelve el valor resultante. Un ejemplo simple:
const esperarDatos = async () => {
const promesa = new Promise((resolver) => {
setTimeout(() => resolver('Resultado'), 1000);
});
const valor = await promesa;
console.log(valor); // Imprime 'Resultado' después de 1 segundo
};
esperarDatos();
Además, await puede aplicarse a cualquier expresión, no solo a Promises. Si la expresión no es una Promise, devuelve el valor directamente:
const calcular = () => 42;
const usarAwait = async () => {
const resultado = await calcular();
console.log(resultado); // Imprime 42
};
usarAwait();
Comportamiento de await tras la resolución
Cuando await recibe una Promise, bloquea temporalmente la ejecución del código posterior hasta que la Promise se resuelva. El valor resuelto se usa como resultado de la operación await. Esto simula un comportamiento síncrono mientras mantiene la naturaleza asíncrona subyacente.
Relación entre async/await y Promises
async/await actúa como azúcar sintáctico sobre Promises y Generadores, pero con mejoras significativas:
- Incluye un ejecutor integrado, eliminando la necesidad de bibliotecas externas para la ejecución.
- Mejora la semántica del código, haciéndolo más intuitivo.
- Devuelve Promises de manera explícita, lo que facilita la composición y manejo de errores.
Solución al infierno de callbacks
Un escenario común donde async/await brilla es al encadenar operaciones dependientes. Consideremos un flujo donde se necesita iniciar sesión, obtener un token y luego usar ese token en otras solicitudes.
Enfoque con callbacks anidados (difícil de mantener):
solicitar('login', { usuario, contrasena }, ({ idUsuario }) => {
solicitar('obtenerToken', { idUsuario }, ({ token }) => {
solicitar('datosPrivados', { token }, resultado => {
// Procesar resultado
});
});
});
Con Promises encadenadas, mejora la legibilidad:
solicitar('login', { usuario, contrasena })
.then(({ idUsuario }) => solicitar('obtenerToken', { idUsuario }))
.then(({ token }) => solicitar('datosPrivados', { token }))
.then(resultado => {
// Procesar resultado
});
Usando async/await, el código se vuelve más claro y lineal:
async function accederDatos({ usuario, contrasena }) {
const { idUsuario } = await solicitar('login', { usuario, contrasena });
const { token } = await solicitar('obtenerToken', { idUsuario });
const resultado = await solicitar('datosPrivados', { token });
// Procesar resultado
}
Las solicitudes se ejecutan secuencialmente en términos de sintaxis, mejorando la comprensión y mantenimiento.
Ventajas y desventajas
Ventajas
- Sintaxis concisa que mejora la legibilidad del código.
- Permite el uso de bloques
try/catchpara el manejo de errores. - El código sigue una lógica más natural y predecible.
Desventajas
- Requiere transpilación con herramientas como Babel para entornos que no soporten la sintaxis moderna.
- Falta de soporte nativo para cancelar solicitudes o controlar flujos complejos.
- El manejo de errores puede ser complicado en casos con múltiples operaciones.
- Para operaciones sin dependencia, es necesario combinar con
Promise.allpara paralelismo.
Manejo elegante de errores en async/await
Una forma común de manejar errores es mediante try/catch. Sin embargo, para múltiples await, esto puede volverbise verboso. Una técnica útil es envolver las Promises en una utilidad que devuelva tuplas con error y datos:
function envolverPromesa(promesa) {
return promesa.then(datos => [null, datos]).catch(err => [err]);
}
async function obtenerInformacion({ usuario, contrasena }) {
let idUsuario, token, error;
[error, { idUsuario }] = await envolverPromesa(solicitar('login', { usuario, contrasena }));
if (error) {
// Manejar error de login
return;
}
[error, { token }] = await envolverPromesa(solicitar('obtenerToken', { idUsuario }));
if (error) {
// Manejar error de token
return;
}
// Continuar con otras operaciones
}
Este enfoque centraliza el manejo de errores y evita anidamientos excesivos.
¿Deberían todas las funciones ser async?
Podría parecer tentador definir todas las funciones como async y usar await en todas partes, pero esto tiene implicaciones importantes. JavaScript es mono-hilo y usa el Event Loop para operaciones asíncronas. Las funciones async con await ceden el control al Event Loop, lo que puede afectar el acceso a recursos compartidos.
Consideremos dos enfoques:
let contador = 0;
function sincrona() {
incrementarContador();
decrementarContador();
return contador++;
}
async function asincrona() {
await incrementarContador();
await decrementarContador();
return contador++;
}
En sincrona(), toda la ejecución ocurre en un solo evento, garantizando un acceso secuencial a contador. En cambio, en asincrona(), los await permiten que otros eventos modifiquen contador entre operaciones, introduciendo posibles condiciones de carrera.
Por lo tanto, async/await no es solo una preferencia estilística; indica explícitamente puntos donde el control se transfiere al Event Loop. Esto ayuda a los desarrolaldores a identificar y manejar adecuadamente los recursos compartidos, manteniendo la previsibilidad del código.