Si solo está comenzando con la programación, lo más probable es que esté pensando en los programas como un conjunto de bloques secuenciales de lógica, donde cada bloque hace una cosa específica y pasa su resultado para que el siguiente bloque pueda ejecutarse y así sucesivamente, y en su mayor parte tiene razón, la mayoría de los programas se ejecutan de manera secuencial, este modelo nos permite crear programas que son fáciles de escribir y mantener. Sin embargo, hay casos de uso específicos en los que este modelo secuencial no funcionaría o no sería óptimo. Como ejemplo, considere una aplicación de lector de libros. Esta aplicación tiene algunas funciones avanzadas, como encontrar todas las apariciones de una palabra, navegar entre marcadores y similares. Ahora imagine que el usuario está leyendo un libro largo y decide buscar todas las apariciones de una palabra común como "El". La aplicación normalmente tardará un par de segundos en encontrar e indexar todas las apariciones de esa palabra. En un programa secuencial, el usuario no puede interactuar con la aplicación (cambiar de página o resaltar un texto) hasta que se cumpla la operación de búsqueda. Con suerte, puede ver que esa no es una experiencia de usuario óptima.
El diagrama ilustra un flujo de ejecución típico de la aplicación del lector de libros. Si el usuario inicia una operación de ejecución prolongada (en este caso, la búsqueda de todas las apariciones de "el" en un libro grande), la aplicación se "congela" durante toda la duración de esa operación. En este caso, el usuario seguirá haciendo clic en el siguiente botón de marcador sin ningún resultado hasta que finalice la operación de búsqueda y todas las operaciones surtirán efecto a la vez, dando al usuario final la sensación de una aplicación retrasada.
Es posible que haya notado que este ejemplo no se corresponde realmente con el modelo secuencial que presentamos anteriormente. Esto se debe a que las operaciones aquí son independientes entre sí. El usuario no necesita conocer el número de apariciones de "el" para navegar al siguiente marcador, por lo que el orden de ejecución de las operaciones no es realmente importante. No tenemos que esperar al final de la operación de búsqueda para poder navegar al siguiente marcador. Una posible mejora del flujo de ejecución anterior se basa en esta lógica: podemos ejecutar la operación de búsqueda larga en segundo plano, continuar con cualquier operación entrante y, una vez que se realiza la operación larga, simplemente podemos notificar al usuario. El flujo de ejecución se convierte en el siguiente:
Con este flujo de ejecución, la experiencia del usuario mejora significativamente. Ahora el usuario puede iniciar una operación de larga duración, continuar con el uso normal de la aplicación y recibir una notificación una vez finalizada la operación. Esta es la base de la programación asíncrona.
Javascript, entre otros lenguajes, es compatible con este estilo de programación asincrónica al proporcionar API extensas para lograr casi cualquier comportamiento asincrónico que pueda imaginar. Al final del día, Javascript debería ser inherentemente un lenguaje asíncrono. Si nos referimos al ejemplo anterior, la lógica asíncrona está en la base de todas las aplicaciones de interacción con el usuario, y Javascript se creó principalmente para usarse en el navegador, donde la mayoría de los programas responden a las acciones del usuario.
Lo siguiente le dará una breve guía sobre Javascript asíncrono:
Devoluciones de llamada
En un programa típico, normalmente encontrará una serie de funciones. Para usar una función, la llamamos con un conjunto de parámetros. El código de la función se ejecutará y devolverá un resultado, nada fuera de lo común. La programación asíncrona cambia ligeramente esta lógica. Volviendo al ejemplo de la aplicación del lector de libros, no podemos usar una función regular para implementar la lógica de la operación de búsqueda ya que la operación lleva una cantidad de tiempo desconocida. Básicamente, una función regular regresará antes de que se realice la operación, y este no es el comportamiento que esperamos. La solución es especificar otra función que se ejecutará una vez realizada la operación de búsqueda. Esto modela nuestro caso de uso ya que nuestro programa puede continuar su flujo normalmente y una vez que finaliza la operación de búsqueda, la función especificada se ejecutará para notificar al usuario los resultados de la búsqueda. Esta función es lo que llamamos una función de devolución de llamada:
// Search occurrences function
function searchOccurrences(word, callback) {
try {
// search operation logic, result is in result variable
//....
callback(null, word, result);
} catch (err) {
callback(err);
}
}
// Search occurrences callback function
function handleSearchOccurrencesResult(err, word, result) {
if (err) {
console.log(`Search operation for ${word} ended with an error`);
} else console.log(`Search results for ${word}: ${result}`);
return;
}
searchOccurrences("the", handleSearchOccurrencesResult);
Primero, definimos la función de operación de búsqueda, searchOccurrences. Toma la palabra a buscar y un segundo parámetro “callback” que será la función a ejecutar una vez realizada la operación de búsqueda. La función de la operación de búsqueda se mantuvo intencionalmente abstracta, solo necesitamos enfocarnos en sus dos posibles resultados: el primer caso es donde todo salió bien y tenemos el resultado de la búsqueda en la variable de resultado. En este caso, solo tenemos que llamar a la función de devolución de llamada con los siguientes parámetros: el primer parámetro es nulo, lo que significa que no ha ocurrido ningún error, el segundo parámetro es la palabra que se buscó y el tercer parámetro, y quizás el más importante de los tres, es el resultado de la operación de búsqueda.
El segundo caso es donde ocurre un error, este también es un caso donde se realiza la ejecución de la operación de búsqueda y tenemos que llamar a la función de devolución de llamada. Usamos un bloque de prueba y captura para interceptar cualquier error y simplemente llamamos a la función de devolución de llamada con el objeto de error del bloque de captura.
Luego definimos la función de devolución de llamada, handleSearchOccurrences, mantuvimos su lógica bastante simple. Solo es cuestión de imprimir un mensaje a la consola. Primero verificamos el parámetro "err" para ver si ocurrió algún error en la función principal. En ese caso, solo le informamos al usuario que la operación de búsqueda terminó con un error. Si no se generaron errores, imprimimos un mensaje con el resultado de la operación de búsqueda.
Finalmente, llamamos a la función buscarOcurrencias con la palabra “el”. La función ahora se ejecutará normalmente sin bloquear el programa principal y una vez que se realiza la búsqueda, se ejecutará la devolución de llamada y obtendremos el mensaje de resultado con el resultado de la búsqueda o el mensaje de error.
Es importante mencionar aquí que solo tenemos acceso a la variable de resultado dentro de las funciones principal y de devolución de llamada. Si intentamos algo como esto:
let result;
function searchOccurrences(word, callback) {
try {
// search operation logic, result is in searchResult variable
//....
result = searchResult;
callback(null, word, result);
} catch (err) {
callback(err);
}
}
searchOccurrences("the", handleSearchOccurrencesResult);
console.log(result);
el resultado de la impresión sería indefinido porque los programas no esperan a que se ejecute la función buscarOcurrencias. Pasa a la siguiente instrucción, que es la declaración de impresión antes de que se asigne la variable de resultado dentro de la función principal. Como resultado, imprimiremos la variable de resultado no asignada.
Entonces, según esta lógica, debemos mantener todo el código que usa la variable de resultado dentro de la función de devolución de llamada. Puede que esto no parezca un problema ahora, pero podría convertirse rápidamente en un problema real. Imagine el caso en el que tenemos una cadena de funciones asincrónicas que deben ejecutarse en secuencia. En la lógica de devolución de llamada típica, implementaría algo como esto:
functionA(function (err, resA) {
///......
functionB(resA, function (err, resB) {
///......
functionC(resB, function (err, resC) {
///......
functionD(resC, function (err, resD) {
///......
});
});
});
});
Tenga en cuenta que cada devolución de llamada tiene un parámetro de error y cada error debe manejarse por separado. Esto hace que el ya complejo código anterior sea aún más complejo y difícil de mantener. Con suerte, Promises está aquí para resolver el problema del infierno de devolución de llamada, lo cubriremos a continuación.
Promesas
Las promesas se construyen sobre las devoluciones de llamada y funcionan de manera similar. Se introdujeron como parte de las características de ES6 para resolver algunos de los problemas más evidentes con las devoluciones de llamadas, como el infierno de las devoluciones de llamadas. Las promesas proporcionan sus propias funciones que se ejecutan al completarse con éxito (resolver) y cuando se producen errores (rechazar). A continuación se muestra el ejemplo de searchOccurrences implementado con promesas:
// Search occurrences function
function searchOccurrences(word) {
return new Promise((resolve, reject) => {
try {
// search operation logic, result is in result variable
//....
resolve(word, result);
} catch (err) {
reject(err);
}
});
}
searchOccurrences("the")
.then((word, result) => {
console.log(`Search results for ${word}: ${result}`);
})
.catch((err) => {
console.log(`Search operation ended with an error`);
});
Repasemos los cambios que aplicamos:
La función searchOccurrences devuelve una promesa. Dentro de la promesa, mantenemos la misma lógica: tenemos dos funciones resolver y rechazar que representan nuestras devoluciones de llamada en lugar de tener una sola función de devolución de llamada que maneja tanto una ejecución exitosa como una ejecución con errores. Las promesas separan los dos resultados y proporcionan una sintaxis limpia al llamar a la función principal. La función de resolución está "enganchada" a la función principal mediante la palabra clave "entonces". Aquí solo especificamos los dos parámetros de la función de resolución e imprimimos el resultado de la búsqueda. Algo similar se aplica a la función de rechazo, se puede enganchar usando la palabra clave "catch". Con suerte, podrá apreciar las ventajas que ofrecen las promesas en términos de legibilidad y limpieza del código. Si aún lo está debatiendo, vea cómo podemos resolver el problema del infierno de la devolución de llamada encadenando las funciones asincrónicas para que se ejecuten una tras otra:
searchOccurrences("the")
.then(searchOccurrences("asynchronous"))
.then(searchOccurrences("javascript"))
.then(searchOccurrences("guide"))
.catch((err) => {
console.log(`Search operation ended with an error`);
});
Asíncrono/Espera
Async/Await es la última incorporación a nuestro cinturón de herramientas asíncrono en Javascript. Presentados con ES8, brindan una nueva capa de abstracción además de las funciones asíncronas simplemente "esperando" la ejecución de una operación asíncrona. El flujo del programa se bloquea en esa instrucción hasta que se devuelve un resultado de la operación asincrónica y luego el programa continúa con la siguiente instrucción. Si está pensando en el flujo de ejecución síncrono, tiene razón. ¡Hemos cerrado el círculo! Async/await intenta traer la simplicidad de la programación síncrona al mundo asíncrono. Tenga en cuenta que esto solo se percibe en la ejecución y el código del programa. Todo permanece igual debajo del capó, Async/await todavía usan promesas y las devoluciones de llamada son sus componentes básicos.
Repasemos nuestro ejemplo e implementémoslo usando Async/await:
async function searchOccurrences(word) {
try {
// search operation logic, result is in result variable
//....
return result;
} catch (err) {
console.log(`Search operation for ${word} ended with an error`);
}
}
const word = "the";
const result = await searchOccurrences(word, handleSearchOccurrencesResult);
console.log(`Search results for ${word}: ${result}`);
Nuestro código no cambió mucho, lo importante a notar aquí es la palabra clave "async" antes de la declaración de la función searchOccurrences. Esto indica que la función es asíncrona. Además, observe la palabra clave "aguardar" al llamar a la función searchOccurrences. Esto le indicará al programa que espere la ejecución de la función hasta que se devuelva el resultado antes de que el programa pueda pasar a la siguiente instrucción, en otras palabras, la variable de resultado siempre contendrá el valor devuelto por la función searchOccurrences y no la promesa de la función, en ese sentido, Async/Await no tiene un estado pendiente como Promises. Una vez que se realiza la ejecución, pasamos a la declaración de impresión y esta vez el resultado contiene realmente el resultado de la operación de búsqueda. Como era de esperar, el nuevo código tiene el mismo comportamiento que si fuera sincrónico.
Otra cosa menor a tener en cuenta es que, dado que ya no tenemos funciones de devolución de llamada, debemos manejar el error searchOccurrences dentro de la misma función, ya que no podemos simplemente propagar el error a la función de devolución de llamada y manejarlo allí. Aquí solo estamos imprimiendo un mensaje de error en caso de error por el bien del ejemplo.
Envolver
En este artículo, analizamos los diferentes enfoques utilizados para implementar la lógica asíncrona en Javascript. Comenzamos explorando un ejemplo concreto de por qué necesitaríamos cambiar del estilo de programación sincrónico regular al modelo asincrónico. Luego pasamos a las devoluciones de llamada, que son los principales componentes básicos de Javascript asíncrono. Las limitaciones de las devoluciones de llamada nos llevaron a las diferentes alternativas que se agregaron a lo largo de los años para superar estas limitaciones, principalmente promesas y Async/await. La lógica asíncrona se puede encontrar en cualquier parte de la web, ya sea que esté llamando a una API externa, iniciando una consulta de base de datos, escribiendo en el sistema de archivos local o incluso esperando la entrada del usuario en un formulario de inicio de sesión. Con suerte, ahora se siente más seguro para abordar estos problemas al escribir Javascript asincrónico limpio y mantenible.
Si le gusta este artículo, consulte el Blog de CLA donde analizamos varios temas sobre cómo ingresar a la tecnología. Además, consulte nuestro canal de youtube para ver nuestros talleres gratuitos anteriores y síganos en redes sociales para que no se pierda los próximos.