¿Por qué funciona el uso de reduce () para resolver secuencialmente las promesas? Programar Plus

Escribir JavaScript asincrónico sin usar el Promise El objeto se parece mucho a hornear un pastel con los ojos cerrados. Se puede hacer, pero será complicado y probablemente terminarás quemando.

No diré que sea necesario, pero entiendes la idea. Es realmente agradable. A veces, sin embargo, necesita un poco de ayuda para resolver algunos desafíos únicos, como cuando intentas resolver secuencialmente un montón de promesas en orden, una tras otra. Un truco como este es útil, por ejemplo, cuando está realizando algún tipo de procesamiento por lotes a través de AJAX. Desea que el servidor procese un montón de cosas, pero no todas a la vez, por lo que espacia el procesamiento a lo largo del tiempo.

Descartando paquetes que ayuden a facilitar esta tarea (como la biblioteca asíncrona de Caolan McMahon), la solución sugerida más comúnmente para resolver promesas secuencialmente es usar Array.prototype.reduce(). Es posible que hayas oído hablar de este. Tome una colección de cosas y redúzcalas a un solo valor, como este:

let result = [1,2,5].reduce((accumulator, item) => {
  return accumulator + item;
}, 0); // <-- Our initial value.

console.log(result); // 8

Pero, al usar reduce() para nuestros propósitos, la configuración se parece más a esto:

let userIDs = [1,2,3];

userIDs.reduce( (previousPromise, nextID) => {
  return previousPromise.then(() => {
    return methodThatReturnsAPromise(nextID);
  });
}, Promise.resolve());

O, en un formato más moderno:

let userIDs = [1,2,3];

userIDs.reduce( async (previousPromise, nextID) => {
  await previousPromise;
  return methodThatReturnsAPromise(nextID);
}, Promise.resolve());

¡Esto es genial! Pero durante mucho tiempo, me tragué esta solución y copié ese fragmento de código en mi aplicación porque “funcionó”. En esta publicación, estoy tratando de entender dos cosas:

  1. ¿Por qué funciona este enfoque?
  2. ¿Por qué no podemos usar otros Array métodos para hacer lo mismo?

¿Por qué esto incluso funciona?

Recuerde, el propósito principal de reduce() es “reducir” un montón de cosas en una sola cosa, y lo hace almacenando el resultado en la accumulator mientras se ejecuta el bucle. Pero eso accumulator no tiene que ser numérico. El bucle puede devolver lo que quiera (como una promesa) y reciclar ese valor a través de la devolución de llamada en cada iteración. En particular, no importa lo que accumulator El valor es que el bucle en sí nunca cambia su comportamiento, incluido su ritmo de ejecución. Simplemente sigue avanzando a través de la colección tan rápido como lo permite el hilo.

Esto es enorme de entender porque probablemente va en contra de lo que crees que está sucediendo durante este ciclo (al menos, lo hizo para mí). Cuando lo usamos para resolver promesas secuencialmente, el reduce() bucle en realidad no se está desacelerando en absoluto. Es completamente sincrónico, haciendo su trabajo normal tan rápido como puede, como siempre.

Mire el siguiente fragmento y observe cómo el progreso del bucle no se ve obstaculizado en absoluto por las promesas devueltas en la devolución de llamada.

function methodThatReturnsAPromise(nextID) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {

      console.log(`Resolve! ${dayjs().format('hh:mm:ss')}`);

      resolve();
    }, 1000);
  });
}

[1,2,3].reduce( (accumulatorPromise, nextID) => {

  console.log(`Loop! ${dayjs().format('hh:mm:ss')}`);

  return accumulatorPromise.then(() => {
    return methodThatReturnsAPromise(nextID);
  });
}, Promise.resolve());

En nuestra consola:

"Loop! 11:28:06"
"Loop! 11:28:06"
"Loop! 11:28:06"
"Resolve! 11:28:07"
"Resolve! 11:28:08"
"Resolve! 11:28:09"

Las promesas se resuelven en el orden esperado, pero el ciclo en sí es rápido, constante y sincrónico. Después de mirar el polyfill MDN para reduce(), Esto tiene sentido. No hay nada asincrónico en un while() bucle que activa el callback() una y otra vez, que es lo que está sucediendo debajo del capó:

while (k < len) {
  if (k in o) {
    value = callback(value, o[k], k, o);
  }
  k++;
}

Con todo eso en mente, la verdadera magia ocurre en esta pieza aquí mismo:

return previousPromise.then(() => {
  return methodThatReturnsAPromise(nextID)
});

Cada vez que se activa nuestra devolución de llamada, devolvemos una promesa que se resuelve en otra promesa. Y mientras reduce() no espera a que se produzca ninguna resolución, la ventaja que proporciona es la capacidad de devolver algo a la misma devolución de llamada después de cada ejecución, una característica única de reduce(). Como resultado, podemos construir una cadena de promesas que se resuelven en más promesas, haciendo que todo sea agradable y secuencial:

new Promise( (resolve, reject) => {
  // Promise #1
  
  resolve();
}).then( (result) => { 
  // Promise #2
  
  return result;
}).then( (result) => { 
  // Promise #3
  
  return result;
}); // ... and so on!

Todo esto también debería revelar por qué no podemos devolver una única promesa nueva en cada iteración. Debido a que el ciclo se ejecuta sincrónicamente, cada promesa se activará inmediatamente, en lugar de esperar a las creadas antes.

[1,2,3].reduce( (previousPromise, nextID) => {

  console.log(`Loop! ${dayjs().format('hh:mm:ss')}`);
  
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log(`Resolve! ${dayjs().format('hh:mm:ss')}`);
      resolve(nextID);
    }, 1000);
  });
}, Promise.resolve());

En nuestra consola:

"Loop! 11:31:20"
"Loop! 11:31:20"
"Loop! 11:31:20"
"Resolve! 11:31:21"
"Resolve! 11:31:21"
"Resolve! 11:31:21"

¿Es posible esperar hasta que finalice todo el procesamiento antes de hacer otra cosa? Si. La naturaleza sincrónica de reduce() no significa que no puedas organizar una fiesta después de que todos los elementos se hayan procesado por completo. Mirar:

function methodThatReturnsAPromise(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log(`Processing ${id}`);
      resolve(id);
    }, 1000);
  });
}

let result = [1,2,3].reduce( (accumulatorPromise, nextID) => {
  return accumulatorPromise.then(() => {
    return methodThatReturnsAPromise(nextID);
  });
}, Promise.resolve());

result.then(e => {
  console.log("Resolution is complete! Let's party.")
});

Dado que todo lo que devolvemos en nuestra devolución de llamada es una promesa encadenada, eso es todo lo que obtenemos cuando finaliza el ciclo: una promesa. Después de eso, podemos manejarlo como queramos, incluso mucho después reduce() ha seguido su curso.

¿Por qué no funcionan otros métodos Array?

Recuerda, bajo el capó de reduce(), no estamos esperando a que se complete nuestra devolución de llamada antes de pasar al siguiente elemento. Es completamente sincrónico. Lo mismo ocurre con todos estos otros métodos:

  • Array.prototype.map()
  • Array.prototype.forEach()
  • Array.prototype.filter()
  • Array.prototype.some()
  • Array.prototype.every()

Pero reduce() es especial.

Descubrimos que la razón reduce() funciona para nosotros es porque podemos devolver algo directamente a nuestra misma devolución de llamada (es decir, una promesa), que luego podemos aprovechar al hacer que se resuelva en otra promesa. Sin embargo, con todos estos otros métodos, simplemente no podemos pasar un argumento a nuestra devolución de llamada que fue devuelto por nuestra devolución de llamada. En cambio, cada uno de esos argumentos de devolución de llamada están predeterminados, lo que nos hace imposible aprovecharlos para algo como la resolución secuencial de promesas.

[1,2,3].map((item, [index, array]) => [value]);
[1,2,3].filter((item, [index, array]) => [boolean]);
[1,2,3].some((item, [index, array]) => [boolean]);
[1,2,3].every((item, [index, array]) => [boolean]);

¡Espero que esto ayude!

Como mínimo, espero que esto ayude a aclarar por qué reduce() está especialmente calificado para manejar promesas de esta manera, y tal vez darle una mejor comprensión de cuán común es Array Los métodos operan bajo el capó. ¿Me he perdido algo? ¿Conseguiste algo mal? ¡Hágamelo saber!