Retomamos esta serie sobre JavaScript asíncrono. Si acabas de aterrizar aquí, te recomiendo que veas la parte #1 antes, ya que voy a utilizar todos los acrónimos allí definidos. ?
Cómo usar las callback functions dentro de una HTTP Request
Lo habitual cuando hacemos una request es darle forma de función y guardarla en una variable para así poder llamarla cuando la necesitemos. Además, eso la hace re-utilizable, lo que es una buena práctica. Como estamos trabajando con JSONPlaceholder para obtener una lista de to do's (tareas), vamos a darle el nombre a la variable de getToDos.
const getToDos = () => { const request = new XMLHttpRequest(); request.addEventListener('readystatechange', () => { // console.log(request); // console.log(request.readyState); if (request.readyState === 4 && request.status === 200) { console.log(request, request.responseText); } else if (request.readyState === 4) { console.log('ooops...we could not fetch the data'); } }); request.open('GET', 'https://jsonplaceholder.typicode.com/todos'); request.send(); };
Ahora, para hacer nuestra request, solo tendríamos que invocar la función:
getToDos();
y recibiríamos la response igual que antes.
Por ahora todo lo que estamos haciendo es imprimir siempre el responseText, porque así lo hemos especificado:
console.log(request, request.responseText);
Sería más útil si, en lugar de tener ese console.log, le pasáramos una callback function, cuyo comportamiento podríamos gestionar independientemente y así hacer nuestra request más flexible. Entonces, cada vez que llamásemos a getToDos(), podríamos especificar una callback function distinta, adaptada a nuestras necesidades. ¡Vamos a ello! ??
1. Especificamos una callback function como primer argumento del getToDos().
2. Como le estamos pasando un parámetro al getToDos, eso significa que tenemos que avisarle de que va a recibir un parámetro. Lo llamamos callbackFunc, por ejemplo.
3. Dentro de nuestra request, en lugar de escribir un console.log, le pasamos nuestra callbackFunc(). Vamos a probarlo haciendo un console.log dentro de la callback function de getToDos().
const getToDos = (callbackFunc) => { const request = new XMLHttpRequest(); request.addEventListener('readystatechange', () => { // console.log(request); // console.log(request.readyState); if (request.readyState === 4 && request.status === 200) { // console.log(request, request.responseText); callbackFunc(); } else if (request.readyState === 4) { // console.log('ooops...we could not fetch the data'); callbackFunc(); } }); request.open('GET', 'https://jsonplaceholder.typicode.com/todos'); request.send(); }; getToDos(() => { console.log('callback function fired!'); });
? Pero si ahora cometemos algún tipo de error en el API endpoint, la callback function se seguiría disparando, porque evidentemente le estamos pasando la misma callback a ambos casos del if / else statement. Lo suyo sería pasarle la información de la response en el bloque if y cualquier error que ocurra en el bloque else:
request.addEventListener('readystatechange', () => { if (request.readyState === 4 && request.status === 200) { callbackFunc(// pass the data); } else if (request.readyState === 4) { callbackFunc(// pass the error); } });
Para hacer esa verificación de cuándo estamos ante una obtención de datos correcta y cuándo ante un error, configuramos nuestra callback function en el getToDos().
4. La callback function va a aceptar dos parámetros: err y data. Es una convención que primero le pasemos el error y segundo la información. ?
getToDos((err, data) => { console.log('callback function fired!'); });
5. Personalizamos cada callbackFunc. El primer if statement contempla el caso de que todo haya salido bien. Por tanto, el parámetro err sería undefined. El segundo parámetro (data), sería el responseText.
En el block del else statement, la callbackFunc también espera un error como primer parámetro, y este es el bloque que gestiona si algo ha salido mal, así que le pasamos un mensaje tipo "algo salió mal, no se ha podido obtener los datos". Como segundo parámetro le pasamos undefined, ya que en este bloque no se ha podido recibir el responseText.
request.addEventListener('readystatechange', () => { if (request.readyState === 4 && request.status === 200) { callbackFunc(undefined, request.responseText); } else if (request.readyState === 4) { callbackFunc('no se han podido obtener los datos', undefined); } });
6. Hacemos un console.log del err y de la data para ver si vamos por el buen camino.
getToDos((err, data) => { console.log('callback function fired!'); console.log(err, data); });
Prueba a hacer la request con un API endpoint correcto y con uno incorrecto, y verás la diferencia. ?
7. Sabiendo esto, podemos comprobar si tenemos o no un error, y hacer cosas distintas según el caso. Vamos simplemente a imprimir por consola el error y los datos.
getToDos((err, data) => { console.log('callback function fired!'); // console.log(err, data); if (err) { console.log(err); } else { console.log(data); } });
Y con esta comprobación, ahora sólo se nos imprimirá el mensaje de error en caso de que el API endpoint sea incorrecto, o recibiremos los datos en caso contrario. ?
8. Si ahora hacemos una prueba para ver si verdaderamente este código asíncrono no bloquea nuestra secuencia de ejecución, tendríamos un resultado satisfactorio.
console.log(1); console.log(2); getToDos((err, data) => { console.log('callback function fired!'); // console.log(err, data); if (err) { console.log(err); } else { console.log(data); } }); console.log(3); console.log(4);
En tu consola verás que los console.log se han impreso primero, ya que la request se ha ejecutado en paralelo y finalmente resuelto, siendo así la última parte en imprimirse en la consola. ?
Cómo trabajar con JSON data
Sabemos que el formato JSON es el formato en el que la mayoría de APIs nos devuelven información cuando se la solicitamos por medio de una HTTP request. Este formato tiene pinta de una JS array lleno de JS objects, pero si nos fijamos, es en realidad un string gigante.
JSON = JavaScript Object Notation
Es un string porque esta es la única manera de transferir datos entre un servidor y un navegador.
?? Vamos a aprender a convertir la información de formato JSON a un verdadero objeto de JS. Para esto, existe un built-in object en JS llamado JSON, que, junto a un método llamado parse(), hace justo lo que necesitamos. Vamos a probarlo en nuestro sandbox.js.
1. Dentro del bloque if de nuestra request, creamos una variable llamada data que almacenará el valor de responseText, convertido a un array de objetos. Para ello usamos el método parse() sobre JSON y le pasamos el JSON string que queramos convertir en un objeto de JS (el responseText).
2. En la callbackFunc del bloque if le pasamos la variable data que acabamos de crear, en lugar del responseText.
if (request.readyState === 4 && request.status === 200) { const data = JSON.parse(request.responseText); callbackFunc(undefined, data); } else if (request.readyState === 4) { callbackFunc('no se han podido obtener los datos', undefined); }
Si ahora guardas y vas a tu consola, ¡verás que tenemos un JS array lleno de objetos! ?
> (200) [{…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, …]
Más adelante exploraremos cómo acceder a esos objetos y a sus key-value pairs. Por el momento, hay otra cosa que podemos hacer: crear nuestro propio archivo JSON. ?
3. En la misma carpeta que nuestro index.html y sandbox.js (al mismo nivel) creamos un archivo llamado toDos.json. Sabemos que un archivo JSON es un único string, pero no por ello necesitamos escribir nuestro código entre comillas " ", ya que nuestro editor sabe que es un archivo JSON.
? La diferencia cuando escribimos código JSON es que todos nuestros key-values tienen que estar entre comillas dobles. Para los números, no es necesario:
4. Siguiendo estas indicaciones, vamos a añadir las primeras líneas de código a nuestro archivo JSON, imaginando que estamos creando una base de datos con listas de tareas de diferentes personas. ?
[ { "play dungeons & dragons": "Will" }, { "buy bat": "Steve" }, { "call Ms. Wheeler": "Billy" } ]
5. Volvemos al sandbox.js e intentamos hacer nuestra request a un API endpoint distinto. Concretamente, a nuestro archivo JSON, usando un relative path.
request.open('GET', 'toDos.json');
¡Y ya lo tenemos! En nuestra consola podemos ver el código JSON, convertido en un array con 3 objetos dentro. ?
¿Qué demonios es el callback hell?
Hasta ahora sólo estamos llamando a la función getToDos una vez, y sólo solicitando información a un API endpoint. En la vida real, lo normal es que hagamos http requests a más de una API, por tanto, vamos a ver cómo tratar esos casos.
1. Creamos una carpeta al nivel del index.html a la que llamamos toDos. Dentro creamos tres archivos JSON, donde cada uno contiene una lista de tareas de una persona distinta.
Estructura de archivos:
- ?toDos
- ?billy.json
- ?mike.json
- ?will.json
- ?index.html
- ?sandbox.js
[ { "task": "call Ms. Wheeler", "author": "Billy" }, { "task": "recruit people for the dark side", "author": "Billy" }, { "task": "clean swimmingpool", "author": "Billy" } ]
[ { "task": "kill Demogorgon", "author": "Mike" }, { "task": "find Eleven", "author": "Mike" }, { "task": "buy a new bike", "author": "Mike" } ]
[ { "task": "play dungeons & dragons", "auhtor": "Will" }, { "task": "escape from Demogorgon", "author": "Will" }, { "task": "go to the arcade", "author": "Will" } ]
Lo que queremos es obtener la información de los tres archivos en orden, es decir, primero las tareas de Billy, luego las de Mike y finalmente las de Will.
Este concepto de solicitar la información en cierto orden es muy común cuando trabajamos con APIs, porque es normal que queramos obtener cierta información de una API primero para luego usarla y hacer otra http request, por ejemplo.
Si buscamos en nuestro código, podemos localizar que el momento en el que una primera request estaría completada sería cuando hacemos el console.log de data:
Así que ese sería un buen lugar para hacer nuestra segunda http request.
Ahora, nuestro API endpoint apunta al archivo toDos.json. Nosotros queremos recibir la información de los otros tres archivos JSON, pero no podemos escribir el relative path de ninguno de éstos, porque así no estaríamos escribiendo código dinámico, sino estático (hardcoded).
2. Así que lo que hacemos es pasarle otro parámetro a la función getToDos(), llamado tasksList. Se lo pasamos también al preparar la request como segundo argumento, sustituyendo así el API endpoint que teníamos hardcoded.
3. Al llamar a la función getToDos(), que ahora recibe dos argumentos, le pasamos como primer argumento el relative path de uno de nuestros archivos JSON, del que queramos recibir la información primero. Por ejemplo, de mike.json.
4. Quitamos el bloque if / else de momento, y sólo hacemos un console.log de data, para comprobar que efectivamente sólo nos devuelve la información de mike.json.
const getToDos = (tasksList, callbackFunc) => { const request = new XMLHttpRequest(); request.addEventListener('readystatechange', () => { if (request.readyState === 4 && request.status === 200) { const data = JSON.parse(request.responseText); callbackFunc(undefined, data); } else if (request.readyState === 4) { callbackFunc('no se han podido obtener los datos', undefined); } }); request.open('GET', tasksList); request.send(); }; getToDos('toDos/mike.json', (err, data) => { // console.log('callback function fired!'); console.log(data); });
Si ahora vas a tu consola, verás que sólo has recibido la información sobre Mike. ¡Chachi! ?
5. Como ya estamos seguros de que en este punto hemos recibido la información de Mike, ahora queremos recibir la información de Billy. Y eso deberíamos hacerlo justo después de recibir la información de Mike.
console.log(data);
// solicitar info Billy aquí
});
Siguiendo el mismo patrón, una vez estemos seguros de que hemos recibido la información de Billy, ya podríamos solicitar la información de Will.
getToDos('toDos/mike.json', (err, data) => { console.log(data); getToDos('toDos/billy.json', (err, data) => { console.log(data); getToDos('toDos/will.json', (err, data) => { console.log(data); }); }); });
Aunque esto funciona perfectamente, empieza a convertirse en un código realmente complejo y terriblemente difícil de mantener a la larga. Por no hablar de que, si añadiésemos 10 callbacks más, tendríamos un código en forma de pirámide totalmente insostenible. ?
Esto es lo que se llama el callback hell, aunque a mí me gusta llamarlo el triángulo del infierno. Así sucede el callback hell: anidando callbacks dentro de callbacks. ?
Qué son las promises y cómo usarlas
Para lidiar con el callback hell y librarnos de él, podemos usar una herramienta más moderna de JS: las promesas (promises). Para entender bien el concepto, vamos a hacer un pequeño ejemplo y luego aplicaremos lo aprendido a nuestra getToDos().
Ejemplo con conceptos básicos
1. Comentamos todo el código del callback hell o lo borramos directamente, porque no nos va a hacer falta más.
2. Creamos una variable llamada getSomething, que guardará una función encargada de hacer una http request. Parecido a lo que hace nuestra función getToDos(). Cuando utilizamos promises, lo primero que debemos hacer es un return de una new Promise.
const getSomething = () => { return new Promise(); };
Una promise es algo que va a tardar cierto tiempo en ejecutarse, y cuando lo haga, lo hará de dos maneras posibles: si se cumple lo que habíamos previsto, (por ejemplo, obtener cierta información), la promesa será resuelta (resolved), en caso contrario, la promesa será rechazada (rejected).
Aquí un esquemita aclarativo:
3. La promise acepta como parámetro una función. Dentro de esa función es donde hacemos la http request. Por ahora no vamos a hacer una request, solo vamos a simularla para no complicar el ejemplo.
const getSomething = () => { return new Promise(() => { }); };
Los conceptos de resolve o reject son parecidos a cuando hacíamos un if / else statement para comprobar si la request había funcionado correctamente y sin errores. Con la ventaja de que en una promise, estos dos métodos (resolve y reject) vienen por defecto en el objeto promise, como parte de la JavaScript promise API. ?
4. Y eso es lo que le pasamos a la función de nuestra promise como parámetro. Si hemos recibido bien los datos de nuestra request, ejecutaremos el método resolve() y le pasaremos los datos. Pero como por ahora no tenemos datos porque estamos haciendo una simulación, le pasamos un string diciendo algo tipo 'datos recibidos'.
5. Sin embargo, si hubiese un error, llamaríamos al método reject() y le pasaríamos el error. Como tampoco tenemos un error definido, le pasamos un string diciendo algo tipo 'error en la recepción de los datos'.
const getSomething = () => { return new Promise((resolve, reject) => { resolve('datos recibidos'); reject('error en la recepción de los datos'); }); };
6. Normalmente haríamos todo esto dentro de un if / else statement, comprobando si todo ha salido bien, ejecutando el resolve, o ejecutando el reject si algo ha ido mal. Pero por motivos de simplicidad, vamos a hacer una posibilidad cada vez. Probamos primero con el método resolve(), comentando el reject().
7. Si ahora llamamos a la función getSomething(), ésta devuelve una promise, que va a ejecutar el bloque de resolve o el de reject dependiendo de cómo haya ido la request. Aquí es donde vinculamos dos métodos a la función: el then y el catch:
8. Al método then() le pasamos una función que se ejecutará cuando la promise haya salido bien (resulte en resolve). Lo que significa que recibe por parámetro la información que se haya recibido en el método resolve. Si ahora hacemos un console.log de esa información, veremos que es justamente la información que trae el método resolve (la frase 'datos recibidos').
const getSomething = () => { return new Promise((resolve, reject) => { resolve('datos recibidos'); // reject('error en la recepción de los datos'); }); }; getSomething() .then(data => { console.log(data); })
Y esa es la frase que deberíamos ver en nuestra consola. ?
9. Si probamos a comentar el resolve() y a descomentar el reject, se dispará la callback function vinculada a otro método llamado catch. Ahí es donde tenemos que especificar qué hacer con el error. En este caso, simplemente lo imprimimos por consola.
const getSomething = () => { return new Promise((resolve, reject) => { // resolve('datos recibidos'); reject('error en la recepción de los datos'); }); }; getSomething() .then(data => { console.log(data); }) .catch(error => { console.log(error); });
?♀️ El estilo para escribir el código es algo personal, aunque hay ciertas convenciones. Normalmente, cuando encadenamos métodos, debemos ponerlos cada uno en una línea nueva para ser más legibles.
Promises en la práctica
Hecho ya el ejemplo con datos simulados, vamos a utilizar promises para hacer una verdadera http request. Lo que vamos a hacer es utilizar una promise dentro de la función getToDos(). Puedes comentar el ejemplo anterior para que no nos moleste.
1. Hacemos un return de una nueva promesa, que acepta una función como parámetro. Dentro de esta función es donde haremos nuestra http request, así que cortamos el código y lo pegamos ahí dentro.
2. Nuestra promise acepta los métodos resolve y reject como parámetros, así que se los pasamos.
3. Ya no vamos a necesitar el segundo parámetro en la función getToDos (callbackFunc), así que lo eliminamos. Esto es así porque en lugar de llamar a la función callbackFunc, vamos a llamar al método resolve o al reject según la promesa se resuelva con éxito o no. ?
Al método resolve le pasamos la variable data, mientras que al reject podemos pasarle un string diciendo que ha habido algún tipo de error, como hacíamos antes en nuestro ejemplo.
const getToDos = tasksList => { return new Promise((resolve, reject) => { const request = new XMLHttpRequest(); request.addEventListener('readystatechange', () => { if (request.readyState === 4 && request.status === 200) { const data = JSON.parse(request.responseText); resolve(data); } else if (request.readyState === 4) { reject('no se han podido obtener los datos'); } }); request.open('GET', tasksList); request.send(); }); };
4. Llamamos a la función getToDos() y le pasamos un único parámetro, el que hace referencia a la tasksList. Le pasamos por ejemplo el archivo JSON de Billy.
5. A la función le adjuntamos el método then(), que, como sabemos, está vinculado al método resolve(). Es decir, la función dentro de then() se ejecutará si la promise se cumple. Le pedimos simplemente que haga un console.log de la información recibida en el resolve().
6. Hacemos lo mismo con el método catch(), lo adjuntamos al getToDos y le pasamos una función que simplemente imprima por consola el error que hemos especificado en el método reject() en caso de que algo salga mal. Recuerda que esta función se disparará si algo sale mal en la promise y JS ejecuta el bloque de código del método reject().
getToDos('toDos/billy.json') .then(data => { console.log('promise resolved!:', data); }) .catch(error => { console.log('promise rejected :(', error); });
¡Y ya está! ? Ahora en tu consola deberías ver los datos de Billy. Si intentas escribir el API endpoint mal, por ejemplo:
getToDos('toDos/billyyyy.json')
JS ejecutará la parte del reject(), ya que la promesa no se ha cumplido al no poder obtener los datos debido a un error por nuestra parte. ?♀️
THE END!
¡Y con esto terminamos nuestra segunda parte de esta Async saga! Espero que hayas aprendido algo nuevo ?. Si te queda alguna duda, ¡nos vemos en los comentarios!
Si quieres seguir aprendiendo, aquí tienes la parte #3.
Y si crees que este post puede serle útil a alguien, ¡compártelo!
Otros artículos que pueden interesarte