Node.js

Conceptos previos de JavaScript

     

En este documento vamos a dar un breve repaso a algunos conceptos de JavaScript que utilizaremos a lo largo del curso, y con los que conviene que nos empecemos a familiarizar desde ya, si no los hemos utilizado aún. En concreto, trataremos:

1. Variables y estructuras de datos

1.1. Declaración de variables y constantes

En JavaScript existen varias formas de declarar variables, pero no todas son igual de seguras ni recomendables.

La forma clásica, que aún encontrarás en muchos tutoriales y ejemplos obsoletos en Internet, es con la palabra reservada var:

var nombre = "Nacho";
var edad = 41;

Aunque funciona, var tiene un problema importante: su ámbito no se limita al bloque ({ … }) en el que se declara, sino a toda la función o incluso al contexto global. Esto puede provocar resultados inesperados:

if (2 > 1)
{
    var nombre = "Nacho";
    console.log("Nombre dentro:", nombre);
}
console.log("Nombre fuera:", nombre);   // "Nacho" <-- sigue existiendo

En este ejemplo, la variable nombre sigue disponible fuera del if, cuando lo lógico sería que se hubiera “perdido” al salir del bloque.

Para evitar este comportamiento, se recomienda utilizar la palabra reservada let, en lugar de var, para declarar variables:

if (2 > 1)
{
    let nombre = "Nacho";
    console.log("Nombre dentro:", nombre);
}
console.log("Nombre fuera:", nombre);   // Error: nombre no está definido

De esta forma, el ámbito de cada variable queda restringido al bloque donde se declara, evitando accesos accidentales desde fuera y haciendo que el código sea más predecible y fácil de mantener.

También podemos emplear la palabra const para definir constantes en el código. Esto será particularmente útil tanto para definir constantes convencionales (como un texto o número fijo, por ejemplo) como para cargar librerías, como veremos en sesiones posteriores.

const pi = 3.1416;

Es importante destacar que const impide la reasignación de la variable, pero no hace inmutable el contenido de un objeto o un array:

const persona = { nombre: "Ana" };
persona.nombre = "Laura"; // permitido (modificación interna)
persona = {};             // error (no se puede reasignar la referencia)

1.2. Estructuras heterogéneas de datos

Las estructuras de datos u objetos literales en JavaScript son colecciones dinámicas de pares propiedad-valor, donde:

Por ejemplo:

let persona = {
    nombre: "Maria",
    edad: 41,
    telefono: "666555444"
};

Podemos acceder a una propiedad usando el punto . o la notación de corchetes [ ].

let nombre = persona.nombre; // Maria
let edad = persona["edad"];  // 41

También es posible extraer propiedades de un objeto directamente en variables o constantes con el mismo nombre que la propiedad del objeto (desestructuración):

const { nombre, edad, telefono } = persona;
console.log(nombre);   // "María"
console.log(edad);     // 41
console.log(telefono); // "666555444"

Objetos anidados

Un objeto puede contener como valor otro objeto, formando estructuras más complejas. En el siguiente ejemplo, el valor de la propiedad direcciones un nuevo objeto:

let persona = {
    nombre: "Maria",
    edad: 41, 
    telefono: "666555444",
    direccion: {
        via: "Avenida", 
        nombre: "Miguel Hernández",
        numero: 62
    }
};

Para acceder a propiedades anidadas podemos hacerlo de la forma tradicional:

let via = persona.direccion.via;             // Avenida
let numero = persona["direccion"]["numero"]; // 62

O bien, usando desestructuración anidada:

const { direccion: { via, numero } } = persona;
console.log(via);    // "Avenida"
console.log(numero); // "62"

Aquí, direccion debe coincidir con la clave del objeto. Sin embargo, en este ejemplo no se crea una variable llamada direccion, solo las internas via y numero.

Si quieres que exista la variable direccion y además variables internas como via y numero, lo puedes hacer en dos pasos:

const { direccion } = persona; // crea la variable direccion
const { via, numero } = persona.direccion; // extrae via y numero

O todo en una sola línea con alias:

const { direccion, direccion: { via, numero } } = persona;
console.log(direccion); // { via: 'Avenida', nombre: 'Miguel Hernández', numero: 62 }
console.log(via);       // "Avenida"
console.log(numero);    // 62

2. Funciones y arrow functions

En JavaScript podemos definir funciones de distintas maneras. La forma más tradicional es utilizando la palabra reservada function, pero existe una notación más compacta y muy usada hoy en dia: las arrow functions (funciones flecha o funciones lambda).

2.1. Las funciones tradicionales

Las funciones tradicionales son definidas con la palabra reservada function. Se conocen como declaraciones de función y tienen una característica especial: el motor de JavaScript las “eleva” al inicio del programa (hoisting), de modo que se pueden utilizar incluso antes de aparecer en el código.

Supongamos esta función tradicional que devuelve la suma de los dos parámetros que se le pasan:

function sumar(num1, num2)  {
    return num1 + num2;
}

A la hora de utilizar esta función, basta con llamarla en el lugar deseado, pasándole los parámetros adecuados. Por ejemplo:

console.log(sumar(3, 2)); // Mostrará 5

2.2. Las funciones anónimas

Otra forma de definir funciones es mediante funciones anónimas, llamadas así porque no tienen un nombre propio.Estas funciones se declaran “sobre la marcha”, y normalmente se asignan a una variable o constante para poder utilizarlas después a través de esa referencia:

let sumarAnonimo = function(num1, num2) {
    return num1 + num2; 
};
console.log(sumarAnonimo(3, 2));

A diferencia de las declaraciones de función tradicionales, estas no se “elevan” con el hoisting, por lo que solo pueden usarse después de haber sido definidas en el código.

2.3. Las arrow functions

Las funciones flecha (arrow functions) son otra forma de definir funciones más compacta. Se emplea una expresión lambda para especificar los parámetros por un lado (entre paréntesis) y el código de la función por otro entre llaves, separados por una flecha (=>). Se prescinde de la palabra reservada function para definirlas.

La misma función anterior, expresada como arrow function, quedaría así:

let sumar = (num1, num2) => {
    return num1 + num2;
};

Al igual que las funciones anónimas, se pueden asignar a una variable o constante para reutilizarlas más adelante, o bien escribirse directamente en el lugar donde se necesitan.

Cuando la función solo devuelve un valor, se puede simplificar eliminando las llaves y la palabra return, quedando así:

let sumar = (num1, num2) => num1 + num2;

Además, si la función tiene un único parámetro, se pueden omitir los paréntesis. Por ejemplo, esta función devuelve el doble del número que recibe como parámetro:

let doble = num => 2 * num;
console.log(doble(3));         // Mostrará 6

2.3.1. Uso directo de arrow functions

Como comentábamos antes, las arrow functions (igual que las funciones anónimas) pueden escribirse directamente en el lugar donde se necesitan. Esto las hace especialmente útiles como funciones de callback, por ejemplo en los métodos de arrays.

Profundizaremos en los apartados siguientes, pero una función de callback es una función que se pasa como argumento a otra función, para que esta la ejecute más tarde.

Por ejemplo, dado el siguiente listado de datos personales:

let datos = [
    {nombre: "Nacho", telefono: "966112233", edad: 41},
    {nombre: "Ana", telefono: "911223344", edad: 36},
    {nombre: "Mario", telefono: "611998877", edad: 15},
    {nombre: "Laura", telefono: "633663366", edad: 17}
];

Si queremos filtrar las personas mayores de edad, podemos hacerlo con una función anónima combinada con la función filter().

El método filter() crea un nuevo array con todos los elementos que cumplan la condición implementada por la función de callback indicada. filter() llama a la función callback sobre cada elemento del array, y construye un nuevo array con todos los valores para los cuales callback devuelve un valor verdadero. Los elementos del array que no cumplan la condición callback simplemente los salta, y no son incluidos en el nuevo array.

let mayoresDeEdad = datos.filter(function(persona) {
    return persona.edad >= 18;
})
console.log(mayoresDeEdad);

Y también podemos emplear una arrow function en su lugar:

let mayoresDeEdad = datos.filter(persona => persona.edad >= 18);
console.log(mayoresDeEdad);

Observa que, en estos casos, no asignamos la función a una variable para usarla más tarde, sino que se emplean en el mismo punto donde se definen. Notar también que el código queda más compacto empleando una arrow function.

2.4. Arrow functions y funciones tradicionales

La diferencia entre las arrow functions y las funciones tradicionales o anónimas es que las primeras no permiten acceder directamente a this ni al objeto especial arguments (objeto especial que contiene todos los parámetros recibidos), que sí están disponibles en las funciones normales. Por lo tanto, cuando necesitemos usar this o arguments, es más sencillo optar por una función tradicional o anónima.

let persona = {
    nombre: "Ana",
    saludar: function() {
        console.log("Hola, soy " + this.nombre);
    }
};

persona.saludar(); // "Hola, soy Ana" -> this apunta al objeto persona
let saludo = persona.saludar;
saludo(); // "Hola, soy undefined" -> this ya no apunta a persona, sino al contexto global

Con arrow function, this hereda del exterior (no el objeto)

let persona2 = {
    nombre: "Ana",
    /* Definimos "saludar" como arrow function. Esto hace que no comparta
       el contexto del objeto en que está (hereda this del exterior, no del propio objeto) */
    saludar: () => {
        console.log("Hola, soy " + this.nombre);
    }
};

persona2.saludar(); // "Hola, soy undefined" -> this no apunta a persona2, sino al contexto exterior.

En cambio, las arrow functions son muy útiles cuando queremos mantener el this de un método en funciones internas, como en callbacks:

let persona3 = {
    nombre: "Ana",
    saludar: function() {
        setTimeout(() => {
            console.log("Hola, soy " + this.nombre);
        }, 500);
    }
};

persona3.saludar(); // Tras 0.5s: "Hola, soy Ana" 

En el ejemplo anterior, la arrow function hereda this del cuerpo de la función saludar, cuyo this es persona3.

En resumen, usa funciones tradicionales para definir métodos de objetos que necesiten su propio this, y arrow functions para callbacks o funciones internas donde se necesite mantener el this exterior.

Ejercicio 1:

Crea una carpeta llamada “ArrowFunctions” en tu espacio de trabajo, en la carpeta de “Ejercicios”. Crea un archivo fuente dentro llamado arrow_functions.js con el siguiente código:

let datos = [
    {nombre: "Nacho", telefono: "966112233", edad: 41},
    {nombre: "Ana", telefono: "911223344", edad: 36},
    {nombre: "Mario", telefono: "611998877", edad: 15},
    {nombre: "Laura", telefono: "633663366", edad: 17}
];

nuevaPersona({nombre: "Juan", telefono:"965661564", edad: 60});
nuevaPersona({nombre: "Rodolfo", telefono:"910011001", edad: 20});
borrarPersona("910011001");
console.log(datos);

Hemos definido un vector con datos de personas, y un programa principal que llama dos veces a una función nuevaPersona, pasándole como parámetros los objetos con los datos de las personas a añadir. Después, llamamos a una función borrarPersona, pasándole como parámetro un número de teléfono, y mostramos el vector de personas con los datos que haya.

Debes implementar las funciones nuevaPersona y borrarPersona para que hagan su cometido. La primera recibirá la persona como parámetro y, si el teléfono no existe en el vector de personas, la añadirá. Para ello, puedes utilizar el método push del vector:

datos.push(persona);

En cuanto a borrarPersona, eliminará del vector a la persona que tenga dicho teléfono, en caso de que exista. Para eliminar a la persona del vector, puedes simplemente filtrar las personas cuyo teléfono no sea el indicado, y asignar el resultado al propio vector de personas:

datos = datos.filter(persona => persona.telefono != telefonoABuscar);

3. Programación asíncrona

En programación existen dos formas de invocar o llamar a las funciones:

Ejemplo de ejecución síncrona

console.log("Inicio");

function tareaLenta() {
  for (let i = 0; i < 1e10; i++) {} // bucle pesado
  console.log("Tarea lenta terminada");
}

tareaLenta();
console.log("Fin");

En este ejemplo, primero se muestra en pantalla el mensaje “Inicio”. A continuación, se ejecuta la función tareaLenta(), que tarda bastante en completarse debido al bucle interno. Durante este tiempo, el programa permanece bloqueado y no avanza a la siguiente instrucción. Solo cuando la función finaliza, aparece el mensaje “Tarea lenta terminada”, y finalmente se ejecuta la última línea, mostrando “Fin”.

Ejemplo de ejecución asíncrona

console.log("Inicio");

setTimeout(() => {
  for (let i = 0; i < 1e10; i++) {} // bucle pesado
  console.log("Tarea lenta terminada");
}, 1000);

console.log("Fin");

En este otro ejemplo con setTimeout, primero se muestra el mensaje “Inicio”. A continuación, se invoca la función setTimeout(), que es asíncrona: en lugar de bloquear el programa, registra una tarea para que se ejecute después de un tiempo determinado (en este caso, 1000 milisegundos) y permite que la ejecución continúe normalmente. Por eso, el siguiente mensaje que aparece inmediatamente es “Fin”. Una vez transcurridos los segundos, el temporizador dispara la función callback asociada y finalmente se muestra “Tarea lenta terminada”.

Este comportamiento se debe a que setTimeout recibe como parámetro otra función que se ejecutará más adelante, lo que se conoce como callback, concepto que estudiaremos en detalle en el siguiente apartado.

3.1. Los callbacks

Uno de los pilares en los que se sustenta la programación asíncrona en JavaScript lo conforman los callbacks.

Un callback es una función A que se pasa como parámetro a otra B, y que será llamada en algún momento durante la ejecución de B (normalmente cuando B finaliza su tarea). Este concepto es fundamental para dotar a Node.js (y a JavaScript en general) de un comportamiento asíncrono: se llama a una función, y se le deja indicado lo que tiene que hacer cuando termine, y mientras tanto el programa puede dedicarse a otras cosas.

Un ejemplo lo tenemos con la función setTimeout() de JavaScript que acabamos de ver. A esta función le podemos indicar una función a la que llamar, y un tiempo (en milisegundos) que esperar antes de llamarla. Ejecutada la línea de la llamada a setTimeout, el programa sigue su curso y cuando el tiempo expira, se llama a la función callback indicada.

Probemos a escribir este ejemplo en un archivo llamado callback.js en nuestra subcarpeta “ProyectosNode/Pruebas/PruebasSimples”:

setTimeout(function() {console.log("Finalizado callback");}, 2000);
console.log("Hola");

Si ejecutamos el ejemplo, veremos que el primer mensaje que aparece es el de “Hola”, y pasados dos segundos, aparece el mensaje de “Finalizado callback”. Es decir, hemos llamado a setTimeout y el programa ha seguido su curso después, ha escrito “Hola” por pantalla y, una vez ha pasado el tiempo estipulado, se ha llamado al callback para hacer su trabajo.

Utilizaremos callbacks ampliamente durante este curso. De forma especial para procesar el resultado de algunas promesas que emplearemos (ahora veremos qué son las promesas), o el tratamiento de algunas peticiones de servicios.

3.2. Las promesas

Las promesas son otro mecanismo importante para dotar de asincronía a JavaScript. Se emplean para definir la finalización (exitosa o no) de una operación asíncrona. En nuestro código, podemos definir promesas para realizar operaciones asíncronas, o bien (más habitual) utilizar las promesas definidas por otros en el uso de sus librerías.

A lo largo de este curso utilizaremos promesas para, por ejemplo, enviar operaciones a una base de datos y recoger el resultado de las mismas cuando finalicen, sin bloquear el programa principal. Pero para entender mejor qué es lo que haremos, llegado el momento, conviene tener clara la estructura de una promesa y las posibles respuestas que ofrece.

3.2.1. Crear una promesa

En el caso de que queramos o necesitemos crear una promesa, se creará un objeto de tipo Promise. A dicho objeto se le pasa como parámetro una función con dos parámetros:

Estos dos parámetros se suelen llamar, respectivamente, resolve y reject. Por lo tanto, un esqueleto básico de promesa, empleando arrow functions para definir la función a ejecutar, sería así:

let nombreVariable = new Promise((resolve, reject) => {
    // Código a ejecutar
    // Si todo va bien, llamamos a "resolve"
    // Si algo falla, llamamos a "reject"
});

Internamente, la función hará su trabajo y llamará a sus dos parámetros en uno u otro caso. En el caso de resolve, se le suele pasar como parámetro el resultado de la operación, y en el caso de reject se le suele pasar el error producido.

Veámoslo con un ejemplo. La siguiente promesa busca los mayores de edad de la lista de personas vista en un ejemplo anterior. Si se encuentran resultados, se devuelven con la función resolve. De lo contrario, se genera un error que se envía con reject. Copia el ejemplo en un archivo llamado prueba_promesa.js en la carpeta “ProyectosNode/Pruebas/PruebasSimples” de tu espacio de trabajo:

let datos = [
    {nombre: "Nacho", telefono: "966112233", edad: 41},
    {nombre: "Ana", telefono: "911223344", edad: 36},
    {nombre: "Mario", telefono: "611998877", edad: 15},
    {nombre: "Laura", telefono: "633663366", edad: 17}
];

let promesaMayoresDeEdad = new Promise((resolve, reject) => {
    let resultado = datos.filter(persona => persona.edad >= 18);
    if (resultado.length > 0)
        resolve(resultado);
    else
        reject("No hay resultados");
});

La función que define la promesa también se podría definir de esta otra forma:

let promesaMayoresDeEdad = listado => {
    return new Promise((resolve, reject) => {
        let resultado = listado.filter(persona => persona.edad >= 18);
        if (resultado.length > 0)
            resolve(resultado);
        else
            reject("No hay resultados");
    });
};

Así no hacemos uso de variables globales, y el array queda pasado como parámetro a la propia función, que devuelve el objeto Promise una vez concluya. Deja definida la promesa de esta segunda forma en el archivo fuente de prueba.

3.2.2. Consumo de promesas

En el caso de querer utilizar una promesa previamente definida (o creada por otros en alguna librería), simplemente llamaremos a la función u objeto que desencadena la promesa, y recogemos el resultado. En este caso:

Así, la promesa anterior se puede emplear de esta forma (nuevamente, empleamos arrow functions para procesar la cláusula then con su resultado, o el catch con su error):

promesaMayoresDeEdad(datos).then(resultado => {
    // Si entramos aquí, la promesa se ha procesado bien
    // En "resultado" podemos acceder al resultado obtenido
    console.log("Coincidencias encontradas:");
    console.log(resultado);
}).catch(error => {
    // Si entramos aquí, ha habido un error al procesar la promesa
    // En "error" lo podemos consultar
    console.log("Error:", error);
});

Copia este código bajo el código anterior en el archivo prueba_promesa.js creado anteriormente, para comprobar el funcionamiento y lo que muestra la promesa.

Notar que, al definir la promesa, se define también la estructura que tendrá el resultado o el error. En este caso, el resultado es un vector de personas coincidentes con los criterios de búsqueda, y el error es una cadena de texto. Pero pueden ser el tipo de dato que queramos.

Ejercicio 2:

Crea una carpeta llamada “Promesas” en tu espacio de trabajo, en la carpeta de “Ejercicios”. Crea dentro un archivo fuente llamado promesas.js, que sea una copia del archivo fuente arrow_functions.js del ejercicio anterior.

Lo que vas a hacer en este ejercicio es adaptar las dos funciones nuevaPersona y borrarPersona para que devuelvan una promesa.

En el caso de nuevaPersona, se devolverá con resolve el objeto persona insertado, si la inserción fue satisfactoria, o con reject el mensaje “Error: el teléfono ya existe” si no se pudo insertar la persona porque ya existía su teléfono en el vector

En el caso de borrarPersona, se devolverá con resolve el objeto persona eliminado, si el borrado fue satisfactorio, o con reject un mensaje “Error: no se encontraron coincidencias” si no existía ninguna persona con ese teléfono en el vector.

Modifica el código del programa principal para que intente añadir una persona correcta y otra equivocada (teléfono ya existente en el vector), y borrar una persona correcta y otra equivocada (teléfono no existente en el vector). Comprueba que el resultado al ejecutar es el que esperabas.

3.2.3. La especificación async/await

Desde ES8 se tiene disponible una nueva forma de trabajar con llamadas asíncronas, a través de la especificación async/await. Es una forma más cómoda de llamar a funciones asíncronas y recoger su resultado antes de llamar a otra, sin necesidad de ir anidando cláusulas then para enlazar el resultado de una promesa con la siguiente.

Vamos a reescribir un ejemplo anterior hecho con promesas usando esta especificación. Partimos del mismo array de personas:

let datos = [
    {nombre: "Nacho", telefono: "966112233", edad: 41},
    {nombre: "Ana", telefono: "911223344", edad: 36},
    {nombre: "Mario", telefono: "611998877", edad: 15},
    {nombre: "Laura", telefono: "633663366", edad: 17}
];

Construimos ahora nuestra función asíncrona para buscar personas mayores de edad. Es similar a la anterior, pero le añadimos la partícula async para indicar que es una función asíncrona. Esto hace que la función siempre devuelva una promesa, sin importar si el retorno es un valor normal o un error.

let promesaMayoresDeEdad = async listado => {
    return new Promise((resolve, reject) => {
        let resultado = listado.filter(persona => persona.edad >= 18);
        if (resultado.length > 0)
            resolve(resultado);
        else
            reject("No hay resultados");
    });
};

En realidad, en este caso no hace falta añadir la partícula async porque la función, al devolver una promesa, ya retorna algo asíncrono. Podemos utilizar async sin crear la promesa manualmente, dejando que sea el propio motor de JavaScript el que envuelva el valor de retorno en Promise.resolve(...)y convierta cualquier throw en un Promise.reject(...). De esta manera el código queda más limpio:

const promesaMayoresDeEdad = async (listado) => {
  const resultado = listado.filter(p => p.edad >= 18);
  if (resultado.length === 0) throw new Error("No hay resultados");
  return resultado;
};

En este caso, el uso de async hace que cualquier valor retornado se convierta automáticamente en una promesa resuelta y cualquier error lanzado con throw en una promesa rechazada. Esta es la forma más clara y moderna de trabajar, y la recomendada en la mayoría de proyectos.

A la hora de invocar a esta función podemos hacerlo del mismo modo que antes (con then/catch) o usando la partícula await. Esta hace que el código espere a que la promesa se resuelva antes de continuar. También podemos envolver la llamada en un bloque try..catch para capturar el posible error (reject):

try
{
    let adultos = await promesaMayoresDeEdad(datos);
    // Al llegar aquí ya tenemos el listado
    console.log("Resultados:", adultos);
} catch(e) {
    // Error
    console.log(e);
}

Podemos incluir varias llamadas await en el bloque try (o en bloques try consecutivos) si queremos que una operación se realice de forma síncrona antes que otra.

try
{
    let adultos = await promesaMayoresDeEdad(datos);
    // Al llegar aquí ya tenemos el listado
    console.log("Resultados:", adultos);
    
    // Otra operación
    let otraVariable = await ...;
} catch(e) {
    // Error de cualquiera de las operaciones lanzadas
    console.log(e);
}

Llamadas asíncronas en versiones anteriores de Node y JavaScript

En versiones anteriores de Node y JavaScript no se podía utilizar await desde fuera de un bloque asíncrono. Por lo tanto, era habitual definir una función async que invoque al resto, y llamar a ésta desde el programa principal:

async function principal()
{
    let adultos = await promesaMayoresDeEdad(datos);
    console.log(adultos);
}

En versiones posteriores sí se permitió la llamada a await desde fuera de bloques asíncronos, siempre que el proyecto cumpliera ciertos requisitos (por ejemplo, que el fichero fuente se renombrara a extensión .mjs, indicando que es un módulo EcmaScript (ES module)). Pero, en entornos más modernos, es posible usar top-level await (sin necesidad de envolver en una función async), como hemos hecho en el ejemplo anterior.

3.2.4. Uso avanzado de llamadas asíncronas

En los ejemplos anteriores hemos hecho un uso simple de la asincronía, donde hemos llamado a una función asíncrona que, cuando terminaba, generaba un resultado. A continuación vamos a combinar varias tareas de forma asíncrona de modo que tengan que esperarse entre ellas para terminar. Lo haremos a través de un ejemplo incremental, así que, para empezar, puedes crear un fichero asincronia_avanzada.js en tu carpeta de Ejercicios. Comenzaremos con este código inicial:

let datos = [
    { nombre: "Nacho", telefono: "966112233", edad: 41 },
    { nombre: "Ana", telefono: "911223344", edad: 36 },
    { nombre: "Mario", telefono: "611998877", edad: 15 },
    { nombre: "Laura", telefono: "633663366", edad: 17 }
];

// Busca una persona por teléfono
const buscarPorTelefono = (telefono) => {
    return new Promise((resolve, reject) => {
    // Añadimos setTimeout para simular un proceso más largo
    setTimeout(() => {
        const encontrada = datos.find(p => p.telefono === telefono);
        if (encontrada) resolve(encontrada);
        else reject(`Error: No existe el teléfono ${telefono}`);
    }, 200);
    });
};

// Inserta una persona si no existe el teléfono
const nuevaPersona = (persona) => {
    return new Promise((resolve, reject) => {
    setTimeout(() => {
        const existe = datos.some(p => p.telefono === persona.telefono);
        if (!existe) {
            datos.push(persona);
            resolve(persona);
        } else {
            reject(`Error: El teléfono ${persona.telefono} ya existe`);
        }
    }, 200);
    });
};

// Borra una persona por teléfono
const borrarPersona = (telefono) => {
    return new Promise((resolve, reject) => {
    setTimeout(() => {
        const existe = datos.some(p => p.telefono === telefono);
        if (existe) {
            const borrada = datos.find(p => p.telefono === telefono);
            datos = datos.filter(p => p.telefono !== telefono);
            resolve(borrada);
        } else {
            reject(`Error: No se pudo borrar el teléfono ${telefono}`);
        }
    }, 200);
    });
};

Como puedes ver, trabajamos con el mismo array de personas de ejemplos anteriores, y hemos definido tres funciones asíncronas:

Vamos a implementar un flujo de actualización complejo basado en estos pasos:

  1. Buscar si existe la persona con teléfono “911223344” (Ana).
  2. Si existe, insertar un nuevo contacto: { nombre: “Carlos”, telefono: “965123456”, edad: 28 }.
  3. Una vez insertado Carlos, eliminar a “Mario” usando su teléfono (“611998877”).
  4. Al finalizar el borrado, mostrar por consola: Proceso completado con éxito.

Como puedes ver, se trata de tareas que deben esperar al resultado de la anterior para realizarse. Lo resolveremos de distintas formas.

Paso 1: El promise hell

Vamos a ejecutar el código con bloques then/catch. Para asegurarnos de que una tarea se ejecuta cuando se ha realizado la otra, debemos lanzarla dentro del bloque then de la anterior:

buscarPorTelefono("911223344")
.then(ana => {
    console.log("Encontrada:", ana.nombre);
    nuevaPersona({ nombre: "Carlos", telefono: "965123456", edad: 28 })
    .then(carlos => {
        console.log("Insertado:", carlos.nombre);
        borrarPersona("611998877")
        .then(mario => {
            console.log("Borrado:", mario.nombre);
            console.log("Proceso completado con éxito");
        })
        .catch(err => console.log("Error al borrar:", err));
    })
    .catch(err => console.log("Error al insertar:", err));
})
.catch(err => console.log("Error al buscar:", err));

Observa que cada cláusula then hace que el siguiente código quede más anidado, y el siguiente then lo anidará aún más. Esto es lo que se conoce como promise hell o el “infierno de las promesas”, porque al anidarlas el código queda cada vez más indentado y difícil de manejar.

Paso 2: encadenamiento plano

Afortunadamente, cuando enlazamos promesas no tenemos por qué anidarlas. La especificación permite enlazar varios bloques then encadenando adecuadamente los retornos para no anidarlas. Esto se consigue haciendo que cada promesa devuelva la siguiente a ejecutar.

buscarPorTelefono("911223344")
.then(ana => {
    console.log("Encontrada:", ana.nombre);
    // Devolvemos (lanzamos) la siguiente promesa
    return nuevaPersona({ nombre: "Carlos", telefono: "965123456", edad: 28 });
})
.then(carlos => {
    console.log("Insertado:", carlos.nombre);
    // Siguiente promesa
    return borrarPersona("611998877");
})
.then(mario => {
    console.log("Borrado:", mario.nombre);
    console.log("Proceso completado con éxito");
})
.catch(error => {
    // Un solo punto de control para cualquier fallo en la cadena
    console.log("Error en el proceso:", error);
});

Paso 3: modernización con async/await y control estructurado

Como alternativa al código anterior, podemos hacer uso de la especificación async/await y enlazar así las llamadas a las diferentes tareas asíncronas. En este caso, es conveniente englobar todo el código en un bloque try/catch para gestionar los errores.

Ejercicio 3: transforma el ejemplo anterior en otro que use async/await con try/catch.