Een snelle intro

Welkom in de wondere wereld van generatorfuncties en asynchrone generatoren in JavaScript.

Je staat op het punt om te leren over een van de meest exotische (volgens de meerderheid van de ontwikkelaars) JavaScript-functie.

Laten we beginnen!

Generator functies in JavaScript

Wat is een generator functie?

Een generator functie (ECMAScript 2015) in JavaScript is een speciaal type synchrone functie die in staat is om zijn uitvoering te stoppen en te hervatten wanneer hij wil.

In tegenstelling tot gewone JavaScript-functies, die fire and forget zijn, hebben generatorfuncties ook de mogelijkheid om:

  • met de aanroeper te communiceren via een tweerichtingskanaal.
  • hun uitvoeringscontext (scope) te behouden bij opeenvolgende aanroepen.

Je kunt generator functies zien als closures op steroïden, maar de overeenkomsten houden hier op!

Je eerste generator functie

Om een generator functie te maken zetten we een ster * na het function sleutelwoord:

function* generate() {//}

Note: generator functies kunnen ook de vorm aannemen van een klasse methode, of van een functie expressie. Daarentegen zijn pijl-generatorfuncties niet toegestaan.

Eenmaal in de functie kunnen we yield gebruiken om de uitvoering te pauzeren:

function* generate() { yield 33; yield 99;}

yield pauzeert de uitvoering en geeft een zogenaamd Generator-object terug aan de aanroeper. Dit object is tegelijkertijd een iterable en een iterator.

Laten we deze concepten eens ontcijferen.

Iterables en iterators

Een iterable object in JavaScript is een object dat Symbol.iterator implementeert. Hier is een minimaal voorbeeld van een iterable:

const iterable = { : function() { /* TODO: iterator */ }};

Als we dit iterable-object hebben, wijzen we een functie toe aan om een iterator-object terug te geven.

Dit klinkt als een hoop theorie, maar in de praktijk is een iterable een object waar we met for...of (ECMAScript 2015) een lus over kunnen maken. Hierover zo dadelijk meer.

Je zou al een paar iterables in JavaScript moeten kennen: arrays en strings zijn bijvoorbeeld iterables:

for (const char of "hello") { console.log(char);}

Andere bekende iterables zijn Map en Set. for...of komt ook van pas voor iteratie over de waarden van een object:

const person = { name: "Juliana", surname: "Crain", age: 32};for (const value of Object.values(person)) { console.log(value);}

Bedenk wel dat elke eigenschap die als enumerable: false is gemarkeerd, niet in de iteratie te zien zal zijn:

const person = { name: "Juliana", surname: "Crain", age: 32};Object.defineProperty(person, "city", { enumerable: false, value: "London"});for (const value of Object.values(person)) { console.log(value);}// Juliana// Crain// 32

Nu, het probleem met onze aangepaste iterable is dat deze niet ver alleen kan gaan zonder een iterator.

Iterators zijn ook objecten, maar ze moeten voldoen aan het iterator-protocol. In het kort, iterators moeten ten minste een next() methode hebben.

next() moet een ander object teruggeven, waarvan de eigenschappen value en done zijn.

De logica voor onze methode next() moet aan de volgende regels voldoen:

  • we geven een object met done: false terug om de iteratie voort te zetten.
  • we geven een object met done: true terug om de iteratie te stoppen.

value moet in plaats daarvan het resultaat bevatten dat we voor de consument willen produceren.

Laten we ons voorbeeld uitbreiden door de iterator toe te voegen:

const iterable = { : function() { let count = 0; return { next() { count++; if (count <= 3) { return { value: count, done: false }; } return { value: count, done: true }; } }; }};

Hier hebben we een iterable, die Symbol.iterator correct implementeert. We hebben ook een iterator, die retourneert:

  • een object waarvan de vorm { value: x, done: false} is totdat count 3 bereikt.
  • een object waarvan de vorm { value: x, done: true} is wanneer count 3 bereikt.

Deze minimale iterable is klaar om met for...of te worden geiterateerd:

const iterable = { : function() { let count = 0; return { next() { count++; if (count <= 3) { return { value: count, done: false }; } return { value: count, done: true }; } }; }};for (const iterableElement of iterable) { console.log(iterableElement);}

Het resultaat zal zijn:

123

Als je de generatorfuncties beter leert kennen, zul je zien dat iterators de basis zijn voor generatorobjecten.

Bedenk het volgende:

  • iterables zijn de objecten waarover we itereren.
  • iterators zijn de dingen die de iterable … “lusable” over.

Wat is uiteindelijk het nut van een iterable?

We hebben nu een standaard, betrouwbare for...of lus die vrijwel werkt voor elke datastructuur, custom of native, in JavaScript.

Om for...of op je eigen datastructuur te gebruiken, moet je

  • Symbol.iterator implementeren.
  • een iterator-object beschikbaar stellen.

Dat is het!

Vdere bronnen:

  • Iterators gonna iterate door Jake Archibald.

Iterables are spreadable and destructurable

In aanvulling op for...of kunnen we ook spread en destructuring gebruiken op eindige iterables.

Overweeg opnieuw het vorige voorbeeld:

const iterable = { : function() { let count = 0; return { next() { count++; if (count <= 3) { return { value: count, done: false }; } return { value: count, done: true }; } }; }};

Om alle waarden eruit te halen, kunnen we de iterable in een array spreiden:

const values = ;console.log(values); // 

Om in plaats daarvan slechts een paar waarden eruit te halen, kunnen we de iterable in een array destructureren. Hier krijgen we de eerste en tweede waarde uit de iterable:

const = iterable;console.log(first); // 1console.log(second); // 2

Hier krijgen we in plaats daarvan de eerste, en de derde:

const = iterable;console.log(first); // 1console.log(third); // 3

Laten we ons nu weer richten op generatorfuncties.

Gegevens uit een generatorfunctie halen

Als een generatorfunctie eenmaal is ingesteld, kunnen we ermee gaan interageren. Deze interactie bestaat uit:

  • waarden uit de generator halen, stap voor stap.
  • optioneel waarden terugsturen naar de generator.

Om waarden uit een generator te halen kunnen we drie benaderingen gebruiken:

  • het aanroepen van next() op het iterator object.
  • iteratie met for...of.
  • spreiding en array destructuring.

Een generator functie berekent niet al zijn resultaten in een enkele stap, zoals reguliere functies doen.

Als we ons voorbeeld nemen, om waarden uit de generator te halen, kunnen we eerst de generator opwarmen:

function* generate() { yield 33; yield 99;}// Initialize the generatorconst go = generate();

Hier wordt go ons iterable/iterator object, het resultaat van het aanroepen van generate.

(Vergeet niet dat een Generator-object zowel een iterable als een iterator is).

Van nu af aan kunnen we go.next() oproepen om de uitvoering te vervroegen:

function* generate() { yield 33; yield 99;}const go = generate();// Consume the generatorconst { value: firstStep } = go.next(); // firstStep is 33const { value: secondStep } = go.next(); // secondStep is 99

Hier levert elke oproep aan go.next() een nieuw object op. In het voorbeeld destructureren we de eigenschap value van dit object.

Objecten die worden teruggegeven door next() aan te roepen op het iterator-object hebben twee eigenschappen:

  • value: de waarde voor de huidige stap.
  • done: een boolean die aangeeft of er meer waarden in de generator zijn, of niet.

We hebben zo’n iterator-object in de vorige sectie geïmplementeerd. Als je een generator gebruikt, is het iterator-object er al voor je.

next() werkt goed om eindige gegevens uit een iterator-object te halen.

Om over niet-eindige gegevens te itereren, kunnen we for...of gebruiken. Hier is een oneindige generator:

function* endlessGenerator() { let counter = 0; while (true) { counter++; yield counter; }}// Consume the generatorfor (const value of endlessGenerator()) { console.log(value);}

Zoals je kunt zien is het niet nodig om de generator te initialiseren wanneer je for...of gebruikt.

Finitief kunnen we het generator object ook verspreiden en destructureren. Hier is de spread:

function* generate() { yield 33; yield 99;}// Initialize the generatorconst go = generate();// Spreadconst values = ;console.log(values); // 

Hier is destructuring:

function* generate() { yield 33; yield 99;}// Initialize the generatorconst go = generate();// Destructuringconst = go;console.log(first); // 1console.log(second); // 2

Het is belangrijk op te merken dat generatoren uitgeput raken zodra je al hun waarden hebt verbruikt.

Als je waarden van een generator uitsmeert, valt er daarna niets meer uit te halen:

function* generate() { yield 33; yield 99;}// Initialize the generatorconst go = generate();// Spreadconst values = ;console.log(values); // // Exhaustconst = go;console.log(first); // undefinedconsole.log(second); // undefined

Generatoren werken ook andersom: ze kunnen waarden of opdrachten van de aanroeper accepteren, zoals we over een minuut zullen zien.

Terugkomend op generator functies

Iterator objecten, het resulterende object van een aanroep van een generator functie, stellen de volgende methoden beschikbaar:

  • next()
  • return()
  • throw()

We zagen reeds next(), die helpt bij het uittrekken van objecten uit een generator.

Het gebruik ervan is niet alleen beperkt tot het uittrekken van gegevens, maar ook om waarden naar de generator te sturen.

Overweeg de volgende generator:

function* endlessUppercase(string) { while (true) { string = yield string.toUpperCase(); }}

Hij noemt een parameter string. We geven dit argument bij de initialisatie:

function* endlessUppercase(string) { while (true) { string = yield string.toUpperCase(); }}const go = endlessUppercase("a");

Zodra we next() voor het eerst aanroepen op het iterator-object, begint de uitvoering en wordt “A” geproduceerd:

function* endlessUppercase(string) { while (true) { string = yield string.toUpperCase(); }}const go = endlessUppercase("a");console.log(go.next().value); // A

Op dit punt kunnen we terugpraten met de generator door een argument te geven voor next:

go.next("b");

Hier volgt de volledige lijst:

function* endlessUppercase(string) { while (true) { string = yield string.toUpperCase(); }}const go = endlessUppercase("a");console.log(go.next().value); // Aconsole.log(go.next("b").value); // B

Van nu af aan kunnen we waarden invoeren in yield telkens wanneer we een nieuwe reeks met hoofdletters nodig hebben:

function* endlessUppercase(string) { while (true) { string = yield string.toUpperCase(); }}const go = endlessUppercase("a");console.log(go.next().value); // Aconsole.log(go.next("b").value); // Bconsole.log(go.next("c").value); // Cconsole.log(go.next("d").value); // D

Als we op een gegeven moment helemaal willen terugkeren van de uitvoering, kunnen we return gebruiken op het iterator object:

const { value } = go.return("stop it");console.log(value); // stop it

Daarmee stoppen we de uitvoering.

Naast waarden kunt u ook een uitzondering in de generator gooien. Zie “Foutafhandeling voor generatorfuncties”.

Use-cases voor generatorfuncties

De meeste ontwikkelaars (ikzelf incluis) zien generatorfuncties als een exotische JavaScript-functie die weinig of geen toepassing heeft in de echte wereld.

Dit zou waar kunnen zijn voor de gemiddelde front-end werk, waar een sprinkle van jQuery, en een beetje CSS kan truc doen de meeste van de tijden.

In werkelijkheid, generator functies echt schitteren in al die scenario’s waar de prestaties van het grootste belang zijn.

In het bijzonder, ze zijn goed voor:

  • werken met grote bestanden en datasets.
  • data wrangling op de back-end, of in de front-end.
  • genereren van oneindige sequenties van gegevens.
  • compute dure logica on-demand.

Generator functies zijn ook de bouwsteen voor geavanceerde asynchrone patronen met asynchrone generator functies, ons onderwerp voor de volgende sectie.

Asynchrone generatorfuncties in JavaScript

Wat is een asynchrone generatorfunctie?

Een asynchrone generatorfunctie (ECMAScript 2018) is een speciaal type asynchrone functie die in staat is om zijn uitvoering naar believen te stoppen en te hervatten.

Het verschil tussen synchrone generatorfuncties en asynchrone generatorfuncties is dat de laatste een asynchroon, op Promise gebaseerd resultaat van het iteratorobject retourneren.

Zoals generatorfuncties zijn asynchrone generatorfuncties in staat om:

  • met de aanroeper te communiceren.
  • hun uitvoeringscontext (bereik) over opeenvolgende aanroepen te behouden.

Uw eerste asynchrone generatorfunctie

Om een asynchrone generatorfunctie te maken declareren we een generatorfunctie met de ster *, voorafgegaan door async:

async function* asyncGenerator() { //}

Eenmaal in de functie kunnen we yield gebruiken om de uitvoering te pauzeren:

async function* asyncGenerator() { yield 33; yield 99;}

Hiermee pauzeert yield de uitvoering en retourneert een zogenaamd Generator-object aan de aanroeper.

Dit object is tegelijkertijd een iterable, en een iterator.

Laten we deze concepten nog eens samenvatten om te zien hoe ze passen in het asynchrone land.

Asynchrone iterables en iterators

Een asynchrone iterable in JavaScript is een object dat Symbol.asyncIterator implementeert.

Hier volgt een minimaal voorbeeld:

const asyncIterable = { : function() { /* TODO: iterator */ }};

Als we dit iterable-object hebben, wijzen we een functie toe aan om een iterator-object te retourneren.

Het iterator-object moet voldoen aan het iterator-protocol met een next()-methode (zoals de synchrone iterator).

Laten we ons voorbeeld uitbreiden door de iterator toe te voegen:

const asyncIterable = { : function() { let count = 0; return { next() { count++; if (count <= 3) { return Promise.resolve({ value: count, done: false }); } return Promise.resolve({ value: count, done: true }); } }; }};

Deze iterator is vergelijkbaar met wat we in de vorige secties hebben gebouwd, deze keer is het enige verschil dat we het terugkerende object omwikkelen met Promise.resolve.

Op dit punt kunnen we iets in deze trant doen:

const go = asyncIterable();go.next().then(iterator => console.log(iterator.value));go.next().then(iterator => console.log(iterator.value));// 1// 2

Of met for await...of:

async function consumer() { for await (const asyncIterableElement of asyncIterable) { console.log(asyncIterableElement); }}consumer();// 1// 2// 3

Asynchrone iterables en iterators zijn de basis voor asynchrone generatorfuncties.

Laten we ons nu weer op hen richten.

Gegevens uit een asynchrone generator halen

Asynchrone generatorfuncties berekenen niet al hun resultaten in één enkele stap, zoals reguliere functies dat doen.

In plaats daarvan halen we de waarden er stap voor stap uit.

Na het bestuderen van asynchrone iterators en iterables, zou het geen verrassing moeten zijn om te zien dat om Promises uit een asynchrone generator te halen, we twee benaderingen kunnen gebruiken:

  • next() aanroepen op het iterator object.
  • async iteratie met for await...of.

In ons eerste voorbeeld kunnen we:

async function* asyncGenerator() { yield 33; yield 99;}const go = asyncGenerator();go.next().then(iterator => console.log(iterator.value));go.next().then(iterator => console.log(iterator.value));

De output van deze code is:

3399

De andere aanpak gebruikt async iteratie met for await...of. Om async iteratie te gebruiken, wikkelen we de consument in met een async-functie.

Hier volgt het complete voorbeeld:

async function* asyncGenerator() { yield 33; yield 99;}async function consumer() { for await (const value of asyncGenerator()) { console.log(value); }}consumer();

for await...of werkt goed voor het extraheren van niet-eindige gegevensstromen.

Laten we nu eens kijken hoe we gegevens terug kunnen sturen naar de generator.

Terugkomend op asynchrone generatorfuncties

Overweeg de volgende asynchrone generator:

async function* asyncGenerator(string) { while (true) { string = yield string.toUpperCase(); }}

Zoals de eindeloze hoofdlettermachine uit het generatorvoorbeeld, kunnen we een argument meegeven aan next():

async function* asyncEndlessUppercase(string) { while (true) { string = yield string.toUpperCase(); }}async function consumer() { const go = await asyncEndlessUppercase("a"); const { value: firstStep } = await go.next(); console.log(firstStep); const { value: secondStep } = await go.next("b"); console.log(secondStep); const { value: thirdStep } = await go.next("c"); console.log(thirdStep);}consumer();

Hierbij stuurt elke stap een nieuwe waarde naar de generator.

De uitvoer van deze code is:

ABC

Zelfs als er niets inherent asynchroon is in toUpperCase(), kun je het doel van dit patroon aanvoelen.

Als we op enig moment de uitvoering willen beëindigen, kunnen we return() op het iterator-object aanroepen:

 const { value } = await go.return("stop it"); console.log(value); // stop it

Naast waarden kun je ook een exception in de generator gooien. Zie “Foutafhandeling voor async-generatoren”.

Gebruiksgevallen voor asynchrone iterabelen en asynchrone generatorfuncties

Als generatorfuncties goed zijn voor het synchroon werken met grote bestanden en oneindige reeksen, maken asynchrone generatorfuncties een heel nieuw land van mogelijkheden voor JavaScript mogelijk.

In het bijzonder, asynchrone iteratie maakt het gemakkelijker om leesbare streams te consumeren. Het object Response in Fetch stelt body beschikbaar als een leesbare stream met getReader(). We kunnen zo’n stream omwikkelen met een asynchrone generator, en er later overheen itereren met for await...of.

Async iterators and generators door Jake Archibald heeft een stel mooie voorbeelden.

Andere voorbeelden van streams zijn request streams met Fetch.

Op het moment van schrijven is er nog geen browser API die Symbol.asyncIterator implementeert, maar de Stream spec gaat hier verandering in brengen.

In Node.js de recente Stream API speelt mooi met asynchrone generatoren en asynchrone iteratie.

In de toekomst zullen we in staat zijn om te consumeren en naadloos te werken met schrijfbare, leesbare, en transformator streams op de client-side.

Volgende bronnen:

  • De Stream API.

Afsluitend

Kernbegrippen en concepten die we in deze post hebben behandeld:

ECMAScript 2015:

  • iterable.
  • iterator.

Dit zijn de bouwstenen voor generatorfuncties.

ECMAScript 2018:

  • asynchrone iterable.
  • asynchrone iterator.

Dit zijn in plaats daarvan de bouwstenen voor asynchrone generatorfuncties.

Een goed begrip van iterables en iterators kan je een heel eind op weg helpen. Het is niet zo dat je elke dag met generatorfuncties en asynchrone generatorfuncties zult werken, maar ze zijn een mooie vaardigheid om in je gereedschapsgordel te hebben.

En jij? Heb jij ze ooit gebruikt?

Bedankt voor het lezen en blijf deze blog volgen!

Geef een antwoord

Het e-mailadres wordt niet gepubliceerd.