W sumie to kiedyś już na blogu wyjaśniłem czym są i do czego służą generatory ES6. Było to przy okazji wpisu na temat redux-saga, która korzysta właśnie z tego, dość nowego w JavaScript, mechanizmu. Jednak od czasu tamtego posta, dostałem już kilka wiadomości z prośbą o opisanie generatorów w osobnym wpisie, postanowiłem więc dziś tę potrzebę spełnić. Jednak te maile od czytelników to fajna sprawa, bo nie miałem za bardzo pomysłu na dzisiejszy wpis…

Poniżej dowiesz się co nieco na temat generatorów ES6. Wszystko oczywiście poparte przykładami. Postaram się dużo bardziej wyczerpać temat niż to zrobiłem w poście o redux-saga. Myślę, że nie ma już co przedłużać - przejdźmy do rzeczy!

Czym są generatory ES6?

Chyba najbardziej kompletnym źródłem wiedzy na temat generatorów ES6 (i w ogóle ES6+) jest Axel Rauschmayer - tutaj jego blog oraz dostępna on-line książka o ES6. Moim zdaniem, bardzo fajnie opisuje on generatory jako procesy, których wykonanie można pauzować oraz wznawiać (za chwilę pokażę o co chodzi).

Z punktu widzenia składni, generatory ES6 są natomiast pewnym rodzajem funkcji. Do ich definiowania służy specjalny “keyword”: function* (funkcja z gwiazdką):

function* generator() {
  console.log('gen');
}

Powyżej mamy bardzo prostą funkcję generatora, której jedynym zadaniem jest wyświetlenie tekstu na ekranie. Funkcję generatora można normalnie wywoływać, a jej wynik najlepiej przypisać do zmiennej/stałej (nawet jeśli nie użyliśmy return):

const iterator = generator();

Robimy tak dlatego, że wywołanie funkcji generatora nie powoduje wykonania ciała tej funkcji. Zamiast tego, wywołanie generatora zwraca obiekt tak zwanego iteratora. Wspomniałem wcześniej, że generatory możemy traktować jak procesy… obiekt iteratora służy do kontrolowania tego procesu, a konkretniej umożliwia nam wznawianie go. Do tego celu wykorzystujemy metodę next() (poniżej cały kod przykładu):

function* generator() {
  console.log('gen');
}

const iterator = generator();

iterator.next(); // dopiero teraz wyświetli "gen"

Przeanalizujmy teraz całość. Najpierw utworzony został generator, następnie wywołano go, w celu pobrania obiektu iteratora. Na końcu wywołano metodę next() iteratora, co spowodowało wykonanie ciała funkcji generatora.

Korzystając z analogii, którą zaproponował Rauschmayer: utworzono proces, który na początku jest “spauzowany”, a następnie go wznowiono (za pomocą wywołania next()) i wykonano w całości. Ok, ale co z pauzowaniem?

Polecenie yield

Jeśli metoda next() wznawia wykonanie ciała funkcji generatora, to polecenie yield je wstrzymuje. Zresztą spójrz na modyfikację powyższego przykładu:

function* generator() {
  console.log('gen');
  yield;
  console.log('erator');
}

const iterator = generator();

iterator.next(); // dopiero teraz wyświetli "gen"
iterator.next(); // pójdzie dalej i wyświetli "erator"

Najpierw zwróć uwagę na zmiany w funkcji generatora: mamy teraz dwa wywołania console.log() przedzielone wywołaniem polecania yield. Spójrz teraz na koniec przykładu: mamy tutaj dwa wywołania metody next(). Jak widzisz, pierwsze z nich wykonuje tylko kod od początku funkcji generatora, do wywołania polecenia yield. Drugie natomiast wykonuje resztę ciała funkcji generatora.

Polecenie yield może też zwracać wartość:

function* generator() {
  console.log('dec');
  yield 'test';
  console.log('orator');
}

const iterator = generator();

const nextRes1 = iterator.next();
const nextRes2 = iterator.next();

console.log(nextRes1);
console.log(nextRes2);

Efekt powyższego kodu będzie następujący:

wynik działania powyższego kodu

Jak widzisz, działa to tak: pierwsza metoda next() wykonuje kod od początku do wywołania yield. Następnie polecenie yield zwraca wartość “test”, która jest pakowana do właściwości value specjalnego obiektu, który zwracany jest przez pierwszą metodę next(). Kolejne wywołanie next() wykonuje resztę kodu generatora i również zwraca taki obiekt. Tym razem wartość właściwości value tego obiektu to “undefined”.

Zwróć też uwagę, że obiekty zwracane przez metodę next(), oprócz właściwości value posiadają też właściwość done. Informuje ona, czy jest coś jeszcze do zrobienia: jeśli wartość właściwości done równa jest false, oznacza to, że można jeszcze wywołać kolejne next().

Polecenie return

Wspomniałem przed chwilą, że obiekt zwracany przez ostatnie wywołanie metody next() zawiera właściwość value o wartości “undefinded”. To się jednak zmieni, jeśli na funkcja generatora będzie zwracała wartość za pomocą polecania return:

function* generator() {
  console.log('dec');
  yield 'test';
  console.log('orator');
  return 'end!';
}

const iterator = generator();

const nextRes1 = iterator.next();
const nextRes2 = iterator.next();

console.log(nextRes1);
console.log(nextRes2);

Potwierdza to podgląd konsoli przeglądarki po wykonaniu powyższego kodu:

podgląd konsoli przeglądarki

Pętle

Wspomniałem wcześniej, że obiekt zwracany przy wywołaniu funkcji generatora to iterator. Nazwa jest nieprzypadkowa, ponieważ obiekt ten może być wykorzystany w pętli (mówimy, że jest “iterowalny”) czyli można go użyć w konstrukcji for ... of (link):

function* generator() {
  for (let i = 0; i < 10; i++) {
    yield i;
  }
}

const iterator = generator();

for (const item of iterator) {
  console.log(item);
}

Powyższa implementacja funkcji generatora zawiera pętlę, której każdy przebieg wywołuje polecenie yield. Dla prostoty przykładu, zwracana jest po prostu wartość indeksu iteracji.

Spójrz teraz na koniec przykładu. Jak wspomniałem, obiektu iteratora można użyć w pętli for ... of. Działa to w ten sposób, że każdy przebieg tej pętli wywołuje pod spodem metodę next() iteratora, która z kolei powoduje wykonanie jednego przebiegu pętli zaimplementowanej w funkcji generatora. Każdy przebieg tej pętli kończy się na wywołaniu polecenia yield. A że polecenie to zwraca wartość, to jest ona przypisywana do właściwości value obiektu zwracanego przez next(). Wartość tej właściwości jest następnie, pod spodem, wyciągana i przypisywana do zmiennej iteracji (w przykładzie stała item).

W ten sposób w konsoli uzyskujemy ciąg liczb od 0 do 9.

Generator jako “obserwator”

Obiekt zwracany przez generator, oprócz tego, że jest “iterowalny” może też być “obserwowalny”. Polecenie yield potrafi bowiem również przyjmować wartości przekazane do generatora jako parametr wywołania metody next(). Zresztą najlepiej będzie jak przeanalizujemy przykład:

function* generator() {
  while (true) {
    const val = yield; // pobranie wartości przekazanej przez 'next'
    console.log(val);
  }
}

const observer = generator();

observer.next(); // uruchamia obserwator
observer.next('obs');
observer.next('erver');

Tym razem przeanalizujmy przykład trochę od końca. Jak widzisz, mamy tutaj trzy wywołania metody next(). Pierwsze z nich opisane jest jako to, które uruchamia “obserwatora”. Działa to bowiem tak: pierwsze wywołanie next() wykonuje kod funkcji generatora od początku aż do pierwszego wystąpienia polecenia yield i na tym kończy. Dopiero drugie wywołanie next() powoduje wykonanie kodu od tego miejsca, aż do kolejnego polecenia yield itd.

W naszym przykładzie zadziała więc to tak: pierwsze next() uruchamia pętlę, ale już nie wykonuje kodu od linii trzeciej włącznie. Nawet jeśli przekazalibyśmy do generatora jakąś wartość poprzez parametr metody next() tozostał by on zignorowany. Kolejne wywołanie next(), tym razem z parametrem, powoduje przekazanie wartości tego parametru do generatora poprzez yield. Wartość ta jest następnie, w trzeciej linii przykładu, przypisywana do stałej val, a kod wykonuje się dalej, wyświetlając tę wartość na konsoli. Następnie rozpoczyna się kolejna iteracja pętli, która znów zatrzymuje się na poleceniu yield (ale linia ta już się nie wykonuje). Kolejne wywołanie next() znów powoduje przypisanie wartości parametru i wykonanie reszty kodu, itd.

Powyższe pokazuje, że polecenie yield może też służyć do przyjmowania wartości. Zresztą za chwilę nam się to przyda…

Dodatek: sposoby deklarowania generatorów

W powyższych przykładach stosowałem zwykłą deklarację funkcji generatora: function* generator() { ... }. Oczywiście, tak jak i przy normalnych funkcjach istnieją też inne sposoby deklaracji generatorów.

Na pierwszy ogień idzie wyrażenie funkcyjne (ang. “function expression”):

const generator = function* () { ··· };

Tutaj raczej nie ma czego wyjaśniać, gwiazdka pojawia się w tym samym miejscu co w przypadku zwykłej deklaracji funkcji.

Generator może też być jedną z metod literału obiektu. W takim przypadku jego deklaracja wygląda tak:

const obj = {
  * generator() { ... }
};

const iterator = obj.generator();

Oczywiście zamiast powyższego można stosować też, wspomniane wcześniej wyrażenie funkcyjne:

const obj = {
  generator: function* () { ... }
}

Jeśli generator ma być metodą klasy, to deklarujemy go podobnie jak w przypadku pierwszego przykładu deklaracji w literale obiektu:

class Obj {
  * generator() { ... }
}

const obj = Obj();
const iterator = obj.generator();

Myślę, że warto znać powyższe sposoby deklaracji generatorów szczególnie, że ten w przypadku klasy i literału obiektu może nie być w pierwszej chwili oczywisty.

Przypadek użycia

To co przedstawiłem powyżej, to w zasadzie wszystko co trzeba wiedzieć o generatorach, aby z powodzeniem ich używać. Myślę jednak, że warto by było pokazać jeszcze jakiś rzeczywisty przypadek ich użycia w realnej sytuacji.

Najbardziej oczywistą sytuacją wydaje się wykorzystanie generatorów przy pobieraniu danych asynchronicznych. Dzięki nim (oraz na przykład bibliotece co) możemy pracować z danymi asynchronicznymi tak jakby były to dane synchroniczne:

import co from 'co';

const getUsers = () => {
  return new Promise((resolve) => {
    resolve(['users']);
  });
}

const getItems = (user) => {
  if (user) {
    return new Promise((resolve) => {
      resolve('items');
    });
  }
}

function* generator() {
  const users = yield getUsers();
  const items = yield getItems(users[0]); // zależy od users

  return items;
}

co(generator()).then((result) => {
  console.log(result); // 'items'
});

Objaśnienie przykładu

W powyższym przykładzie widać dwie (asynchroniczne) funkcje zwracające obiekt Promise: są to getUsers() oraz getItems(). Następnie mamy generator, w którym wywołujemy powyższe metody, poprzedzone poleceniem yield. Wynik działania tych operacji przypisywany jest do stałych. Istotne jest tutaj to, że wywołanie drugiej funkcji zależy od wyniku działania pierwszej z nich. Na końcu widać wykorzystanie funkcji co, do której przekazujemy zdefiniowany przed chwilą generator.

Ogólnie działa to tak, że funkcja co wywołuje pod spodem generator, tworząc obiekt “obserwatora”. Następnie wywołuje odpowiednią ilość razy metodę next() iteratora, każdorazowo czekając na wynik danej operacji asynchronicznej. Gdy metoda next() zostanie wywołana po raz ostatni, zwraca całość w postaci jednego “promisa”, który możemy już wykorzystać w normalny sposób.

Jak widzisz, kod w którym korzystamy z metod asynchronicznych (w generatorze) wygląda na synchroniczny. Wywołując metodę getItems() zachowujemy się tak, jakby dane users na pewno już były pobrane. Jest to jednak operacja, która może trochę potrwać. Na szczęście tutaj wszystko dzieje się pod spodem, a my nie musimy się martwić - po prostu piszemy kod, tak jakby wszystkie dane były już dostępne.

P.S. We wpisie Co to jest Redux-Saga opisałem inny przykład użycia generatorów w kontekście pobierania danych asynchronicznych. Jeśli korzystasz z Reduxa, to ta wiedza może być dla Ciebie przydatna!

Podsumowanie

I to tyle na dziś - mam nadzieję, że w miarę przystępnie udało mi się przedstawić czym są generatory ES6. Jeśli coś jest nie jasne to daj znać w komentarzach - postaramy się (ja lub inny czytelnicy) rozwiać wszelkie wątpliwości. Zachęcam też do zapoznania się z rozdziałem książki wspomnianego wcześniej Axela Rauschmayera, w którym generatory ES6 zostały rozłożone na czynniki pierwsze!

Do wpisu powstało też dedykowane repozytorium na GitHubie. Możesz tam empirycznie przetestować to wszystko, o czym dziś napisałem!