Ostatnie dwa tygodnie upłynęły nam pod znakiem wywołań asynchronicznych w Redux oraz RxJS (wprowadzenie oraz Redux + RxJS). Przy tej okazji dostałem kilka pytań od czytelników: “a co z redux-saga?”. Dlatego tez postanowiłem pozostać jeszcze w temacie Reduxa i pokazać dziś co to jest Redux-Saga! Myślę, że w ten sposób w dość dogłębny sposób będziemy mieli na blogu omówiony temat asynchroniczności w Redux.

P.S. Jak widzisz, jestem bardzo otwarty na propozycje tematów wpisów, które podsuwają mi moi czytelnicy! Jeśli jest jakiś temat, który chciałbyś poczytać na blogu to pisz do mnie śmiało (czy to poprzez formularz kontaktowy, czy przez wiadomość na fanpage bloga na Facebooku). Nie obiecuję, że zajmę się Twoim tematem w pierwszej kolejności ale na pewno trafi on na listę pomysłów na wpisy. Jest więc duże prawdopodobieństwo, że ostatecznie pomysł taki przerodzi się w posta!

Wprowadzenie do generatorów w ES6

Zanim przejdę do przedstawienia co to jest redux-saga, muszę najpierw poświęcić chwilę na pokazanie koncepcji generatorów jaka pojawiła się w ES6. To właśnie na tym zagadnieniu opiera się redux-saga dlatego też myślę, że kluczowym jest aby wiedzieć o co chodzi…

Pojęcie generatorów może się początkowo wydawać dość skomplikowane. Szersze omówienie tego tematu to materiał na oddzielny wpis, który prawdopodobnie pojawi się w przyszłości na blogu. Dziś natomiast chcę się skupić na redux-saga więc teraz przedstawię tylko minimum informacji na temat generatorów, jakie będzie nam potrzebne w dalszej części wpisu.

Co to jest generator

Przejdźmy zatem do rzeczy! Generator jest to specjalna funkcja zwracająca obiekt iteratora. Definiuje się ją za pomocą wywołania function* () (funkcja z gwiazdką):

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

W powyższym przykładzie, funkcja generatora nie ma jeszcze ciała. Dodajmy więc je:

const generatorFunction = function* () {
  console.log('body');
};

Iterator i metoda next()

Jak wspomniałem, funkcja ta zwraca obiekt iteratora. Oznacza to, że ciało funkcji generatora nie jest wykonywane w momencie wywołania tej funkcji. Dzieje się to dopiero gdy wywołamy metodę next() iteratora:

const generator = function* () {
  console.log('body');
};
const iterator = generator();

iterator.next(); // prints "body" text

Słowo kluczowe yield

W ciele funkcji generatora możemy używać słowa kluczowego yield. Działa ono podobnie do return, służy jednak do zwracania kolejnych wartości iteracji przez iterator. Metoda next() iteratora utworzonego przez generator wykorzystujący yield zwraca obiekt posiadający właściwość done. Mówi ona o stanie iteratora - czy jest iterowanie jest już zakończone czy jeszcze nie:

const generator = function* () {
    yield;
};
const iterator = generator();

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

Za pomocą yield możemy oczywiście zwracać wartości:

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

let next = iterator.next();
while (next.done === false) {
  console.log(next.value);
  next = iterator.next();
}

W powyższym kodzie, funkcja generatora zawiera pętlę, która dla każdej iteracji zwraca wartość zmiennej i. Jak widać w dalszej części przykładu, za pomocą wywołania metody next() iteratora możemy w dowolnym momencie “dobierać” kolejne iteracje z generatora. Każda z takich iteracji to obiekt zawierający wspomnianą już właściwość done oraz właściwość value, która zawiera wartość zwracaną w danej iteracji za pomocą yield.

Za pomocą yield dozwolone jest zwracanie każdego typu danych: obiekty, tablice, funkcje, ciągi znaków czy liczby. Oczywiście siłą tego rozwiązania jest to, że dane z generatora mogą trafiać do iteratora w sposób asynchroniczny.

Iteracja za pomocą pętli for ... of

W ES6 możemy użyć pętli for ... of do iteracji po obiekcie iteratora:

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

for (let val of iterator) {
  console.log(val);
}

Powyższy kod jest tożsamy z tym co pokazałem w poprzednim przykładzie. Tym razem jednak, zamiast pętli while użyłem pętli for ... of. W tym przypadku wywołania metody next() kontrolowane są przez pętlę. Są też przez nią obsługiwane “w locie”: pętla sama kontroluje wystąpienie wartości true właściwości done, a wartość właściwości value przypisywana jest bezpośrednio do zmiennej val.

Więcej na temat generatorów

To co powyżej pokazałem to absolutne minimum jakie trzeba znać na temat generatorów w ES6. Nie wyczerpuje to jednak tematu. Zachęcam do zajrzenia do tego artykułu Axela Rauschmayera, który moim zdaniem w pełni przedstawia potęgę generatorów!

Ponadto generatory zostały fajnie przedstawione w tym artykule - każdy przykład pokazany jest w edytorze, można więc samemu odpalać te przykłady oraz modyfikować i testować samodzielnie!

Co to jest redux-saga?

Skoro co nieco wiesz już o generatorach, mogę teraz przejść do przedstawienia co to jest redux-saga. Otóż mówiąc w skrócie, redux-saga to middleware Reduxa, które pozwala na wykorzystanie generatorów ES6 do obsługi wywołań asynchronicznych. Czyli jest to coś trochę podobnego do redux-observable dla omawianego ostatnio na blogu RxJS…

W dalszej części tego wpisu potrzebna Ci będzie wiedza, którą zawarłem w poniższych moich artykułach:

Konfiguracja

Jak zwykle, zanim przejdę do omawiania zawiłości tego rozwiązania, kilka słów na temat konfiguracji tego middleware. Spójrz na przykład kodu odpowiedzialnego za utworzenie “store” Reduxa:

import { createStore, applyMiddleware } from 'redux';
import createSagaMiddleware from 'redux-saga';

import { rootSaga } from './rootSaga';

const sagaMiddleware = createSagaMiddleware();
const store = createStore(
  reducer,
  applyMiddleware(sagaMiddleware)
);
sagaMiddleware.run(rootSaga);

Jak widzisz, jest to typowy kod tworzący “store” Reduxa. To co ważne to import funkcji createSagaMiddleware z pakietu redux-saga. Oczywiście instalujemy go z npm, w sposób standardowy (npm install --save-dev redux-saga).

Dalsza część kodu również jest standardowa - wywołujemy funkcję createSagaMiddleware, a jej wynik przekazujemy jako parametr wywołania funkcji applyMiddleware. Ważne jest, aby przypisać ten wynik do zmiennej. Jest to bowiem obiekt, który wykorzystujemy na końcu przykładu wywołując jego metodę run(). Jak widzisz, przekazujemy do niego funkcję rootSaga, którą zaimportowałem z pliku rootSaga.js. Oto kod tego pliku:

import { getDataSaga } from './sagas';

export function* rootSaga() {
  yield [
    getDataSaga()
  ];
}

Widać tutaj, że funkcja rootSaga to funkcja generatora, która zwraca wszystkie inne “sagi” (w tym przypadku tylko jedną). Zastanawiasz się pewnie co to jest ta “saga”? Spieszę z wyjaśnieniem…

Co to jest saga?

Wedle tego co napisano w dokumentacji, “saga” jest to specjalny wątek aplikacji, który odpowiedzialny jest za obsługę “efektów ubocznych”. Efekt uboczny jest to termin z programowania reaktywnego, oznaczający mniej więcej efekt działania jakiejś operacji, który może być odroczony w czasie.

Sagę można porównać do “Epica” czyli koncepcji, którą znamy już z mojego ostatniego wpisu na temat RxJS w Redux. Jest to funkcja (w tym przypadku generator), która nasłuchuje na rozgłaszane akcje i na tej podstawie uruchamia operację asynchroniczną, która w efekcie rozgłasza inne akcje w zależności od wyniku.

Przejdźmy do przykładu. Oprę się tutaj na przykładzie z wpisu na temat wywołań asynchronicznych w Redux. Mieliśmy tam takie akcje:

export const GET_DATA_REQUESTED = 'GET_DATA_REQUESTED';
export const GET_DATA_DONE = 'GET_DATA_DONE';
export const GET_DATA_FAILED = 'GET_DATA_FAILED';

export function getDataRequested() {
  return {
    type: 'GET_DATA_REQUESTED'
  };
}

export function getDataDone(data) {
  return {
    type: 'GET_DATA_DONE',
    payload: data
  };
}

export function getDataFailed(error) {
  return {
    type: 'GET_DATA_FAILED',
    payload: error
  };
}

Myślę, że tutaj jest wszystko jasne. Jeśli spojrzysz teraz raz jeszcze do pliku rootSaga.js, to zauważysz, że importuję tam metodę getDataSaga() z pliku sagas.js. Spójrzmy teraz na kod tego pliku:

import * as actions from './actions';
import fetch from 'isomorphic-fetch';
import { call, put, takeEvery } from 'redux-saga/effects'

export function* getData() {
  try {
    const response = yield call(fetch, 'https://api.github.com/users/burczu/repos');
    const data = yield response.json();
    yield put(actions.getDataDone(data));
  } catch (e) {
    yield put(actions.getDataFailed(e));
  }
}

export function* getDataSaga() {
  yield takeEvery(actions.GET_DATA_REQUESTED, getData);
}

Objaśnienie kodu sagi

Jak widzisz, importuję tutaj wszystko z akcji (kreatory oraz stałe typów), metodę fetch z pakietu isomorphic-fetch oraz kilka metod z redux-saga/effects (o nich za chwilę).

Na początek spójrz na funkcję generatora getDataSaga(). Jest to funkcja generatora, która dodana została do generatora rootSaga, a ten z kolei przekazany został do sagaMiddleware. Jak widzisz, zwraca on za pomocą yield wynik wywołania funkcji takeEvery(). Funkcja ta “nasłuchuje” każdej akcji typu GET_DATA_REQUESTED i w przypadku gdy na nią natrafi, wywołuje i zwraca wynik wywołania generatora getData(), którego implementacja znajduje się powyżej.

Funkcja getData() wywołuje z kolei funkcję call(). Jako parametry przekazuję jej metodę fetch() oraz adres API, który ma zostać wywołany. Całość za pomocą yield przypisuję do stałej response. Dzięki temu, do response przypisany zostanie efekt działania “promisa” zwracanego przez funkcję fetch() gdy ten będzie dostępny.

Jest to trochę zakręcony kod, co moim zdaniem sprawia, że redux-saga może nie być zbyt proste do zrozumienia. Generalnie chodzi o to, że wywołując funkcję call() informujemy middleware, że ma on za pomocą funkcji next() wywołać funkcję fetch() z parametrem będącym ścieżką do API.

Dalej response zwracany przez fetch() musi zostać jeszcze przekształcony za pomocą funkcji json(). Funkcja ta, także standardowo zwraca “promise”, dlatego też tutaj również wykorzystuję yield, dzięki czemu do stałej data przypisany zostanie efekt rozwiązania “promise”, kiedy ten będzie dostępny.

Na koniec generator zwraca wynik działania funkcji put(), do której przekazuję wywołanie kreatora nowej akcji. Jest to funkcja podobna do call() tylko, że zamiast wywoływać pierwszy parametr jako funkcję, middleware rozgłasza przekazany parametr jako akcję. Dzięki temu zostanie ona standardowo obsłużona przez “reducer”.

Odpowiedzialność middleware redux-saga

De facto, w powyższym przykładzie, nie było konieczne wywołanie funkcji call(). Można było wywołać bezpośrednio metodę fetch():

const response = yield fetch('https://api.github.com/users/burczu/repos');

Chciałem jednak również o niej wspomnieć dlatego też pojawiła się w przykładzie.

Co do zasady funkcji put() lub call() używa się zwracając wartość z generatora, na podstawie którego utworzony zostanie iterator (w middleware). Metody te zwracają odpowiedni typ obiektu, na którego podstawie middleware wie czy wywołać funkcję czy też rozgłosić akcję.

Odpowiedzialnością middleware redux-saga jest utworzenie iteratorów na podstawie generatorów, które zostały w nim zarejestrowane (z pomocą generatora rootSaga). Middleware może wtedy obsługiwać iteratory za każdym razem, gdy w systemie zostanie rozgłoszona jakaś akcja.

Podsumowanie

To tyle na dziś. Podobnie jak w przypadku RxJS + Redux, także tutaj mam mieszane uczucia co do tego czy udało mi się jasno przedstawić co to jest redux-saga… O ile zarówno redux-saga jak i redux-observable rozwiązują ten sam problem to w tym momencie bardziej do mnie przemawia koncepcja pracy na strumieniach w RxJS. Użycie metod put() oraz call() wydaje się trochę nienaturalne i wymuszone co nie do końca mnie przekonuje. A Ty co o tym sądzisz?

P.S. Oczywiście kod dzisiejszego przykładu dostępny jest u mnie na GitHubie. Klonuj śmiało i testuj we własnym zakresie - na pewno warto dla lepszego zrozumienia tematu!