Javascript
Tutorials

JavaScript-Generatoren erklärt, aber auf Senior-Niveau

Generatoren sind leistungsstark und werden von JavaScript-Entwicklern. Viele Tutorials über Generatoren kratzen jedoch nur an der Oberfläche. In diesem Artikel werden wir tief in die Materie eintauchen und ein fortgeschrittenes Verständnis der Theorie hinter Generatoren entwickeln.

Aber zuerst: Möchten Sie dieses Tutorial in Aktion sehen? Hier ist die Videoversion.

Generatoren werden am häufigsten in Sagas verwendet, aber es gibt noch weitere Anwendungsfälle dafür. Einige davon werden wir in diesem Artikel behandeln.

Die kurze Antwort auf die Frage „Was ist ein Generator?“ lautet:

Generatoren sind Pull-Streams in JavaScript.

Lassen Sie uns diese Definition zerlegen und dann zu einigen Beispielen übergehen.

Zuerst müssen Sie zwei Begriffe verstehen: „Pull“ und „Stream“.

Was ist ein Stream?

Ein Stream ist Daten über die Zeit. Es gibt zwei Arten von Streams: Push-Streams und Pull-Streams.

Was ist ein Push-Stream?

Ein Push-Stream ist ein Mechanismus, bei dem Sie NICHT kontrollieren können, WANN die Daten ankommen.

Beispiele für Pull-Streams sind:

  • ein WebSocket,
  • das Lesen einer Datei von der Festplatte und
  • Server-Sent Events.

Ein JavaScript-Beispiel für einen Push-Stream, der Node.js zum Lesen einer großen Datei von der Festplatte verwendet, finden Sie unten.

const fs = require('fs');
const readStream = fs.createReadStream('./largeFile.txt');

readStream.on('data', chunk => {
  console.log('data received', chunk.length)
});

readStream.on('end', () => {
  console.log('finished reading file');
});

readStream.on('error', error => {
  console.log('an error occured while reading the file', error);
});

Was ist ein Pull-Stream?

Ein Pull-Stream ist, wenn Sie SELBST kontrollieren können, WANN Sie die Daten anfordern möchten.

Codebeispiele für Pull-Streams in JavaScript werden Sie bald sehen, wenn es um Generator-Code geht, aber zuerst müssen Sie ein anderes Konzept verstehen.

Träge vs. aktiv

In der Programmierung können Daten auf zwei grundlegende Arten verarbeitet werden: aktiv oder träge.

Aktiv

Aktiv bedeutet, dass Daten sofort ausgewertet werden, unabhängig davon, ob das Ergebnis in diesem Moment benötigt wird. Ein Push-Stream ist aktiv. (Weitere Beispiele: Array-Methoden, Promises)

// Eager evaluation with array methods:
const numbers = [1, 2, 3, 4, 5];

// Map immediately processes all elements in the array.
const squares = numbers.map(num => {
    console.log(`Squaring ${num}`);
    return num * num;
});

console.log('squares:', squares); // [1, 4, 9, 16, 25]

console.log('squares:', squares); // [1, 4, 9, 16, 25]

Vielleicht denken Sie jetzt: „Okay, aber warum sind Promises aktiv? Ihr Ergebnis kommt doch erst später.“

Promises in JavaScript zeigen aus mehreren Gründen eine aktive Auswertung.

  1. Sofortige Ausführung: Die Funktion, die an ein neues Promise übergeben wird (bekannt als Executor-Funktion), wird sofort ausgeführt, wenn das Promise erstellt wird.
  2. Irreversible Operationen: Sobald die Executor-Funktion mit der Ausführung beginnt, kann sie vom konsumierenden Code nicht mehr gestoppt oder pausiert werden. Die Ergebnisse der von ihr durchgeführten Operation (entweder Auflösung oder Ablehnung) werden in der JavaScript-Event-Loop in die Warteschlange gestellt, um so schnell wie möglich verarbeitet zu werden.
  3. Keine träge Option: Ein Promise verfügt über keinen integrierten Mechanismus, um die Ausführung seines Executors aufzuschieben oder abzubrechen, bis ein Wert benötigt wird.
  4. Seiteneffekte: Die aktive Natur von Promises bedeutet, dass alle im Executor enthaltenen Seiteneffekte (wie API-Aufrufe, Timeouts oder I/O-Operationen) sofort als Teil der Promise-Erstellung erfolgen.

Das folgende Beispiel zeigt, wie Promises sofort ausgeführt werden.

// Eager evaluation with promises and array methods

console.log("Before promise");

let promise = new Promise((resolve, reject) => {
    console.log("Inside promise executor");
    resolve("Resolved data");
});

console.log("After promise");

promise.then(result => {
    console.log(result);
});

Dies führt zu folgender Ausgabe.

$ node eager-promise-example.js
Before promise
Inside promise executor
After promise
Resolved data

Träge

Träge bedeutet, dass sie nur ausgewertet wird, wenn der Wert benötigt wird (nicht vorher). Ein Pull-Stream ist träge.

Ein synchrones Beispiel wären die Operanden-Selektor-Operatoren.

// Lazy evaluation with logical operators

function processData(data) {
    console.log(`Processing ${data}`); // This never logs out 🚫
    return data * data;
}

console.log('Lazy evaluation starts');
const data = 5;
const isDataProcessed = false;

// Lazy evaluation using the logical AND operator.
const result = isDataProcessed && processData(data);
console.log('Result:', result); // false

Wenn Sie diesen Code ausführen, werden Sie die folgende Ausgabe sehen.

$ node lazy-evaluation-example.js
Lazy evaluation starts
Result: false

Da isDataProcessed ist false, die processData Funktion nie ausgeführt wird und Sie niemals "Processing 5" in der Konsole sehen. Dies zeigt, dass der Ausdruck nur das auswertet, was zur Erzielung des Ergebnisses erforderlich ist.

Was ist ein Generator?

Ein Generator ist ein Pull-Stream in JavaScript. Das bedeutet, es ist eine spezielle Art von Funktion, bei der Sie die Ausführung anhalten und fortsetzen es später.

Das Generator Objekt wird von einer Generatorfunktion zurückgegeben und entspricht sowohl dem Iterable-Protokoll als auch dem Iterator-Protokoll.

function* myGenerator() {
  yield "Hire senior";
  yield "React engineers";
  yield "at ReactSquad.io";
}

const iterator = myGenerator();

// Using the generator as an iterator.
console.log(iterator.next()); // { done: false, value: "Hire senior" }
console.log(iterator.next()); // { done: false, value: "React engineers" }
console.log(iterator.next()); // { done: false, value: "at ReactSquad.io" }
console.log(iterator.next()); // { done: true, value: undefined }

// Using the generator as an iterable.
for (let string of myGenerator()) {
  console.log(number); // "Hire senior" "React engineers" "at ReactSquad.io"
}

Neben der .next() Methode verfügen Generatoren auch über .return() und .throw().

  • .return() – Die .return() Methode beendet die Ausführung des Generators und gibt den angegebenen Wert zurück, wobei auch alle finally Blöcke ausgelöst werden.
  • .throw() – Die .throw() Methode ermöglicht es Ihnen, einen Fehler innerhalb des Generators an der Stelle des letzten `yield` auszulösen, der abgefangen und behandelt werden kann oder dem Generator erlaubt, über einen finally Block aufzuräumen. Wird er nicht abgefangen, stoppt er den Generator und markiert ihn als abgeschlossen.
function* numberGenerator() {
  try {
    yield 1;
    yield 2;
    yield 3;
  } finally {
    console.log("Cleanup complete");
  }
}

const generator = numberGenerator();

// Using the generator normally.
console.log(generator.next()); // { done: false, value: 1 }
console.log(generator.next()); // { done: false, value: 2 }

// Using return() to finish the generator early.
console.log(generator.return(10)); // { done: true, value: 10 }
// After return(), no more values are yielded.
console.log(generator.next()); // { done: true, value: undefined }

// Resetting the generator for throw example.
const newGenerator = numberGenerator();
console.log(newGenerator.next()); // { done: false, value: 1 }

// Using throw() to signal an error.
try {
  newGenerator.throw(new Error("Something went wrong"));
} catch (e) {
  console.log(e.message); // "Something went wrong"
}
// After throw(), the generator is closed.
console.log(newGenerator.next()); // { done: true, value: undefined }

Sie können auch Zahlen oder andere Werte an Generatoren übergeben, wenn Sie next() mit einem Argument aufrufen.

Versuchen Sie vorherzusagen, was ausgegeben wird und wann im folgenden Beispiel.

function* moreNumbers(x) {
    console.log('x', x);
    const y = yield x + 2;
    console.log('y', y);
    const z = yield x + y;
    console.log('z', z);
}

const it2 = moreNumbers(40);

console.log(it2.next());
console.log(it2.next(2012));
console.log(it2.next());

Dieses Beispiel zeigt, wie die Generatorfunktion moreNumbers Werte manipuliert und erzeugt, basierend auf den Eingaben, die sie während der Abfolge von .next() Aufrufe.

Schauen Sie sich die Ausgabe an und überprüfen Sie Ihre Vorhersage.

const it2 = moreNumbers(40);

// x: 40
console.log(it2.next()); // { value: 42, done: false }

// y: 2012
console.log(it2.next(2012)); // { value: 2052, done: false }

// z: undefined
console.log(it2.next()); // { value: undefined, done: true }

Lassen Sie uns jeden Schritt der Generatorfunktion moreNumbers aufschlüsseln, damit Sie sie vollständig verstehen.

Step Code Line Console Output Explanation
1 const it2 = moreNumbers(40) Initializes the generator with x set to 40.
2 console.log(it2.next()); { value: 42, done: false } Generator starts and logs x as 40, then yields 42 (x + 2).
3 console.log(it2.next(2012)); { value: 2052, done: false } Resumes with y as 2012, logs y, and yields 2052 (x + y).
4 console.log(it2.next()); { value: undefined, done: true } Resumes, logs z as undefined (no new input), and finishes.

Anwendungsfälle für Generatoren

Es gibt drei Hauptanwendungsfälle für Generatoren.

  1. Verzögerte Auswertung – Daten bei Bedarf generieren oder große oder unendliche Datensätze verarbeiten.
  2. Asynchrone Programmierung – asynchrone Operationen verwalten.
  3. Iteratoren – ermöglicht das Anhalten zwischen den Schritten für komplexe Abläufe.

Zuvor haben Sie ein Beispiel für das Lesen einer Datei von der Festplatte als Push-Stream gesehen. Im Folgenden wird gezeigt, wie Sie das Datenlesen mithilfe eines Generators schreiben würden, um es in einen Pull-Stream umzuwandeln.

const fs = require('fs');

function getChunkFromStream(stream) {
    return new Promise((resolve, reject) => {
        stream.once('data', (chunk) => {
            stream.pause();
            resolve(chunk);
        });

        stream.once('end', () => {
            resolve(null);
        });

        stream.once('error', (err) => {
            reject(err);
        });

        stream.resume();
    });
}

async function* readFileChunkByChunk(filePath) {
    const stream = fs.createReadStream(filePath);
    let chunk;

    while (chunk = await getChunkFromStream(stream)) {
        yield chunk;
    }
}

const generator = readFileChunkByChunk('./largeFile.txt');

(async () => {
    for await (const chunk of generator) {
        console.log("data received", chunk.length);
    }
})();

Praxisbeispiele

Sagas sind ein Paradebeispiel für die Handhabung asynchroner I/O-Operationen. Sie werden jedoch in einem zukünftigen Artikel, in einer Reihe von Artikeln über Redux.

Und generell verwendet man Generatoren, wenn man kontrollieren möchte, WANN man einen Wert erhält.

Werfen Sie einen Blick auf dieses Testbeispiel.

test('given an onboarded owner user: shows the invite link creation UI as well as the members of the organization, and lets the user change their role', async ({ page }) => {
  // Generator for roles in the organization.
  function* roleGenerator() {
    const allRoles = Object.values(ORGANIZATION_MEMBERSHIP_ROLES);
    for (const role of allRoles) {
      yield role;
    }
  }
  const roleIterator = roleGenerator();
  const data = await setup({
    page,
    role: ORGANIZATION_MEMBERSHIP_ROLES.OWNER,
    numberOfOtherTeamMembers: allRoles.length,
  });
  const { organization, sortedUsers, user } = data;

  // Navigate to team members settings page.
  await page.goto(`/organizations/${organization.slug}/settings/team-members`);

  // Loop through each team member to assign roles using the generator.
  for (let index = 0; index < sortedUsers.length; index++) {
    const memberListItem = page.getByRole('list', { name: /team members/i }).getByRole('listitem').nth(index);
    const otherUser = sortedUsers[index];

    // Change role for each team member, except the current user.
    if (otherUser.id !== user.id) {
      await memberListItem.getByRole('button', { name: /member/i }).click();
      const role = roleIterator.next().value!;
      await page.getByRole('option', { name: role }).getByRole('button').click();
      await page.keyboard.press('Escape');
    }
  }

  await teardown(data);
});

In diesem Test definieren Sie eine roleGenerator um sequenziell eine Liste von Rollen für Benutzer innerhalb einer Organisation bereitzustellen. Dieser Ansatz ermöglicht es dem Test, jedem Benutzer dynamisch eine einzigartige Rolle aus einer vordefinierten Liste als Teil einer Rollenverwaltungsfunktion in einer Benutzeroberfläche zuzuweisen.

Der Grund, warum für dieses Beispiel ein Generator – im Gegensatz zu einem Array – verwendet wurde, ist, dass die Position des Hauptbenutzers in der sortedUsers Array unbekannt ist und da ein Generator ein Pull-Stream ist, können Sie die Rollenwerte bei Bedarf abrufen.

Wenn Ihnen das gefallen hat, dann werden Sie meinen YouTube-Kanallieben. Schauen Sie mal rein!

Hire reliable React Developers without breaking the bank
Match me with a dev
About the Author
Jan Hesters
CTO, ReactSquad
What's up, this is Jan, CTO of ReactSquad. After studying physics, I ventured into the startup world and became a programmer. As the 7th employee at Hopin, I helped grow the company from a $6 million to a $7.7 billion valuation until it was partly sold in 2023.

Get actionable tips from the ReactSquad team

5-Minute Read. Every Tuesday. For Free