JavaScript asíncrono: de Callback Hell a Async and Await
Publicado: 2022-03-11Una de las claves para escribir una aplicación web exitosa es poder realizar docenas de llamadas AJAX por página.
Este es un desafío típico de programación asincrónica, y la forma en que elija manejar las llamadas asincrónicas, en gran parte, hará o deshabilitará su aplicación y, por extensión, potencialmente toda su puesta en marcha.
La sincronización de tareas asincrónicas en JavaScript fue un problema grave durante mucho tiempo.
Este desafío está afectando tanto a los desarrolladores de back-end que usan Node.js como a los desarrolladores de front-end que usan cualquier marco de JavaScript. La programación asíncrona es parte de nuestro trabajo diario, pero el desafío a menudo se toma a la ligera y no se considera en el momento adecuado.
Una breve historia de JavaScript asíncrono
La primera y más sencilla solución se presentó en forma de funciones anidadas como devoluciones de llamada . Esta solución condujo a algo llamado infierno de devolución de llamada, y demasiadas aplicaciones todavía sienten la quemadura de eso.
Entonces, tenemos Promesas . Este patrón hizo que el código fuera mucho más fácil de leer, pero estaba muy lejos del principio Don't Repeat Yourself (DRY). Todavía había demasiados casos en los que tenía que repetir las mismas piezas de código para administrar correctamente el flujo de la aplicación. La última incorporación, en forma de declaraciones async/await, finalmente hizo que el código asíncrono en JavaScript fuera tan fácil de leer y escribir como cualquier otra pieza de código.
Echemos un vistazo a los ejemplos de cada una de estas soluciones y reflexionemos sobre la evolución de la programación asíncrona en JavaScript.
Para ello, examinaremos una tarea sencilla que realiza los siguientes pasos:
- Verificar el nombre de usuario y la contraseña de un usuario.
- Obtenga roles de aplicación para el usuario.
- Registrar el tiempo de acceso a la aplicación para el usuario.
Enfoque 1: Infierno de devolución de llamada ("La pirámide de la perdición")
La solución antigua para sincronizar estas llamadas era a través de devoluciones de llamadas anidadas. Este era un enfoque decente para tareas de JavaScript asincrónicas simples, pero no escalaba debido a un problema llamado callback hell.
El código para las tres tareas simples se vería así:
const verifyUser = function(username, password, callback){ dataBase.verifyUser(username, password, (error, userInfo) => { if (error) { callback(error) }else{ dataBase.getRoles(username, (error, roles) => { if (error){ callback(error) }else { dataBase.logAccess(username, (error) => { if (error){ callback(error); }else{ callback(null, userInfo, roles); } }) } }) } }) };
Cada función recibe un argumento que es otra función que se llama con un parámetro que es la respuesta de la acción anterior.
Demasiadas personas experimentarán un congelamiento cerebral con solo leer la oración anterior. Tener una aplicación con cientos de bloques de código similares causará aún más problemas a la persona que mantiene el código, incluso si lo escribieron ellos mismos.
Este ejemplo se vuelve aún más complicado una vez que te das cuenta de que una database.getRoles
de datos.getRoles es otra función que tiene devoluciones de llamadas anidadas.
const getRoles = function (username, callback){ database.connect((connection) => { connection.query('get roles sql', (result) => { callback(null, result); }) }); };
Además de tener un código que es difícil de mantener, el principio DRY no tiene absolutamente ningún valor en este caso. El manejo de errores, por ejemplo, se repite en cada función y la devolución de llamada principal se llama desde cada función anidada.
Las operaciones JavaScript asincrónicas más complejas, como el bucle a través de llamadas asincrónicas, son un desafío aún mayor. De hecho, no hay una forma trivial de hacer esto con devoluciones de llamada. Esta es la razón por la cual las bibliotecas de JavaScript Promise como Bluebird y Q obtuvieron tanta tracción. Proporcionan una forma de realizar operaciones comunes en solicitudes asincrónicas que el propio lenguaje aún no proporciona.
Ahí es donde entran en juego las promesas nativas de JavaScript.
Promesas de JavaScript
Las promesas eran el siguiente paso lógico para escapar del infierno de devolución de llamadas. Este método no eliminó el uso de devoluciones de llamada, pero facilitó el encadenamiento de funciones y simplificó el código, haciéndolo mucho más fácil de leer.

Con Promises en su lugar, el código en nuestro ejemplo de JavaScript asíncrono se vería así:
const verifyUser = function(username, password) { database.verifyUser(username, password) .then(userInfo => dataBase.getRoles(userInfo)) .then(rolesInfo => dataBase.logAccess(rolesInfo)) .then(finalResult => { //do whatever the 'callback' would do }) .catch((err) => { //do whatever the error handler needs }); };
Para lograr este tipo de simplicidad, todas las funciones utilizadas en el ejemplo tendrían que ser Promisified . Echemos un vistazo a cómo se actualizaría el método getRoles
para devolver una Promise
:
const getRoles = function (username){ return new Promise((resolve, reject) => { database.connect((connection) => { connection.query('get roles sql', (result) => { resolve(result); }) }); }); };
Hemos modificado el método para devolver una Promise
, con dos devoluciones de llamada, y la Promise
misma realiza acciones desde el método. Ahora, las devoluciones de llamada de resolve
y reject
se asignarán a los métodos Promise.then
y Promise.catch
respectivamente.
Puede notar que el método getRoles
todavía es propenso internamente al fenómeno de la pirámide de la perdición. Esto se debe a la forma en que se crean los métodos de la base de datos, ya que no devuelven Promise
. Si nuestros métodos de acceso a la base de datos también devolvieran Promise
, el método getRoles
tendría el siguiente aspecto:
const getRoles = new function (userInfo) { return new Promise((resolve, reject) => { database.connect() .then((connection) => connection.query('get roles sql')) .then((result) => resolve(result)) .catch(reject) }); };
Enfoque 3: Async/Await
La pirámide de la perdición se mitigó significativamente con la introducción de Promises. Sin embargo, todavía teníamos que depender de las devoluciones de llamada que se pasan a los métodos .catch
.then
Promise
.
Las promesas allanaron el camino hacia una de las mejores mejoras en JavaScript. ECMAScript 2017 incorporó azúcar sintáctico además de Promises en JavaScript en forma de declaraciones async
y de await
.
Nos permiten escribir código basado en Promise
como si fuera sincrónico, pero sin bloquear el hilo principal, como lo demuestra este ejemplo de código:
const verifyUser = async function(username, password){ try { const userInfo = await dataBase.verifyUser(username, password); const rolesInfo = await dataBase.getRoles(userInfo); const logStatus = await dataBase.logAccess(userInfo); return userInfo; }catch (e){ //handle errors as needed } };
Esperar la Promise
de resolver solo se permite dentro de las funciones async
, lo que significa que verifyUser
el usuario tuvo que definirse mediante async function
.
Sin embargo, una vez realizado este pequeño cambio, puede await
cualquier Promise
sin cambios adicionales en otros métodos.
Async: una resolución largamente esperada de una promesa
Las funciones asíncronas son el siguiente paso lógico en la evolución de la programación asíncrona en JavaScript. Harán que su código sea mucho más limpio y fácil de mantener. Declarar una función como async
garantizará que siempre devuelva una Promise
para que ya no tenga que preocuparse por eso.
¿Por qué debería comenzar a usar la función async
de JavaScript hoy?
- El código resultante es mucho más limpio.
- El manejo de errores es mucho más simple y se basa en
try
/catch
como en cualquier otro código síncrono. - La depuración es mucho más simple. Establecer un punto de interrupción dentro de un bloque
.then
no se moverá al siguiente.then
porque solo pasa por el código síncrono. Sin embargo, puede pasar por las llamadas enawait
como si fueran llamadas sincrónicas.