JavaScript: ¿Cómo funciona? + bucle de eventos y el auge de la programación asincrónica
JavaScript
En esta publicación exploramos JavaScript y sus componentes de construcción. En el proceso de identificar y describir los elementos centrales, también compartimos algunas reglas generales que usamos al construir SessionStack, una aplicación JavaScript que debe ser robusta y de alto rendimiento para seguir siendo competitiva.
En esta ocasión, revisaremos los inconvenientes de la programación en un entorno de un solo subproceso y cómo superarlos para crear impresionantes IU de JavaScript. Al final del artículo compartiremos 5 consejos sobre cómo escribir código más limpio con async / await.
¿Por qué tener un solo hilo es una limitación?
Reflexionemos sobre la siguiente cuestión: qué sucede cuando tiene llamadas a funciones en la pila de llamadas que requieren una gran cantidad de tiempo para procesarse.
Imagine, por ejemplo, un complejo algoritmo de transformación de imágenes que se ejecuta en el navegador.
Si bien la pila de llamadas tiene funciones que ejecutar, el navegador no puede hacer nada más: está bloqueado. Esto significa que el navegador no puede renderizar, no puede ejecutar ningún otro código, simplemente está bloqueado. Y aquí viene el problema: la interfaz de usuario de su aplicación ya no es eficiente y agradable.
Su aplicación está bloqueada.
En algunos casos, esto puede no ser un problema tan crítico. Pero bueno, aquí hay un problema aún mayor. Una vez que su navegador comienza a procesar demasiadas tareas en la Pila de llamadas, puede dejar de responder durante mucho tiempo. En ese momento, muchos navegadores tomarían medidas al generar un error y preguntarían si deberían terminar la página:
Es feo y arruina por completo tu experiencia de usuario:
Los bloques de construcción de un programa JavaScript
Es posible que esté escribiendo su aplicación JavaScript en un solo archivo .js, pero es casi seguro que su programa se compone de varios bloques, solo uno de los cuales se ejecutará ahora, y el resto se ejecutará más tarde. La unidad de bloque más común es la función.
El problema que la mayoría de los desarrolladores nuevos en JavaScript parecen tener es comprender que más adelante no sucede necesariamente de manera estricta e inmediata después de ahora. En otras palabras, las tareas que no se pueden completar ahora, por definición, se completarán de forma asincrónica, lo que significa que no tendrá el comportamiento de bloqueo mencionado anteriormente como podría haber esperado o esperado inconscientemente.
Veamos el siguiente ejemplo:
Probablemente sepa que las solicitudes Ajax estándar no se completan de forma síncrona, lo que significa que en el momento de la ejecución del código, la función ajax (..) aún no tiene ningún valor para volver a ser asignado a una variable de respuesta.
Una forma sencilla de “esperar” a que una función asincrónica devuelva su resultado es utilizar una función llamada callback:
Solo una nota: en realidad, puede realizar solicitudes Ajax sincrónicas. Nunca jamás hagas eso. Si realiza una solicitud Ajax sincrónica, la interfaz de usuario de su aplicación JavaScript se bloqueará: el usuario no podrá hacer clic, ingresar datos, navegar ni desplazarse. Esto evitaría cualquier interacción del usuario. Es una práctica terrible.
Así es como se ve, pero por favor, nunca haga esto, no arruine la Web:
Usamos una solicitud Ajax solo como ejemplo. Puede hacer que cualquier fragmento de código se ejecute de forma asincrónica.
Esto se puede hacer con la función setTimeout(callback, milliseconds)
. Lo que hace la función setTimeout
es configurar un evento (un tiempo de espera) para que suceda más tarde. Vamos a ver:
La salida en la consola será la siguiente:
Disección del bucle de eventos
Comenzaremos con una afirmación un tanto extraña, a pesar de permitir código JavaScript asíncrono (como el [ setTimeout ] que acabamos de discutir), hasta ES6, JavaScript en sí nunca ha tenido una noción directa de asincronía incorporada. El motor de JavaScript nunca ha hecho nada más que ejecutar una sola parte de su programa en un momento dado.
Entonces, ¿quién le dice a JavaScript Engine que ejecute partes de su programa? En realidad, JS Engine no se ejecuta de forma aislada, se ejecuta dentro de un entorno de alojamiento, que para la mayoría de los desarrolladores es el típico navegador web o Node.js. De hecho, hoy en día, JavaScript se integra en todo tipo de dispositivos, desde robots hasta bombillas. Cada dispositivo representa un tipo diferente de entorno de alojamiento para JS Engine.
El denominador común en todos los entornos es un mecanismo incorporado llamado bucle de eventos, que maneja la ejecución de múltiples partes de su programa a lo largo del tiempo, cada vez que invoca el motor JS.
Esto significa que JS Engine es solo un entorno de ejecución bajo demanda para cualquier código JS arbitrario. Es el entorno circundante el que programa los eventos (las ejecuciones del código JS).
Entonces, por ejemplo, cuando su programa JavaScript realiza una solicitud Ajax para obtener algunos datos del servidor, configura el código de “respuesta” en una función (la “devolución de llamada”), y JS Engine le dice al entorno de alojamiento:
“Oye, voy a suspender la ejecución por ahora, pero cuando termines con esa solicitud de red y tengas algunos datos, vuelve a llamar a esta función”.
Luego, el navegador se configura para escuchar la respuesta de la red, y cuando tenga algo que devolverle, programará la función de devolución de llamada para que se ejecute insertándola en el bucle de eventos.
Veamos el siguiente diagrama:
¿Y qué son estas API web? En esencia, son hilos a los que no puede acceder, simplemente puede llamarlos. Son las partes del navegador en las que se activa la simultaneidad. Si eres un desarrollador de Node.js, estas son las API de C ++.
Entonces, ¿qué es el ciclo de eventos después de todo?
El bucle de eventos tiene un trabajo simple: monitorear la pila de llamadas y la cola de devolución de llamadas. Si la pila de llamadas está vacía, tomará el primer evento de la cola y lo enviará a la pila de llamadas, que lo ejecuta de forma eficaz.
Tal iteración se denomina ‘tick‘ en el bucle de eventos. Cada evento es solo una función de callback (devolución de llamada).
“Ejecutemos” este código y veamos qué sucede:
1 | El estado es claro. La consola del navegador está limpia y la pila de llamadas está vacía.
2 | Se agrega console.log('Hi')
a la Pila de llamadas.
3 | Se ejecuta console.log('Hi')
4 | console.log('Hi')
se elimina de la pila de llamadas.
5 | setTimeout(function cb1() { ... })
se agrega a la pila de llamadas.
6 | setTimeout(function cb1() { ... })
se ejecuta. El navegador crea un temporizador como parte de las API web. Se encargará de la cuenta atrás por usted.
7. El setTimeout(function cb1() { ... })
en sí está completo y se elimina de la pila de llamadas.
8 | console.log('Bye')
se agrega a la pila de llamadas
9 | console.log('Bye')
es ejecutado
10 | console.log('Bye')
se elimina de la pila de llamadas
11 | Después de al menos 5000 ms, el temporizador se completa y envía la devolución de llamada cb1 a la cola de devolución de llamada.
12 | El bucle de eventos toma cb1
de la cola de devolución de llamada y lo envía a la pila de llamadas.
13 | cb1
se ejecuta y agrega console.log('cb1')
a la pila de llamadas
14 | console.log('cb1')
es ejecutado
15 | console.log('cb1')
se elimina de la pila de llamadas
16 | cb1
se elimina de la pila de llamadas
Un resumen rápido:
Es interesante notar que ES6 especifica cómo debe funcionar el bucle de eventos, lo que significa que técnicamente está dentro del alcance de las responsabilidades del motor JS, que ya no es solo una función del entorno de alojamiento. Una razón principal de este cambio es la introducción de Promises en ES6 porque este último requiere acceso a un control directo y detallado sobre las operaciones de programación en la cola del bucle de eventos (las discutiremos con mayor detalle más adelante).
Cómo funciona setTimeout (…)
Es importante tener en cuenta que setTimeout(…)
no coloca automáticamente su devolución de llamada en la cola del bucle de eventos. Configura un temporizador. Cuando el temporizador expira, el entorno coloca su devolución de llamada en el bucle de eventos, de modo que algún tic futuro lo recogerá y lo ejecutará. Eche un vistazo a este código:
Eso no significa que myCallback
se ejecutará en 1,000 ms, sino que, en 1,000 ms, myCallback
se agregará a la cola. Sin embargo, es posible que la cola tenga otros eventos que se hayan agregado anteriormente; su devolución de llamada tendrá que esperar.
Hay bastantes artículos y tutoriales sobre cómo comenzar con el código asíncrono en JavaScript que sugieren hacer un setTimeout (callback, 0). Bueno, ahora ya sabe lo que hace el bucle de eventos y cómo funciona setTimeout: llamar a setTimeout con 0 como segundo argumento solo pospone la devolución de llamada hasta que la pila de llamadas esté limpia.
Eche un vistazo al siguiente código:
Aunque el tiempo de espera se establece en 0 ms, el resultado en la consola del navegador será el siguiente:
¿Qué son los trabajos en ES6?
En ES6 se introdujo un nuevo concepto denominado “Cola de trabajos”. Es una capa encima de la cola de eventos. Es más probable que se tope con él cuando se enfrente al comportamiento asincrónico de Promises (también hablaremos de ellos).
Simplemente tocaremos el concepto ahora para que cuando analicemos el comportamiento asincrónico con Promises, más adelante, comprenda cómo se programan y procesan esas acciones.
Imagínelo así: la cola de trabajos es una cola que se adjunta al final de cada tick en la cola de bucle de eventos. Ciertas acciones asíncronas que pueden ocurrir durante un tick del bucle de eventos no harán que se agregue un evento completamente nuevo a la cola del bucle de eventos, sino que agregarán un elemento (también conocido como Trabajo) al final de la cola de trabajos del tick actual.
Esto significa que puede agregar otra funcionalidad para que se ejecute más tarde, y puede estar seguro de que se ejecutará inmediatamente después, antes que nada.
Un trabajo también puede hacer que se agreguen más trabajos al final de la misma cola. En teoría, es posible que un “bucle” de trabajo (un trabajo que sigue agregando otros trabajos, etc.) gire indefinidamente, privando al programa de los recursos necesarios para pasar al siguiente ciclo de eventos. Conceptualmente, esto sería similar a simplemente expresar un bucle infinito o de larga duración (como while (true)
..) en su código.
Los trabajos son como el “truco” setTimeout(callback, 0)
, pero implementados de tal manera que introducen un orden mucho más definido y garantizado: más tarde, pero lo antes posible.
Callbacks (Devoluciones de llamada)
Como ya sabe, las devoluciones de llamada son, con mucho, la forma más común de expresar y administrar la asincronía en programas JavaScript. De hecho, la devolución de llamada es el patrón asíncrono más fundamental en el lenguaje JavaScript. Innumerables programas JS, incluso los muy sofisticados y complejos, se han escrito sobre ninguna otra base asincrónica que la devolución de llamada.
Excepto que las devoluciones de llamada no vienen sin defectos. Muchos desarrolladores están intentando encontrar mejores patrones asíncronos. Sin embargo, es imposible utilizar eficazmente cualquier abstracción si no comprende lo que hay realmente debajo del capó.
En el siguiente capítulo, exploraremos un par de estas abstracciones en profundidad para mostrar por qué los patrones asíncronos más sofisticados (que se discutirán en publicaciones posteriores) son necesarios e incluso recomendados.
Devoluciones de llamada anidadas
Mira el siguiente código:
Tenemos una cadena de tres funciones anidadas juntas, cada una de las cuales representa un paso en una serie asincrónica.
Este tipo de código a menudo se denomina “infierno de devolución de llamada”. Pero el “infierno de devolución de llamada” en realidad no tiene casi nada que ver con el anidamiento / sangría. Es un problema mucho más profundo que eso.
Primero, estamos esperando el evento de “clic”, luego estamos esperando a que se active el temporizador, luego estamos esperando a que vuelva la respuesta del Ajax, momento en el que podría repetirse todo nuevamente.
A primera vista, este código puede parecer que asigna su asincronía de forma natural a pasos secuenciales como:
Entonces nosotros tenemos:
Luego, más tarde tenemos:
Y finalmente:
Entonces, una forma secuencial de expresar su código asíncrono parece mucho más natural, ¿no es así? Debe haber tal forma, ¿verdad?
Promesas
Eche un vistazo al siguiente código:
Todo es muy sencillo: suma los valores de x
e y
y los imprime en la consola. ¿Qué pasa si, sin embargo, falta el valor de x
o y
aún no se determina? Digamos, necesitamos recuperar los valores de x
e y
del servidor, antes de que puedan usarse en la expresión. Imaginemos que tenemos una función loadX
y loadY
que cargan respectivamente los valores de x
e y
del servidor. Luego, imagina que tenemos una función sum
que suma los valores de x
e y
una vez que ambos están cargados.
Podría verse así (bastante feo, ¿no es así):
Hay algo muy importante aquí: en ese fragmento, tratamos x
e y
como valores futuros, y expresamos una operación sum(…)
a la que (desde el exterior) no le importaba si x
e y
, o ambos estaban o no disponibles inmediatamente.
Por supuesto, este enfoque aproximado basado en devoluciones de llamada deja mucho que desear. Es solo un primer pequeño paso hacia la comprensión de los beneficios de razonar sobre valores futuros sin preocuparse por el aspecto del tiempo de cuándo estarán disponibles.
Valor de la promesa
Veamos brevemente cómo podemos expresar el ejemplo x + y
con Promesas:
Hay dos capas de promesas en este fragmento.
fetchX()
y fetchY()
se llaman directamente, y los valores que devuelven (¡promesas!) se pasan a sum(...)
. Los valores subyacentes que representan estas promesas pueden estar listos ahora o más tarde, pero cada promesa normaliza su comportamiento para que sea el mismo independientemente. Razonamos sobre los valores de x
e y
de forma independiente del tiempo. Son valores futuros, punto.
La segunda capa es la promesa de que sum(...)
crea (a través de Promise.all([ ... ])
) y regresa, que esperamos llamando then(...)
. Cuando se completa la operación de sum(...)
, nuestro valor futuro de suma está listo y podemos imprimirlo. Ocultamos la lógica para esperar los valores futuros x
e y
dentro de sum(...)
.
Nota: Dentro de sum(...)
, la llamada Promise.all([ ... ])
crea una promesa (que está esperando a promiseX
y promiseY
para resolverse). La llamada encadenada a .then(...)
crea otra promesa, que el retorno values[0] + values[1]
línea resuelve inmediatamente (con el resultado de la suma). Por lo tanto, la llamada then(...)
que encadenamos al final de la llamada sum(...)
, al final del fragmento, en realidad está operando en esa segunda promesa devuelta, en lugar de la primera creada por Promise.all([ ... ])
. Además, aunque no estamos encadenando el final de ese segundo then(...)
, también ha creado otra promesa, si hubiéramos elegido observarlo / usarlo. Este tema del encadenamiento de promesas se explicará con mucho más detalle más adelante en este capítulo.
Con Promises, la llamada then(...)
puede tomar dos funciones, la primera para el cumplimiento (como se mostró anteriormente) y la segunda para el rechazo:
Si algo salió mal al obtener x
o y
, o algo falló de alguna manera durante la adición, la promesa de que sum(...)
devuelve sería rechazada, y el segundo controlador de error de devolución de llamada pasado a then(...)
recibiría el rechazo valor de la promesa.
Debido a que las Promesas encapsulan el estado dependiente del tiempo (esperar el cumplimiento o el rechazo del valor subyacente) desde el exterior, la Promesa en sí es independiente del tiempo y, por lo tanto, las Promesas se pueden componer (combinar) de maneras predecibles independientemente del momento o el resultado. debajo.
Además, una vez que se resuelve una Promesa, permanece así para siempre (se convierte en un valor inmutable en ese punto) y luego se puede observar tantas veces como sea necesario.
Es realmente útil que puedas encadenar promesas:
La llamada en delay(2000)
crea una promesa que se cumplirá en 2000 ms, y luego la devolvemos desde la primera devolución de llamada then(...)
de cumplimiento, lo que provoca la promesa del segundo then(...)
de esperar esa promesa de 2000 ms.
Nota: Debido a que una Promesa es inmutable externamente una vez resuelta, ahora es seguro pasar ese valor a cualquier parte, sabiendo que no se puede modificar accidental o maliciosamente. Esto es especialmente cierto en relación con múltiples partes que observan la resolución de una Promesa. No es posible que una de las partes afecte la capacidad de la otra para cumplir la resolución de la Promesa. La inmutabilidad puede parecer un tema académico, pero en realidad es uno de los aspectos más fundamentales e importantes del diseño de Promise y no debe pasarse por alto.
¿Prometer o no prometer?
Un detalle importante acerca de Promesas es saber con certeza si algún valor es una Promesa real o no. En otras palabras, ¿es un valor que se comportará como una Promesa?
Sabemos que las promesas se construyen con la sintaxis de new Promise(…)
, y podría pensar que una p instanceof Promise
sería una comprobación suficiente. Bueno, no del todo.
Principalmente porque puede recibir un valor de Promesa de otra ventana del navegador (por ejemplo, iframe), que tendría su propia Promesa, diferente de la de la ventana o marco actual, y esa verificación no identificaría la instancia de Promise.
Además, una biblioteca o marco puede optar por vender sus propias Promesas y no utilizar la implementación nativa de Promesas de ES6 para hacerlo. De hecho, es muy posible que esté usando Promises con bibliotecas en navegadores más antiguos que no tienen Promise en absoluto.
Tragar excepciones
Si en cualquier momento de la creación de una Promesa, o en la observación de su resolución, se produce un error de excepción de JavaScript, como un TypeError
o ReferenceError
, esa excepción se detectará y obligará a que la Promesa en cuestión sea rechazada.
Por ejemplo:
Pero, ¿qué sucede si se cumple una Promesa pero hubo un error de excepción JS durante la observación (en una devolución de llamada registrada en ese then(…)
? Aunque no se pierda, es posible que encuentre un poco sorprendente la forma en que se manejan. Hasta que profundices un poco más:
Parece que la excepción de foo.bar()
realmente se tragó. Sin embargo, no lo fue. Sin embargo, hubo algo más profundo que salió mal y que no escuchamos. La llamada p.then(…)
en sí misma devuelve otra promesa, y es esa promesa la que se rechazará con la excepción TypeError
.
Manejo de excepciones no detectadas
Hay otros enfoques que muchos dirían que son mejores.
Una sugerencia común es que a las Promesas se les debe agregar un done(…)
, que esencialmente marca la cadena Promesa como “hecha”. done(…)
no crea ni devuelve una Promesa, por lo que las devoluciones de llamada pasadas a done(…)
obviamente no están conectadas para informar problemas a una Promesa encadenada que no existe.
Se trata como es de esperar normalmente en condiciones de error no detectado: cualquier excepción dentro de un controlador de rechazo done(…)
se lanzaría como un error no detectado global (en la consola del desarrollador, básicamente):
¿Qué está pasando en ES8? Async / await
JavaScript ES8 introdujo async/await
que facilita el trabajo de trabajar con Promises. Veremos brevemente las posibilidades de ofertas async/await
y cómo aprovecharlas para escribir código asíncrono.
Entonces, veamos cómo funciona async / await.
Una función asíncrona se define mediante la declaración de función async
. Estas funciones devuelven un objeto AsyncFunction. El objeto AsyncFunction
representa la función asincrónica que ejecuta el código, contenido dentro de esa función.
Cuando se llama a una función asíncrona, devuelve una Promise
. Cuando la función asincrónica devuelve un valor, que no es una Promise
, se creará una Promise
automáticamente y se resolverá con el valor devuelto por la función. Cuando la función async
arroja una excepción, la Promesa se rechazará con el valor arrojado.
Una función async
puede contener una expresión de await
, que pausa la ejecución de la función y espera la resolución de la Promesa pasada, y luego reanuda la ejecución de la función asíncrona y devuelve el valor resuelto.
Puede pensar en una Promise
en JavaScript como el equivalente de Future de Java o Tarea de C#
.
El propósito de
async/await
es simplificar el comportamiento del uso de promesas.
Veamos el siguiente ejemplo:
De manera similar, las funciones que generan excepciones son equivalentes a las funciones que devuelven promesas que han sido rechazadas:
La palabra clave await
solo se puede usar en funciones async
y le permite esperar sincrónicamente una Promesa. Si usamos promesas fuera de una función async
, aún tendremos que usar las devoluciones de llamada then
:
También puede definir funciones asíncronas utilizando una “expresión de función asíncrona”. Una expresión de función asíncrona es muy similar y tiene casi la misma sintaxis que una declaración de función asíncrona. La principal diferencia entre una expresión de función asíncrona y una declaración de función asíncrona es el nombre de la función, que se puede omitir en las expresiones de función asíncrona para crear funciones anónimas. Una expresión de función asíncrona se puede utilizar como IIFE (Expresión de función invocada inmediatamente) que se ejecuta tan pronto como se define.
Se parece a esto:
Más importante aún, async / await es compatible con todos los navegadores principales:
Al final del día, lo importante es no elegir a ciegas el enfoque “más reciente” para escribir código asincrónico. Es esencial comprender los aspectos internos de JavaScript asíncrono, aprender por qué es tan crítico y comprender en profundidad los aspectos internos del método que ha elegido. Cada enfoque tiene pros y contras como con todo lo demás en programación.