Warum die Verwendung von reduce() zur sequenziellen Auflösung von Promises funktioniert

Avatar of Alex MacArthur
Alex MacArthur am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Asynchrones JavaScript zu schreiben, ohne das Promise-Objekt zu verwenden, ist wie Kuchenbacken mit geschlossenen Augen. Es kann gemacht werden, aber es wird unordentlich sein und Sie werden sich wahrscheinlich verbrennen.

Ich sage nicht, dass es notwendig ist, aber Sie verstehen, was ich meine. Es ist wirklich gut. Manchmal braucht es jedoch ein wenig Hilfe, um einige einzigartige Herausforderungen zu lösen, wie z. B. wenn Sie eine Reihe von Promises sequenziell nacheinander auflösen möchten. Ein solcher Trick ist beispielsweise praktisch, wenn Sie eine Art Stapelverarbeitung über AJAX durchführen. Sie möchten, dass der Server eine Reihe von Dingen verarbeitet, aber nicht alle auf einmal, also verteilen Sie die Verarbeitung über die Zeit.

Wenn wir Pakete ausschließen, die diese Aufgabe erleichtern (wie die async Bibliothek von Caolan McMahon), ist die am häufigsten vorgeschlagene Lösung für die sequenzielle Auflösung von Promises die Verwendung von Array.prototype.reduce(). Von diesem haben Sie vielleicht schon gehört. Nehmen Sie eine Sammlung von Dingen und reduzieren Sie sie auf einen einzigen Wert, wie diesen

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

console.log(result); // 8

Wenn wir reduce() jedoch für unsere Zwecke verwenden, sieht die Einrichtung eher so aus

let userIDs = [1,2,3];

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

Oder, in einem moderneren Format

let userIDs = [1,2,3];

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

Das ist nett! Aber sehr lange habe ich diese Lösung einfach geschluckt und diesen Codeblock in meine Anwendung kopiert, weil er "funktionierte". Dieser Beitrag ist mein Versuch, zwei Dinge zu verstehen:

  1. Warum funktioniert dieser Ansatz überhaupt?
  2. Warum können wir keine anderen Array-Methoden verwenden, um dasselbe zu tun?

Warum funktioniert dieser Ansatz überhaupt?

Denken Sie daran, dass der Hauptzweck von reduce() darin besteht, eine Reihe von Dingen zu "reduzieren" und dies tut, indem das Ergebnis im accumulator gespeichert wird, während die Schleife läuft. Aber dieser accumulator muss nicht numerisch sein. Die Schleife kann alles zurückgeben, was sie möchte (wie ein Promise), und diesen Wert bei jeder Iteration über die Callback-Funktion wiederverwenden. Beachten Sie, dass die Schleife unabhängig vom Wert des accumulator ihr Verhalten, einschließlich der Ausführungsgeschwindigkeit, niemals ändert. Sie läuft einfach so schnell durch die Sammlung, wie es der Thread zulässt.

Das ist wichtig zu verstehen, denn es widerspricht wahrscheinlich dem, was Sie denken, was während dieser Schleife passiert (zumindest bei mir war das so). Wenn wir es zur sequenziellen Auflösung von Promises verwenden, verlangsamt sich die reduce()-Schleife tatsächlich überhaupt nicht. Sie ist vollständig synchron und tut ihr normales Ding, so schnell sie kann, wie immer.

Schauen Sie sich den folgenden Ausschnitt an und bemerken Sie, wie der Fortschritt der Schleife durch die im Callback zurückgegebenen Promises überhaupt nicht behindert wird.

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());

In unserer Konsole

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

Die Promises lösen sich wie erwartet in der richtigen Reihenfolge auf, aber die Schleife selbst ist schnell, stetig und synchron. Nachdem ich mir die MDN-Polyfill für reduce() angesehen habe, macht das Sinn. Es gibt nichts Asynchrones an einer while()-Schleife, die immer wieder die callback() auslöst, was im Hintergrund passiert.

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

Mit all dem im Hinterkopf geschieht die eigentliche Magie in diesem Teil hier

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

Jedes Mal, wenn unser Callback ausgelöst wird, geben wir ein Promise zurück, das sich zu einem weiteren Promise auflöst. Und während reduce() nicht auf eine Auflösung wartet, bietet der Vorteil, dass wir nach jedem Durchlauf etwas an denselben Callback zurückgeben können, eine Funktion, die nur reduce() eigen ist. Infolgedessen können wir eine Kette von Promises aufbauen, die sich in weitere Promises auflösen, wodurch alles schön und sequenziell wird.

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

Das alles sollte auch offenlegen, warum wir nicht einfach jedes Mal ein einzelnes, neues Promise zurückgeben können. Da die Schleife synchron läuft, wird jedes Promise sofort ausgelöst, anstatt auf die zuvor erstellten zu warten.

[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());

In unserer Konsole

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

Ist es möglich zu warten, bis alle Verarbeitungen abgeschlossen sind, bevor etwas anderes getan wird? Ja. Die synchrone Natur von reduce() bedeutet nicht, dass Sie keine Party veranstalten können, nachdem jeder Artikel vollständig verarbeitet wurde. Schauen Sie

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.")
});

Da wir in unserem Callback nur ein verkettetes Promise zurückgeben, ist das alles, was wir am Ende der Schleife erhalten: ein Promise. Danach können wir es nach Belieben behandeln, auch lange nachdem reduce() seinen Lauf beendet hat.

Warum funktionieren keine anderen Array-Methoden?

Denken Sie daran, dass wir im Hintergrund von reduce() nicht darauf warten, dass unser Callback abgeschlossen ist, bevor wir zum nächsten Element übergehen. Es ist völlig synchron. Das Gleiche gilt für all diese anderen Methoden

Aber reduce() ist besonders.

Wir haben festgestellt, dass reduce() für uns funktioniert, weil wir etwas direkt an unseren gleichen Callback zurückgeben können (nämlich ein Promise), auf dem wir dann aufbauen können, indem wir es in ein weiteres Promise auflösen lassen. Bei all diesen anderen Methoden können wir jedoch kein Argument an unseren Callback übergeben, das von unserem Callback zurückgegeben wurde. Stattdessen sind alle diese Callback-Argumente vordefiniert, was es uns unmöglich macht, sie für etwas wie sequenzielle Promise-Auflösung zu nutzen.

[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]);

Ich hoffe, das hilft!

Ich hoffe, das wirft zumindest Licht darauf, warum reduce() sich auf diese Weise für die Behandlung von Promises eignet, und gibt Ihnen vielleicht ein besseres Verständnis dafür, wie gängige Array-Methoden im Hintergrund funktionieren. Habe ich etwas verpasst? Habe ich etwas falsch gemacht? Lassen Sie es mich wissen!