Encadenamiento de promesas
Introducción
Las promesas en JavaScript nos permiten manejar operaciones asíncronas de forma elegante, pero su verdadero potencial se desbloquea cuando aprendemos a encadenarlas. El encadenamiento de promesas es una técnica fundamental que nos permite ejecutar operaciones asíncronas en secuencia, pasando datos entre ellas y manejando errores de manera organizada. Esta capacidad de encadenar operaciones convierte código que podría ser complejo y difícil de leer en una estructura clara y mantenible.
En este artículo, exploraremos cómo funcionan las cadenas de promesas, sus ventajas frente a otras técnicas y los patrones más efectivos para implementarlas en nuestras aplicaciones.
¿Qué es el encadenamiento de promesas?
El encadenamiento de promesas es una técnica que nos permite ejecutar múltiples operaciones asíncronas de forma secuencial, donde cada operación comienza cuando la anterior se completa. Esto se logra gracias a la capacidad del método then()
de devolver una nueva promesa.
Cuando encadenamos promesas:
- Cada llamada a
then()
devuelve una nueva promesa - Podemos conectar múltiples
then()
en secuencia - El valor devuelto por un
then()
se pasa como parámetro al siguientethen()
Veamos un ejemplo básico:
// Simulamos una operación asíncrona que tarda 1 segundo
function operacion1() {
return new Promise((resolve) => {
setTimeout(() => {
console.log("Operación 1 completada");
resolve(10); // Resolvemos con un valor
}, 1000);
});
}
// Segunda operación que recibe el resultado de la primera
function operacion2(valor) {
return new Promise((resolve) => {
setTimeout(() => {
const resultado = valor * 2;
console.log(`Operación 2 completada: ${valor} * 2 = ${resultado}`);
resolve(resultado); // Pasamos el resultado a la siguiente promesa
}, 1000);
});
}
// Tercera operación
function operacion3(valor) {
return new Promise((resolve) => {
setTimeout(() => {
const resultado = valor + 5;
console.log(`Operación 3 completada: ${valor} + 5 = ${resultado}`);
resolve(resultado);
}, 1000);
});
}
// Encadenamos las operaciones
operacion1()
.then(resultado => operacion2(resultado))
.then(resultado => operacion3(resultado))
.then(resultadoFinal => {
console.log(`Resultado final: ${resultadoFinal}`);
});
En este ejemplo, cada operación espera a que la anterior termine antes de ejecutarse, y el resultado de una se pasa como parámetro a la siguiente.
Retorno de valores entre then()
El valor que devolvemos en una función manejadora dentro de then()
se pasa automáticamente al siguiente then()
en la cadena. Esto es fundamental para entender cómo funciona el encadenamiento.
Podemos devolver dos tipos de valores:
- Valores simples: Números, cadenas, objetos, etc.
- Promesas: Cuando devolvemos una promesa, el siguiente
then()
espera a que esta promesa se resuelva.
Veamos un ejemplo con diferentes tipos de retorno:
let promesa = new Promise((resolve) => {
resolve(1); // Comenzamos con el valor 1
});
promesa
.then(valor => {
console.log(`Valor inicial: ${valor}`); // 1
return valor + 1; // Devolvemos un valor simple (2)
})
.then(valor => {
console.log(`Después del primer then: ${valor}`); // 2
// Devolvemos una promesa que se resuelve con valor * 2
return new Promise(resolve => {
setTimeout(() => {
resolve(valor * 2);
}, 1000);
});
})
.then(valor => {
console.log(`Después del segundo then: ${valor}`); // 4
return `El resultado es: ${valor}`; // Devolvemos una cadena
})
.then(mensaje => {
console.log(mensaje); // "El resultado es: 4"
});
Este ejemplo muestra cómo podemos devolver tanto valores simples como promesas, y cómo el siguiente then()
siempre recibe el valor resuelto.
Retorno de nuevas promesas
Cuando devolvemos una nueva promesa dentro de un then()
, el siguiente then()
en la cadena no se ejecutará hasta que esta nueva promesa se resuelva. Esto es lo que permite secuenciar operaciones asíncronas que dependen unas de otras.
function buscarUsuario(id) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`Buscando usuario con id ${id}...`);
resolve({
id: id,
nombre: "Ana",
email: "ana@ejemplo.com"
});
}, 1000);
});
}
function buscarPublicaciones(usuario) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`Buscando publicaciones de ${usuario.nombre}...`);
resolve({
usuario: usuario,
publicaciones: [
{ id: 1, titulo: "JavaScript es genial" },
{ id: 2, titulo: "Promesas en JavaScript" }
]
});
}, 1000);
});
}
// Encadenamos las operaciones
buscarUsuario(123)
.then(usuario => {
console.log(`Usuario encontrado: ${usuario.nombre}`);
// Devolvemos una nueva promesa
return buscarPublicaciones(usuario);
})
.then(resultado => {
console.log(`Publicaciones de ${resultado.usuario.nombre}:`);
resultado.publicaciones.forEach(pub => {
console.log(`- ${pub.titulo}`);
});
});
En este ejemplo, buscarPublicaciones
no comienza hasta que buscarUsuario
se haya completado, y además recibe los datos del usuario para utilizarlos en su operación.
Procesamiento secuencial vs. paralelo
Es importante entender la diferencia entre ejecutar promesas en secuencia (una tras otra) o en paralelo (todas a la vez):
Procesamiento secuencial
Cuando cada promesa depende del resultado de la anterior, necesitamos procesamiento secuencial:
// Procesamiento secuencial
function obtenerDatoSecuencial(numero) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`Procesando dato ${numero}`);
resolve(numero);
}, 1000);
});
}
// Las promesas se ejecutan una tras otra (tarda 3 segundos en total)
obtenerDatoSecuencial(1)
.then(valor => obtenerDatoSecuencial(valor + 1))
.then(valor => obtenerDatoSecuencial(valor + 1))
.then(valorFinal => {
console.log(`Valor final: ${valorFinal}`);
});
Procesamiento paralelo
Cuando las promesas son independientes entre sí, podemos procesarlas en paralelo con métodos como Promise.all()
:
// Procesamiento paralelo
function obtenerDatoParalelo(numero) {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`Procesando dato ${numero}`);
resolve(numero);
}, 1000);
});
}
// Las promesas se ejecutan todas a la vez (tarda solo 1 segundo)
Promise.all([
obtenerDatoParalelo(1),
obtenerDatoParalelo(2),
obtenerDatoParalelo(3)
])
.then(resultados => {
console.log(`Resultados: ${resultados}`); // [1, 2, 3]
});
Manejo de errores en cadenas
Una gran ventaja del encadenamiento de promesas es que podemos manejar errores de forma centralizada. Si ocurre un error en cualquier punto de la cadena, la ejecución saltará al catch()
más cercano:
function paso1() {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log("Paso 1 completado");
resolve(10);
}, 1000);
});
}
function paso2(valor) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log("Paso 2 ejecutándose");
// Simulamos un error
reject(new Error("Algo salió mal en el paso 2"));
}, 1000);
});
}
function paso3(valor) {
return new Promise((resolve) => {
setTimeout(() => {
console.log("Paso 3 completado");
resolve(valor + 5);
}, 1000);
});
}
paso1()
.then(resultado => paso2(resultado)) // Si falla, salta al catch
.then(resultado => paso3(resultado)) // Este no se ejecutará si hay error
.then(resultadoFinal => {
console.log(`Éxito: ${resultadoFinal}`);
})
.catch(error => {
console.error(`Error capturado: ${error.message}`);
// Podemos devolver un valor por defecto para continuar
return "Valor de recuperación";
})
.then(valor => {
console.log(`Continuando después del error con: ${valor}`);
});
En este ejemplo, cuando el paso2
falla, la ejecución salta directamente al catch()
, omitiendo el paso3
.
Promise.all(), Promise.race() y Promise.allSettled()
JavaScript proporciona métodos para trabajar con múltiples promesas:
Promise.all()
Recibe un array de promesas y devuelve una nueva promesa que se resuelve cuando todas las promesas del array se resuelven, o se rechaza tan pronto como una de ellas falla:
const promesa1 = Promise.resolve(1);
const promesa2 = new Promise((resolve) => setTimeout(() => resolve(2), 1000));
const promesa3 = new Promise((resolve) => setTimeout(() => resolve(3), 500));
Promise.all([promesa1, promesa2, promesa3])
.then(valores => {
console.log(valores); // [1, 2, 3]
})
.catch(error => {
console.error(`Una promesa falló: ${error}`);
});
Promise.race()
Devuelve una promesa que se resuelve o rechaza tan pronto como una de las promesas del array se resuelve o rechaza:
const promesaRapida = new Promise((resolve) => setTimeout(() => resolve("¡Rápida!"), 500));
const promesaLenta = new Promise((resolve) => setTimeout(() => resolve("Lenta"), 1000));
Promise.race([promesaRapida, promesaLenta])
.then(resultado => {
console.log(resultado); // "¡Rápida!"
});
Promise.allSettled()
A diferencia de Promise.all(), espera a que todas las promesas finalicen, independientemente de si se resuelven o rechazan:
const promesaExitosa = Promise.resolve("Éxito");
const promesaFallida = Promise.reject(new Error("Falló"));
Promise.allSettled([promesaExitosa, promesaFallida])
.then(resultados => {
console.log(resultados);
// [
// { status: "fulfilled", value: "Éxito" },
// { status: "rejected", reason: Error: "Falló" }
// ]
});
Patrones avanzados de encadenamiento
Procesamiento secuencial de un array
Cuando necesitamos procesar elementos de un array de forma secuencial:
const usuarios = [1, 2, 3, 4, 5];
// Procesamiento secuencial de usuarios
usuarios.reduce((promesaAnterior, idUsuario) => {
return promesaAnterior.then(() => {
return new Promise(resolve => {
setTimeout(() => {
console.log(`Procesando usuario ${idUsuario}`);
resolve();
}, 1000);
});
});
}, Promise.resolve());
Reintentos automáticos
Implementar reintentos automáticos cuando una operación falla:
function operacionPropensaAFallos() {
return new Promise((resolve, reject) => {
const exito = Math.random() > 0.7; // 70% de probabilidad de fallar
setTimeout(() => {
if (exito) {
resolve("Operación exitosa");
} else {
reject(new Error("La operación ha fallado"));
}
}, 500);
});
}
function intentarOperacion(intentosMaximos) {
let intentos = 0;
function intentar() {
intentos++;
console.log(`Intento ${intentos}...`);
return operacionPropensaAFallos()
.catch(error => {
if (intentos < intentosMaximos) {
console.log(`Falló, reintentando... (${error.message})`);
return intentar(); // Recursión
} else {
throw new Error(`Fallaron todos los intentos: ${error.message}`);
}
});
}
return intentar();
}
intentarOperacion(3)
.then(resultado => console.log(`Éxito: ${resultado}`))
.catch(error => console.error(`Error final: ${error.message}`));
Ejecución controlada (límite de concurrencia)
Para limitar el número de operaciones asíncronas que se ejecutan simultáneamente:
function ejecutarConLimite(tareas, limiteConcurrencia) {
let indiceActual = 0;
const resultados = [];
let tareasActivas = 0;
return new Promise(resolve => {
function iniciarSiguienteTarea() {
// Si ya procesamos todas las tareas y no hay más activas, terminamos
if (indiceActual >= tareas.length && tareasActivas === 0) {
resolve(resultados);
return;
}
// Mientras haya tareas pendientes y no superemos el límite
while (indiceActual < tareas.length && tareasActivas < limiteConcurrencia) {
const indice = indiceActual++;
const tarea = tareas[indice];
tareasActivas++;
// Ejecutamos la tarea
Promise.resolve(tarea())
.then(resultado => {
resultados[indice] = resultado;
tareasActivas--;
// Intentamos iniciar más tareas
iniciarSiguienteTarea();
})
.catch(error => {
resultados[indice] = { error };
tareasActivas--;
iniciarSiguienteTarea();
});
}
}
iniciarSiguienteTarea();
});
}
// Ejemplo de uso
const tareas = [
() => new Promise(resolve => setTimeout(() => resolve("Tarea 1"), 2000)),
() => new Promise(resolve => setTimeout(() => resolve("Tarea 2"), 1000)),
() => new Promise(resolve => setTimeout(() => resolve("Tarea 3"), 1500)),
() => new Promise(resolve => setTimeout(() => resolve("Tarea 4"), 500)),
() => new Promise(resolve => setTimeout(() => resolve("Tarea 5"), 1000))
];
ejecutarConLimite(tareas, 2) // Solo 2 tareas al mismo tiempo
.then(resultados => console.log(resultados));
Organización del código asíncrono
El encadenamiento de promesas nos ayuda a estructurar mejor nuestro código asíncrono. A continuación, algunas recomendaciones:
- Extraer funciones: Crea funciones específicas para cada paso del proceso.
- Nombrar adecuadamente: Usa nombres descriptivos que indiquen qué hace cada promesa.
- Modularizar: Separa la lógica en módulos reutilizables.
- Manejar errores específicos: Implementa bloques catch para diferentes tipos de errores.
- Documentar: Comenta el propósito de cada etapa y sus dependencias.
// Ejemplo de código bien organizado
function obtenerDatosUsuario(id) {
return obtenerUsuario(id)
.then(usuario => {
return Promise.all([
Promise.resolve(usuario),
obtenerPermisosUsuario(usuario.id)
]);
})
.then(([usuario, permisos]) => {
return {
...usuario,
permisos
};
})
.catch(error => {
console.error("Error al obtener datos de usuario:", error);
throw new Error(`No se pudo completar la operación: ${error.message}`);
});
}
// Uso
obtenerDatosUsuario(123)
.then(datosCompletos => {
console.log("Datos completos:", datosCompletos);
})
.catch(error => {
console.error("Error general:", error);
});
Resumen
El encadenamiento de promesas es una técnica poderosa que nos permite organizar código asíncrono de manera secuencial y legible. A través de este enfoque, podemos procesar operaciones que dependen del resultado de operaciones anteriores, manejar errores de forma centralizada y construir flujos de trabajo complejos.
Las claves para un encadenamiento efectivo son:
- Comprender que cada
then()
devuelve una nueva promesa - Saber cuándo usar procesamiento secuencial vs. paralelo
- Implementar un manejo de errores robusto
- Utilizar métodos como
Promise.all()
yPromise.race()
para coordinar múltiples promesas
Con estas herramientas, puedes transformar código asíncrono complejo en estructuras claras y mantenibles, mejorando significativamente la calidad de tu código JavaScript.