Ein asynchrones 'map' für JavaScript

the next big thing  –  4 Kommentare

JavaScript kennt seit der Version ECMAScript 2017 die beiden Schlüsselwörter async und await, mit denen sich asynchroner Code in deutlich lesbarerer Form schreiben lässt. Leider unterstützen nicht alle Konstrukte die neuen Schlüsselwörter, so fehlt beispielsweise eine asynchrone map-Funktion. Glücklicherweise lässt sich leicht Abhilfe schaffen.

Die Schlüsselwörter async und await tragen maßgeblich dazu bei, dass sich asynchroner Code in JavaScript seit einer Weile deutlich klarer strukturieren lässt, was dramatisch zu einer besseren Lesbarkeit des Codes beiträgt. Leider unterstützen nicht alle Konstrukte die neuen Schlüsselwörter, so fehlt beispielsweise eine asynchrone map-Funktion.

Zur Erinnerung: Die klassische map-Funktion bildet ein Array auf ein anderes ab, indem jeder einzelne Wert des Ursprungs-Arrays mit einer anzugebenden Funktion transformiert wird. Das lässt sich beispielsweise nutzen, um zu einer gegebenen Reihe von Zahlen deren Quadratzahlen zu berechnen:

const squares = [ 1, 2, 3, 4, 5 ].map(n => n ** 2);
// => [ 1, 4, 9, 16, 25 ]

Das funktioniert hervorragend, solange die transformierende Funktion synchron arbeitet, wie in dem genannten Beispiel. Doch was, wenn die Funktion asynchron arbeiten soll? Das wäre unter anderem dann der Fall, wenn man alle Einträge eines Verzeichnisses auslesen und entscheiden will, ob es sich dabei jeweils um eine Datei oder wiederum um ein Verzeichnis handelt.

Zunächst benötigt man dazu eine Funktion, die alle Einträge innerhalb eines Verzeichnisses ermittelt. Das funktioniert in Node.js einfach mit der fs.readdir-Funktion, die sich mit util.promisify promisifizieren lässt, so dass man sie mit async und await verwenden kann. Da die fs.stat-Funktion im weiteren Verlauf ebenfalls benötigt wird, kann man sie direkt auf die gleiche Art verarbeiten:

'use strict';

const fs = require('fs'),
path = require('path'),
{ promisify } = require('util');

const readdir = promisify(fs.readdir),
stat = promisify(fs.stat);

(async () => {
const directory = process.cwd();

const entries = await readdir(directory);
})();

Das Array entries enthält nun alle Einträge aus dem aktuellen Arbeitsverzeichnis. Um nun für jeden dieser Einträge zu entscheiden, ob es sich um eine Datei oder ein Verzeichnis handelt, müsste man die Einträge einzeln mit der stat-Funktion verarbeiten, und das Ergebnis speichern. Das klingt nach einer Aufgabe für die map-Funktion!

Problematisch ist dabei leider nur, dass die transformierende Funktion auf stats zugreifen müsste, die aber wiederum asynchron ist – was nicht unterstützt wird. Das heißt, eigentlich würde man ungefähr so etwas wie den folgenden Code schreiben wollen:

const content = await entries.map(async entry => {
const fullEntry = path.join(directory, entry);

const stats = await stat(fullEntry);

if (stats.isDirectory()) {
return { name: entry, type: 'directory' };
}
if (stats.isFile()) {
return { name: entry, type: 'file' };
}

return { name: entry, type: 'other' };
});

Doch das funktioniert leider nicht, da die map-Funktion nicht asynchron arbeitet. Doch es lässt sich leicht Abhilfe schaffen. Der ganze Trick basiert darauf, dass Funktionen, die als async gekennzeichnet sind, technisch synchronen Funktionen entsprechen, die ein Promise zurückgeben.

Das Schlüsselwort async weist also lediglich den Compiler an, das entsprechende Konstrukt automatisch zu ergänzen. Mit anderen Worten: Es spart Schreibarbeit. Technisch gibt es keinen Unterschied, weshalb der Code

const foo = async function () {
return 42;
};

und

const foo = function () {
return new Promise(resolve => {
resolve(42);
});
};

äquivalent sind. Das bedeutet auch, dass es durchaus legitim ist, der map-Funktion eine Transformations-Funktion zu übergeben, die mit dem Schlüsselwort async markiert wurde: Das bedeutet lediglich, dass die Funktion ein Promise zurückgibt.

Der zuvor genannte Wunschcode lässt sich also weitestgehend tatsächlich wie gewünscht schreiben. Die einzige Frage ist, wie man mit dem await umgehen soll, was vor dem Aufruf von map angegeben ist. Die Antwort ist einfach: Da die map-Funktion nun einzelne Einträge auf Promises abbildet, erhält man als Ergebnis der map-Funktion ein Array von Promises.

Es gilt also, darauf zu warten, dass alle in diesem Array enthaltenen Promises erfüllt werden – was wiederum mit der Promise.all-Funktion möglich ist, wie das folgende Beispiel zeigt:

const content = await Promise.all(entries.map(async entry => {
const fullEntry = path.join(directory, entry);

const stats = await stat(fullEntry);

if (stats.isDirectory()) {
return { name: entry, type: 'directory' };
}
if (stats.isFile()) {
return { name: entry, type: 'file' };
}

return { name: entry, type: 'other' };
}));

Der gezeigte Weg funktioniert hervorragend, sofern gewünscht ist, dass die map-Funktion die einzelnen Iterationen parallel ausführt. Ist das nicht gewünscht, muss man auf eine klassische for-Schleife zurückgreifen. Häufig ist die parallele Ausführung aber nicht nur unproblematisch, sondern geradezu gewünscht – weshalb der hier gezeigte Ansatz in der Regel ein guter Weg ist, um das Problem zu lösen. Langfristig wird sich in JavaScript hier vermutlich noch einiges tun, für den Moment ist das aber der gangbarste Weg.

tl;dr: Die map-Funktion in JavaScript arbeitet stets synchron und hat kein asynchrones Pendant. Da async-Funktionen vom Compiler aber in synchrone Funktionen, die Promises zurückgeben, umgewandelt werden, lässt sich map mit Promise.all kombinieren, um den gewünschten Effekt zu erzielen.