- Asincronía en JS
- Como funciona
- Event
- Callbacks
Una de las características de JS, es que es monohilo. Esto, a nivel técnico, significa que JS sólo una cosa puede ocurrir a la vez. Es decir, JS SOLO puede procesar una sentencia cada vez en ese hilo.
Esto tiene sus ventajas y desventajas, ya que si bien no tienes que preocuparte de la concurrencia, hay ciertas acciones (llamadas http, procesamiento de imágenes, etc) que necesitan de más tiempo para procesarse y, por tanto, bloquean este hilo. Para que esto no sea un bloqueo, se utilizan cierto mecanismos de asincronía (callbacks, promises, async/await) que permiten desbloquear el hilo de ejecución.
setTimeout(() => console.log('hola, hola'), 0)
console.log('Adios, adios')
Antes de conocer estas herramientas, intentaremos entender cómo funciona la asincronía en el motor de JS.
Tomemos el siguiente código Javascript (100% síncrono) como ejemplo:
const miSegunda = () => {
console.log('Toh bien mano');
}
const miPrimera = () => {
console.log('Qué pasa premoh');
miSegunda();
console.log('Que bueno pana');
}
miPrimera()
Es un concepto abstracto que se refiere al lugar donde el código JS se evalúa y se ejecuta. Todo el código JS se ejecuta en un contexto de ejecución. Cuando creamos una función se crea su propio contexto de ejecución de función, mientras que cuando tenemos código global, este se ejecuta en el contexto global.
La pila de llamadas (o call stack) es una estructura LIFO donde se almacenan todos los contextos creados durante la ejecución del código. Al ser monohilo, JS tiene una única pila de llamadas.
- Nuestro código empieza a ejecutarse, Se crea un contexto de ejecución global
main()
y se apila. - Aparece una llamada a la función
miPrimera()
. Así que se apila y comienza su ejecución. - Se comienza a ejecutar el nuevo contexto, así que se apila la llamada a
console.log()
. - Como la ejecución de
console.log()
ha acabado, desaparece de la pila. - Aparece una llamada a la función
miSegunda()
. Se apila y se empieza a ejecutar. - Se ejecuta
console.log()
y se añade a la pila. - Como la ejecución de
console.log()
ha acabado, desaparece de la pila. - Como la ejecución de
miSegunda()
ha acabado, desaparece de la pila. - Se ejecuta
console.log()
y se añade a la pila. - Como la ejecución de
console.log()
ha acabado, desaparece de la pila. - Como la ejecución de
miPrimera()
ha acabado, desaparece de la pila. - Como la ejecución de
main()
ha acabado, desaparece de la pila. Fin del programa.
Si en este esquema hubiera una pieza de código que, por el motivo que sea, requiriera de más tiempo para ser ejecutada, nuestro código se vería lastrado por la ejecución de esta. Para solucionar este problema, es preciso que antes entendamos cómo y donde se ejecuta JS.
El entorno de ejecución hace referencia al lugar donde se ejecuta el código Javascript. Aunque tradicionalmente Javascript ha sido un lenguaje de Front que se ejecutaba en el navegador, desde la aparición de NodeJS también podemos ejecutarlo en Servidor. Esto hace que haya dos entornos diferentes que proveen de APIs concretas. Por un lado tenemos el navegador, que nos provee de una serie de APIs y de métodos propios orientados a tratar en un entorno como es el navegador. En el caso de NodeJs, este tiene una serie de APIs propias que son distintas a las del navegador, ya que el tipo de problemas que hay en un servidor no tienen por qué ser los mismos.
Por ejemplo, mientras que el navegador provee métodos específicos para el manejo del DOM, gestión de eventos o el famoso Fetch
, estos no están presentes en NodeJs. Al igual, mientras que en NodeJs tenemos de forma nativa la manipulación de ficheros, esta no está presente en los navegadores. Sin embargo, funciones como setTimeout
o setInterval
están en ambos entornos de ejecución, pero no son parte del motor de JS.
Cuando creamos un manejador de eventos, este se guarda en el entorno correspondiente ya que los eventos no pertenecen al motor de Javascript.
Es una cola (FIFO) donde se almacenan las funciones callback que se tienen que ejecutar en orden. Cuando no hay nada apilado en la pila de llamadas, la función callback se envía a la pila. Este proceso se repite tantas veces cómo callbacks haya encolados.
Este loop de eventos es el corazón de la asincronía de JS. Se encarga de comprobar constantemente el estado de la pila y de la cola de mensajes. En el momento en que la pila de llamadas se queda vacía, notifica a la cola de mensajes por si esta tuviera callbacks que ejecutar.
Partamos de este código asíncrono en el que vamos a emular una llamada HTTP:
const llamadaHTTP = () => {
setTimeout(() => {
console.log('Ha pasado un segundo y medio');
}, 1500);
}
console.log('Inicio del programa');
llamadaHTTP();
console.log('Fin del programa')
Cuando se inicia la ejecución del programa ocurre lo siguiente:
- Se apila el contexto de ejecución global en el call stack.
- Se apila la invocación a
console.log
. console.log
acaba y se desapila.- Se apila la ejecución de
llamadaHTTP
y comienza su ejecución. - Se apila la llamada a
setTimeout
. setTimeout
crea un temporizador de 1 segundo y medio en el entorno de ejecución del navegador.- Como la ejecución de
setTimeout
ha acabado, se desapila. - Como no hay nada más que ejecutar en
llamadaHTTP
, se desapila. - Se apila la ejecución de
console.log()
console.log
acaba y se desapila.- Como ha pasado un segundo y medio el callback de
setTimeout
llega a la cola de mensajes. - Como la pila de llamadas está vacía, el event loop notifica a la cola y se apila en la pila de llamadas el callback del
setTimeout
. - Como la ejecución del
callback
ha acabado, esta se desapila.
Los eventos nos permiten notificar a nuestro código que algo ha pasado y ejecutar código cuando estos ocurren. En los eventos hay dos partes involucradas siempre, por un lado, el disparador del evento, y por el otro lado, el manejador de ese evento. El primero será quien lance la notificación de que algo ha pasado mientras que el segundo será quien actúe cuando ese evento ocurra.
El proceso por el que se propaga los eventos ocurre en 3 fases:
- Fase de captura. Empezando desde el mayor de los ancestros (
window
). El evento va pasando por todos los hijos de forma profunda hasta llegar al elemento que ha disparado el evento. - Objetivo. El evento llega al disparador.
- Fase de Bubbling: El evento vuelve desde el disparador hasta el ancestro más primitivo (
window
).
Por defecto, los eventos en el navegador se capturan en la fase de bubbling. Si queremos que se capturen también en la primera fase debemos indicarlo de forma explicita.
La forma en la que se propagan, da pie a lo que se llama delegación de eventos. Cuando queremos que una serie de elementos que son hermanos tengan el mismo manejador de eventos, podemos asignar este al elemento padre ya que cuando los eventos 'burbujeen', se capturarán de forma única.
El DOM nos provee de una cantidad ingente de disparadores de eventos que podemos manejar. Esto lo podemos hacer de tres formas distintas:
- Manejadores de eventos inline: Este es el acercamiento menos recomendado de todos y el más antiguo.
<button onclick="cambiaFondo()">¡Pulsa Aquí!</button>
<script>
function cambiaFondo() {
// magia
}
</script>
- Con la función
.addEventListener
. Esta función nos permite capturar los eventos en la fase de captura. Tiene su complementariaremoveEventListener
.
<button id="miBoton">¡Pulsa Aquí!</button>
<script>
function cambiaFondo() {
// aquí ocurren cosas mágicas
}
const miBoton = document.querySelector('#miBoton')
miBoton.addEventListener('click', cambiaFondo)
</script>
<div id="papa">
<div id="hijo">
Pulsa aquí
</div>
</div>
<script>
const padre = document.getElementById('papa');
const hijo = document.getElementById('hijo');
hijo.addEventListener('click', function(event) {
alert('Hijo ha llegado a la fase de bubbling');
});
papa.addEventListener('click', function(event) {
alert('Papa ha llegado a la fase de bubbling');
console.log(event);
});
hijo.addEventListener('click', function(event) {
alert('Hijo ha llegado a la fase de captura');
}, true);
papa.addEventListener('click', function(event) {
alert('Papa ha llegado a la fase de captura');
}, true);
</script>
- Modificando los manejadores por defecto de eventos:
<button id="miBoton">¡Pulsa Aquí!</button>
<script>
function cambiaFondo() {
// aquí ocurren cosas mágicas también
}
const miBoton = document.querySelector('#miBoton');
miBoton.onclick = cambiaFondo;
</script>
Cuando se captura un evento, la función de callback que actúa de manejador recibirá un objeto evento con una serie de propiedades y métodos muy interesantes. Algunos de ellos son los siguientes:
-
Propiedades
.type
: El nombre del evento que estamos capturando..target
: Devuelve una referencia al objeto desde donde se envió el evento..cancelable
: Indica si un evento se puede cancelar o no..eventPhase
: Nos devuelve un entero que identifica en qué punto de la propagación del evento nos encontramos:0
: No se está procesando ningún evento.1
: Nos indica que está en la fase de captura.2
: El evento ha llegado al disparador del evento.3
: El evento está en estado debubbling
.
.isTrusted
: Nos indica si un evento ha sido lanzado por la acción del usuario o de forma programática..bubbles
: Indica si el evento se propaga hacia arriba o no..currentTarget
: Devuelve una referencia al elemento que tiene asignado el manejador de eventos.
-
.preventDefault()
: Cancela el evento, es decir, evita el comportamiento por defecto para un manejador. Por ejemplo, un enlace que va a una nueva URL o un botón de tiposubmit
. -
.stopPropagation()
: Evita que el evento se propague por el DOM hacia arriba, pero permite la acción por defecto.
Independientemente de los eventos que lanza el DOM, nosotros podemos crear nuestros eventos personalizados y lanzarlos y capturarlos a voluntad. El constructor de la clase event puede recibir dos parámetros, aunque el segundo es opcional.
const miEventoCustom = new Event('miEvento')
const miEventoComplejo = new Event('miEventoComplejo', {
bubbles: false, // Indica si el evento burbujea
cancelable: false, // Indica si el evento se puede cancelar
composed: false // Indica si el evento se podrá escuchar fuera de un shadow root
})
document.body.addEventListener('miEventoComplejo', function(event) {
console.log(event);
});
document.body.dispatchEvent(miEventoComplejo);
<ul id="list">
<ul>
<li class="listItem">
<div class="card">
</div>
</li>
<li class="listItem">Nosotros</li>
<li class="listItem">Contacta</li>
</ul>
</ul>
<script>
const miEventoComplejo = new Event('miEventoComplejo', {
bubbles: false, // Indica si el evento burbujea
cancelable: false, // Indica si el evento se puede cancelar
composed: false // Indica si el evento se podrá escuchar fuera de un shadow root
})
document.body.addEventListener('miEventoComplejo', function(event) {
console.log(event);
});
document.body.dispatchEvent(miEventoComplejo);
</script>
Cuando se trabaja con eventos, es muy típico usar algunos de los siguientes mecanismos:
- Throttling: Hay veces en que cierto evento se produce demasiadas veces consecutivas, activando su manejador por cada ocasión que esto ocurre. El throttling nos permite limitar el número de eventos ante el que reaccionamos, ya sea por tiempo o por número de veces que salta el evento.
function throttler(delay, fn) {
let ultimaLlamada;
return function (...args) {
const ahora = (new Date).getTime();
if (ahora - ultimaLlamada < delay) {
return;
}
ultimaLlamada = ahora;
return fn(...args);
}
}
const manejador = (evento) => console.log(evento)
const manejadorConThrottling = throttler(2000, manejador)
document.addEventListener('mousemove', manejadorConThrottling)
- Debounce: Esta técnica se utiliza en escenarios en los cuales se producirán muchos eventos muy rápido de un mismo tipo pero nosotros no queremos manejar todos, sólo el último de ellos. El debouncing nos permite esperar a que se dejen de producir eventos de un tipo para lanzar el manejador de los mismos.
function debouncer(delay, fn) {
let temporizador;
return function (...args) {
if (temporizador) {
clearTimeout(temporizador);
}
temporizador = setTimeout(() => {
fn(...args);
temporizador = null;
}, delay);
}
}
const manejador = (evento) => console.log(evento)
const manejadorConDebounce = debouncer(1000, manejador)
document.addEventListener('mousemove', manejadorConDebounce)
Un callback es una función que se pasa como parámetro a otra función para que sea esta última la que la ejecute. Esta función puede estar previamente declarada o podemos declararla en el mismo momento que la pasamos como argumento. Es importante entender que al pasar la función no la estamos ejecutando. Es la función a la que le pasamos el callback la que decide cuando se ejecuta.
// La función replace puede recibir una función de callback
// En este caso la declaramos al pasársela a la función replace
const conMayus = (str) => str.replace(/ [a-z]/g, (char) => char.toUpperCase())
// Pero también podríamos separarlas
const aMayus = letra => letra.toUpperCase();
const todoACapital = str => str.replace(/ [a-z]/g, aMayus)
function filter(array, cb) {
const nuevoArray = []
for(let i = 0; i < array.length; i++) {
if (cb(array[i], i, array)) {
nuevoArray.push(array[i])
}
}
return nuevoArray;
}
Cuando se trata de eventos, los manejadores son callbacks
const miCallback = (evento) => console.log(evento.target)
document.body.addEventListener('click', miCallback)
También, podemos anidarlos.
<!DOCTYPE html>
<html>
<body>
<button id="miBoton">Añade item</button>
<ul id="miLista"></ul>
<script>
window.addEventListener('DOMContentLoaded', function() {
let contador = 1;
const boton = document.getElementById('miBoton')
const lista = document.getElementById('miLista')
boton.addEventListener('click', function() {
const nuevoLi = document.createElement('li');
const contenido = document.createTextNode(`Item ${contador}`)
nuevoLi.append(contenido)
setTimeout(function() {
lista.append(nuevoLi)
}, 1500)
contador += 1;
})
})
</script>
</body>
</html>`
El anidar callbacks nos lleva a lo que llamamos *CALLBACK HELL que es anidar la declaración de callbacks hasta convertir nuestro código en una pirámide egipcia MAL.
const api = {
get: function(ruta, cb) {
setTimeout(function() {
cb(null, { /* Los datos que queremos */ })
})
}
}
const traeDatosUsuario = (id, cb) => api.get(`/user/${id}`, cb)
const traeDatosCarrito = (id, cb) => api.get(`/cart/${id}`, cb)
const traeDatosMascota = (id, cb) => api.get(`/pet/${id}`, cb)
const traeDatosHeroe = (id, cb) => api.get(`/hero/${id}`, cb)
const traeImagen = (id, cb) => api.get(`/image/${id}`, cb)
function traeDatos() {
traeDatosUsuario(idUsuario, (errorUsuario, usuario) => {
// Cosas de Usuario
traeDatosCarrito(idCarrito, (errorCarrito, carrito) => {
// Cosas de carrito
traeDatosMascota(idMascota, (errorMascota, mascota) => {
// Cosas de mascota
traeDatosHeroe(idHeroe, (errorHeroe, heroe) => {
// Cosas de heroe
traeImagen(idImagen, (errorImagen, imagen) => {
// Invocamos a Ramses porque hemos llegado a la cima de la pirámide
})
})
})
})
})
}
Anidar callbacks hasta que formen una piramide se considera una mala práctica en Javascript. Una forma de evitarlo es declarando funciones como variables en vez de a la hora de pasarlas como parámetro.
const cbDeTraeImagen = (err, imagen) => {
// Hago cosas
}
const cbDeTraeDatosHeroes = (err, heroe) => {
// Hago cosas de heroe
traeImagen(idImagen, cbDeTraeImagen);
}
const cbDeTraeDatosMascota = (err, mascota) => {
// Hago cosas de mascota
traeDatosHeroe(idHeroe, cbDeTraeDatosHeroes);
}
const cdDeTraeDatosCarrito = (err, carrito) => {
traeDatosMascota(idMascota, cbDeTraeDatosMascota)
}
const cbDeTraeDatosUsuario = (err, usuario) => {
// Cosas de usuario
traeDatosCarrito(idCarrito, cdDeTraeDatosCarrito)
}
function traeDatos() {
traeDatosUsuario(idUsuario, cbDeTraeDatosUsuario);
}
Esto puede presentar un problema, y es que queramos acceder a variables que esten declaradas dentro de la pirámide:
function traeDatos(idUsuario, cb) {
traeDatosUsuario(idUsuario, (errorUsuario, usuario) => {
// Cosas de Usuario
const { idMascota } = usuario
traeDatosCarrito(idCarrito, (errorCarrito, carrito) => {
// Cosas de carrito
traeDatosMascota(idMascota, (errorMascota, mascota) => {
// Cosas de mascota
traeDatosHeroe(idHeroe, (errorHeroe, heroe) => {
// Cosas de heroe
traeImagen(idImagen, (errorImagen, imagen) => {
// Invocamos a Ramses porque hemos llegado a la cima de la pirámide
cb(null, resultado)
})
})
})
})
})
}
Con el acercamiento que acabamos de ver, esto no sería posible, ya que estas variables no existen en el contexto de la función.
Hay diferentes formas de solucionar esto. Una forma de solucionarlo, sería que en nuestras funciones se arrastre siempre la información que sabemos que necesitaremos:
const traeDatosUsuario = (params, cb) => {
const { idUsuario } = params
api.get(`/user/${idUsuario}`, (err, data) => {
// Hacemos lo que sea
cb(null, { ...params, usuario: data })
})
}
const traeDatosCarrito = (params, cb) => {
const { usuario: { idCarrito } } = params
api.get(`/cart/${idCarrito}`, (err, data) => {
// Hacemos lo que sea
cb(null, { ...params, carrito: data })
})
}
const traeDatosMascota = (params, cb) => {
const { usuario: { idMascota } } = params
api.get(`/pet/${idMascota}`, (err, data) => {
// Hacemos lo que sea
cb(null, { ...params, mascota: data })
})
}
const traeDatosHeroe = (params, cb) => {
const { mascota: { idHeroe } } = params
api.get(`/hero/${idHeroe}`, (err, data) => {
// Hacemos lo que sea
cb(null, { ...params, heroe: data })
})
}
const traeDatosImagen = (params, cb) => {
const { heroe: { idImagen } } = params
api.get(`/imagen/${idImagen}`, (err, data) => {
// Hacemos lo que sea
cb(null, { ...params, imagen: data })
})
}
Si juntamos todo, tendríamos lo siguiente:
const traeDatosUsuario = (params, cb) => {
const { idUsuario } = params
api.get(`/user/${idUsuario}`, (err, data) => {
// Hacemos lo que sea
cb(null, { ...params, usuario: data })
})
}
const traeDatosCarrito = (params, cb) => {
const { usuario: { idCarrito } } = params
api.get(`/cart/${idCarrito}`, (err, data) => {
// Hacemos lo que sea
cb(null, { ...params, carrito: data })
})
}
const cbDeTraeDatosUsuario = (err, datos) => {
// Cosas de usuario
traeDatosCarrito(datos, cdDeTraeDatosCarrito)
}
const traeDatosMascota = (params, cb) => {
const { usuario: { idMascota } } = params
api.get(`/pet/${idMascota}`, (err, data) => {
// Hacemos lo que sea
cb(null, { ...params, mascota: data })
})
}
const cdDeTraeDatosCarrito = (err, datos) => {
traeDatosMascota(datos, cbDeTraeDatosMascota)
}
const traeDatosHeroe = (params, cb) => {
const { mascota: { idHeroe } } = params
api.get(`/hero/${idHeroe}`, (err, data) => {
// Hacemos lo que sea
cb(null, { ...params, heroe: data })
})
}
const cbDeTraeDatosMascota = (err, datos) => {
// Hago cosas de mascota
traeDatosHeroe(datos, cbDeTraeDatosHeroes);
}
const traeDatosImagen = (params, cb) => {
const { heroe: { idImagen } } = params
api.get(`/imagen/${idImagen}`, (err, data) => {
// Hacemos lo que sea
cb(null, { ...params, imagen: data })
})
}
const cbDeTraeDatosHeroes = (err, datos) => {
// Hago cosas de heroe
traeImagen(datos, cbDeTraeImagen);
}
const cbDeTraeImagen = (err, imagen) => {
// Hago cosas
}
function traeDatos(idUsuario) {
traeDatosUsuario({ idUsuario }, cbDeTraeDatosUsuario);
}