Szybkie wprowadzenie

Witamy w cudownym świecie funkcji generatora i generatorów asynchronicznych w JavaScript.

Zaraz poznasz jedną z najbardziej egzotycznych (według większości programistów) funkcji JavaScript.

Zacznijmy!

Funkcje generatora w JavaScript

Co to jest funkcja generatora?

Funkcja generatora (ECMAScript 2015) w JavaScript to specjalny typ funkcji synchronicznej, która jest w stanie dowolnie zatrzymywać i wznawiać swoje wykonanie.

W przeciwieństwie do zwykłych funkcji JavaScript, które są fire and forget, funkcje generatora mają również zdolność do:

  • komunikacji z wywołującym przez dwukierunkowy kanał.
  • zachowania swojego kontekstu wykonania (scope) nad kolejnymi wywołaniami.

Możesz myśleć o funkcjach generatora jak o domknięciach na sterydach, ale na tym podobieństwa się kończą!

Twoja pierwsza funkcja generatora

Aby utworzyć funkcję generatora, stawiamy gwiazdkę * po słowie kluczowym function:

function* generate() {//}

Uwaga: funkcje generatora mogą również przyjmować kształt metody klasy lub wyrażenia funkcji. Natomiast funkcje generatora strzałek są niedozwolone.

Już wewnątrz funkcji możemy użyć yield do wstrzymania wykonania:

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

yield wstrzymuje wykonanie i zwraca do wywołującego tzw. obiekt Generator. Ten obiekt jest jednocześnie iterowalną i iteratorem.

Zdemistyfikujmy te pojęcia.

Iterable i iteratory

Obiekt iterowalny w JavaScript jest obiektem implementującym Symbol.iterator. Oto minimalny przykład iterowalnego:

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

Gdy mamy już ten obiekt iterowalny, przypisujemy funkcję do , aby zwrócić obiekt iteratora.

Brzmi to jak dużo teorii, ale w praktyce iterowalny jest obiektem, na którym możemy wykonać pętlę za pomocą for...of (ECMAScript 2015). Więcej na ten temat za chwilę.

Powinieneś już znać kilka iterable w JavaScript: tablice i łańcuchy na przykład są iterable:

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

Inne znane iterable to Map i Set. for...of przydaje się również do iteracji nad wartościami obiektu:

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

Pamiętajmy tylko, że każda właściwość oznaczona jako enumerable: false nie pojawi się w iteracji:

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

Teraz, problem z naszą niestandardową iterable polega na tym, że nie może ona daleko zajść sama bez iteratora.

Iteratory są również obiektami, ale powinny być zgodne z protokołem iteratora. W skrócie, iteratory muszą mieć co najmniej metodę next().

next() musi zwracać inny obiekt, którego właściwościami są value i done.

Logika dla naszej metody next() musi przestrzegać następujących reguł:

  • zwracamy obiekt o wartości done: false, aby kontynuować iterację.
  • zwracamy obiekt o wartości done: true, aby zatrzymać iterację.

value zamiast tego, powinno trzymać wynik, który chcemy wyprodukować dla konsumenta.

Rozwińmy nasz przykład dodając iterator:

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

Mamy tutaj iterable, która poprawnie implementuje Symbol.iterator. Mamy też iterator, który zwraca:

  • obiekt, którego kształtem jest { value: x, done: false}, dopóki count nie osiągnie 3.
  • obiekt, którego kształtem jest { value: x, done: true}, kiedy count osiągnie 3.

Ta minimalna iterowalna jest gotowa do iterowania po niej za pomocą for...of:

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

Wynikiem będzie:

123

Kiedy lepiej poznasz funkcje generatora, zobaczysz, że iteratory są podstawą dla obiektów generatora.

Pamiętaj:

  • iterable to obiekty, nad którymi wykonujemy iterację.
  • iteratory to rzeczy, które sprawiają, że iterable …. „loopable” over.

W końcu, jaki jest sens iterable?

Mamy teraz standardową, niezawodną for...of pętlę, która działa praktycznie dla prawie każdej struktury danych, niestandardowej lub natywnej, w JavaScript.

Aby użyć for...of na swojej niestandardowej strukturze danych, musisz:

  • zaimplementować Symbol.iterator.
  • zapewnić obiekt iteratora.

To wszystko!

Dalsze zasoby:

  • Iterators gonna iterate by Jake Archibald.

Iterable are spreadable and destructurable

W uzupełnieniu do for...of, możemy również użyć spread i destructuring na skończonych iterablach.

Rozważmy ponownie poprzedni przykład:

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

Aby wyciągnąć wszystkie wartości możemy rozłożyć iterowalną na tablicę:

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

Aby wyciągnąć tylko kilka wartości zamiast tego możemy tablicowo zniszczyć iterowalną. Tutaj otrzymamy pierwszą i drugą wartość z iterowalnej:

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

Tutaj zamiast tego otrzymamy pierwszą i trzecią:

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

Skupmy się teraz ponownie na funkcjach generatora.

Wyciąganie danych z funkcji generatora

Gdy funkcja generatora jest już na swoim miejscu, możemy rozpocząć interakcję z nią. Interakcja ta polega na:

  • wyciąganiu wartości z generatora, krok po kroku.
  • opcjonalnie wysyłaniu wartości z powrotem do generatora.

Aby wyciągnąć wartości z generatora możemy użyć trzech podejść:

  • wywołanie next() na obiekcie iteratora.
  • iteracja z for...of.
  • rozprzestrzenianie i destrukcja tablicy.

Funkcja generatora nie oblicza wszystkich swoich wyników w jednym kroku, tak jak robią to zwykłe funkcje.

Jeśli weźmiemy nasz przykład, aby uzyskać wartości z generatora, możemy najpierw rozgrzać generator:

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

Tutaj go staje się naszym obiektem iterable/iterator, wynikiem wywołania generate.

(Pamiętaj, że obiekt Generator jest zarówno iterowalnym jak i iteratorem).

Od teraz możemy wywoływać go.next(), aby posuwać wykonanie:

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

Tutaj każde wywołanie go.next() produkuje nowy obiekt. W przykładzie destrukturyzujemy z tego obiektu właściwość value.

Obiekty zwrócone z wywołania next() na obiekcie iteratora mają dwie właściwości:

  • value: wartość dla bieżącego kroku.
  • done: boolean wskazujący, czy w generatorze jest więcej wartości, czy nie.

Taki obiekt iteratora zaimplementowaliśmy w poprzednim rozdziale. Kiedy używasz generatora, obiekt iteratora jest już tam dla Ciebie.

next() działa dobrze do wydobywania skończonych danych z obiektu iteratora.

Aby iterować po nieskończonych danych, możemy użyć for...of. Oto nieskończony generator:

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

Jak można zauważyć, nie ma potrzeby inicjalizacji generatora, gdy używamy for...of.

Wreszcie, możemy również rozłożyć i zniszczyć obiekt generatora. Oto rozłożenie:

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

Oto destrukcja:

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

Ważne jest, aby zauważyć, że generatory wyczerpują się, gdy zużyjemy wszystkie ich wartości.

Jeśli rozłożysz wartości z generatora, nie pozostanie nic do wyciągnięcia po tym:

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

Generatory działają również w drugą stronę: mogą przyjmować wartości lub polecenia od wywołującego, jak zobaczymy za chwilę.

Wracając do funkcji generatora

Obiekty iteratora, wynikowy obiekt wywołania funkcji generatora, eksponują następujące metody:

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

Widzieliśmy już next(), który pomaga w wyciąganiu obiektów z generatora.

Jego zastosowanie nie ogranicza się tylko do wyciągania danych, ale także do wysyłania wartości do generatora.

Rozważmy następujący generator:

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

Wymienia on parametr string. Podajemy ten argument przy inicjalizacji:

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

Jak tylko wywołamy next() po raz pierwszy na obiekcie iteratora, rozpoczyna się wykonywanie i powstaje „A”:

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

W tym momencie możemy odezwać się do generatora, przekazując argument dla next:

go.next("b");

Oto kompletny listing:

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

Od teraz możemy podawać wartości do yield za każdym razem, gdy potrzebujemy nowego łańcucha wielkich liter:

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

Jeśli w dowolnym momencie chcemy całkowicie powrócić z wykonania, możemy użyć return na obiekcie iteratora:

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

To zatrzymuje wykonanie.

Oprócz wartości możesz również rzucić wyjątek do generatora. Zobacz „Obsługa błędów dla funkcji generatora”.

Przypadki użycia funkcji generatora

Większość programistów (w tym ja) postrzega funkcje generatora jako egzotyczną cechę JavaScript, która ma małe lub żadne zastosowanie w prawdziwym świecie.

Może to być prawdą w przypadku przeciętnej pracy front-end, gdzie posypka jQuery i trochę CSS może zrobić sztuczkę przez większość czasu.

W rzeczywistości, funkcje generatora naprawdę błyszczą we wszystkich tych scenariuszach, gdzie wydajność jest najważniejsza.

W szczególności, są one dobre dla:

  • pracy z dużymi plikami i zestawami danych.
  • zajmowania się danymi na zapleczu lub we frontendzie.
  • generowania nieskończonych sekwencji danych.
  • obliczania kosztownej logiki na żądanie.

Funkcje generatora są również budulcem dla wyrafinowanych wzorców asynchronicznych z asynchronicznymi funkcjami generatora, naszym tematem w następnej sekcji.

Asynchroniczne funkcje generatora w JavaScript

Co to jest asynchroniczna funkcja generatora?

An asynchroniczna funkcja generatora (ECMAScript 2018) to specjalny typ funkcji asynchronicznej, która jest w stanie dowolnie zatrzymywać i wznawiać swoje wykonanie.

Różnica między synchronicznymi funkcjami generatora a asynchronicznymi funkcjami generatora polega na tym, że te ostatnie zwracają asynchroniczny, oparty na obietnicy wynik z obiektu iteratora.

Dużo jak funkcje generatora, asynchroniczne funkcje generatora są w stanie:

  • komunikować się z wywołującym.
  • zachować swój kontekst wykonania (scope) nad kolejnymi wywołaniami.

Twoja pierwsza asynchroniczna funkcja generatora

Aby utworzyć asynchroniczną funkcję generatora, deklarujemy funkcję generatora z gwiazdką *, poprzedzoną async:

async function* asyncGenerator() { //}

Już wewnątrz funkcji możemy użyć yield do wstrzymania wykonania:

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

Tutaj yield wstrzymuje wykonanie i zwraca do wywołującego tzw. obiekt Generator.

Ten obiekt jest zarówno iterowalną, jak i iteratorem jednocześnie.

Podsumujmy te koncepcje, aby zobaczyć, jak pasują do asynchronicznej krainy.

Asynchroniczne iterable i iteratory

An asynchroniczna iterowalna w JavaScript jest obiektem implementującym Symbol.asyncIterator.

Oto minimalny przykład:

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

Gdy mamy już ten obiekt iterowalny, przypisujemy funkcję do , aby zwrócić obiekt iteratora.

Obiekt iteratora powinien być zgodny z protokołem iteratora z metodą next() (tak jak iterator synchroniczny).

Rozwińmy nasz przykład dodając iterator:

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

Ten iterator jest podobny do tego, który zbudowaliśmy w poprzednich rozdziałach, tym razem jedyną różnicą jest to, że zawijamy zwracający obiekt za pomocą Promise.resolve.

W tym momencie możemy zrobić coś wzdłuż tych linii:

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

Albo z for await...of:

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

Asynchroniczne iterable i iteratory są podstawą dla asynchronicznych funkcji generatora.

Zwróćmy teraz ponownie naszą uwagę na nie.

Wyciąganie danych z generatora asynchronicznego

Asynchroniczne funkcje generatora nie obliczają wszystkich swoich wyników w jednym kroku, tak jak robią to zwykłe funkcje.

Zamiast tego wyciągamy wartości krok po kroku.

Po zbadaniu asynchronicznych iteratorów i iterabli nie powinno być niespodzianką, że aby wyciągnąć obietnice z generatorów asynchronicznych, możemy użyć dwóch podejść:

  • wywołując next() na obiekcie iteratora.
  • async iteracja z for await...of.

W naszym początkowym przykładzie możemy zrobić:

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

Wyjściem tego kodu jest:

3399

Drugie podejście wykorzystuje async iterację z for await...of. Aby użyć iteracji async, zawijamy konsumenta za pomocą funkcji async.

Oto kompletny przykład:

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

for await...of działa ładnie przy wyodrębnianiu niekończących się strumieni danych.

Zobaczmy teraz, jak wysłać dane z powrotem do generatora.

Rozmowa z powrotem do asynchronicznych funkcji generatora

Rozważmy następujący generator asynchroniczny:

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

Bardzo podobnie jak nieskończona maszyna wielkich liter z przykładu generatora, możemy dostarczyć argument do 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();

Tutaj każdy krok wysyła nową wartość do generatora.

Wyjściem tego kodu jest:

ABC

Nawet jeśli w toUpperCase() nie ma nic z natury asynchronicznego, można wyczuć cel tego wzorca.

Jeżeli w dowolnym momencie chcemy wyjść z wykonania, możemy wywołać return() na obiekcie iteratora:

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

Oprócz wartości możesz również rzucić wyjątek do generatora. Zobacz „Obsługa błędów dla generatorów async”.

Przypadki użycia dla asynchronicznych iterables i asynchronicznych funkcji generatora

Jeśli funkcje generatora są dobre do synchronicznej pracy z dużymi plikami i nieskończonymi sekwencjami, asynchroniczne funkcje generatora umożliwiają zupełnie nową krainę możliwości dla JavaScript.

W szczególności, asynchroniczna iteracja ułatwia konsumpcję czytelnych strumieni. Obiekt Response w Fetch eksponuje body jako czytelny strumień z getReader(). Możemy owinąć taki strumień asynchronicznym generatorem, a później iterować nad nim za pomocą for await...of.

Async iterators and generators autorstwa Jake’a Archibalda ma mnóstwo ładnych przykładów.

Innymi przykładami strumieni są strumienie żądań z Fetch.

W czasie pisania nie ma żadnego API przeglądarki implementującego Symbol.asyncIterator, ale specyfikacja Stream ma to zmienić.

W Node.js ostatnie API Stream ładnie gra z asynchronicznymi generatorami i asynchroniczną iteracją.

W przyszłości będziemy mogli konsumować i pracować bezproblemowo z zapisywalnymi, odczytywalnymi i transformowalnymi strumieniami po stronie klienta.

Dalsze zasoby:

  • The Stream API.

Wrapping up

Kluczowe terminy i pojęcia, które poruszyliśmy w tym poście:

ECMAScript 2015:

  • iterable.
  • iterator.

To są elementy konstrukcyjne funkcji generatora.

ECMAScript 2018:

  • asynchroniczna iterowalna.
  • asynchroniczny iterator.

Te zamiast tego są elementami konstrukcyjnymi dla asynchronicznych funkcji generatora.

Dobre zrozumienie iterables i iteratorów może zabrać cię na długą drogę. Nie chodzi o to, że będziesz pracował z funkcjami generatora i asynchronicznymi funkcjami generatora każdego dnia, ale są one miłą umiejętnością, którą warto mieć w swoim pasie narzędzi.

A ty? Czy kiedykolwiek ich używałeś?

Dzięki za przeczytanie i bądź na bieżąco z tym blogiem!

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany.