Redux middleware

Redux jest obecnie najpopularniejszą architekturą do przechowywania i manipulacji stanem w ekosystemie Reacta. Mimo, iż może być używany z dowolną biblioteką to najcześciej zobaczymy go w połączeniu.z Reactem.

Przy podstawowej konfiguracji przepływ informacji wygląda w taki sposób:

Przepływ informacji w Redux

W skrócie: komponent wywołuje akcję, następnie akcja przesyłana jest do reducera, który zwraca nam nowy stan. Komponent powiadamiany jest o zmianie stanu i może zaktualizować swój widok.

Redux pozwala na rozszerzenie funkcji naszego store'a, createStore przyjmuje dodatkowy parametr jakim jest tzw. enhancer. Najpopularniejszym, wbudowanym w Reduxa enhancerem jest applyMiddleware. Funkcja ta przyjmuje jako argumenty middleware'y które chcemy zaaplikować.

Oto schemat przepływu danych z użyciem middleware'a.

Przepływ informacji w Redux z użyciem Middleware

Jak wynika z powyższego schematu, middleware jest pośrednikiem między wywołaną akcją, a reducerem. Ma zatem możliwość modyfikowania przepływającej akcji, zatrzymania jej przed przesłaniem do reducera lub wywoływania dodatkowych akcji.

Oprócz tego zachowuje się jak normalna funkcja. Może na przykład wysyłać zapytania do serwera czy logować dane do konsoli.

Zanim jednak pokażę w jaki sposób tworzy się middleware, krótko przedstawię czym jest currying.

Currying i tworzenie middleware'a

Currying metoda kompozycja funkcji. Taka funkcja zamiast zwrócić wartość będzie zwracała kolejną funkcję, którą można wykonać, aby otrzymać ostateczną wartość. Najlepiej zilustruje to przykład implementacji funkcji add, która dodaje dwie liczby

const add = a => b => a + b;

add(3)(3); // 6
add(5)(3) ;// 8

const addTo5 = add(5); // funkcja b => 5 + b
addTo5(3); // 8
addTo5(5); // 10

Jak widzimy pojedyncze wykonanie takiej funkcji możemy przypisać do zmiennej i ponownie używać. Zapewne spotkaliście się już z curryingiem przy podłączaniu componentu Reacta do store'a.

export default connect(mapStateToProps)(MyComponent)

Oto przykład middleware'u logującego akcję do konsoli i przesyłającego ją dalej w niezmienionej formie. Składa się on z 3 funkcji, które zapewniają dostęp do store'a, funkcji next przesyłającej akcję dalej (do kolejnego middleware'a lub reducera) oraz akcji, która została wywołana.

const loggerMiddleware = store => next => action => {
  console.log(action);
  return next(action);
};

Następnie możemy go dodać do naszego store'a za pomocą applyMiddleware.

import { createStore, applyMiddleware } from 'redux';

const reducer = state => state;

const loggerMiddleware = store => next => action => {
  console.log(action);
  return next(action);
};

const store = createStore(reducer, initialState, applyMiddleware(loggerMiddleware));

Bardzo ważną właściwością middleware'ów w Reduxie jest możliwość ich kompozycji. Stwórzmy middleware, który będzie blokował niektóre akcje.

const blockCounter = store => next => action => {
  const counterValue = store.getState().value;
  if (!action.type === 'INCREASE_COUNTER_VALUE' || counterValue < 10) {
    return next(action);
  }
};

Ten middleware korzysta z argumentu store, aby zdobyć informacje o obecnym stanie. Następnie, jeśli użytkownik chce zwiększyć wartość licznika, ale jest on równy lub większy od 10 - akcja nie jest przesyłana dalej. Użycie middleware'a do wykonania tego typu operacji nie jest wskazane, lecz ten przykład dobrze obrazuje w jaki sposób odczytywać dane ze store'a oraz z akcji.

const store = createStore(
  reducer,
  initialState,
  applyMiddleware(blockCounterMiddleware, loggerMiddleware)
);

Przepływ informacji.z użyciem wielu middleware'ów

Middleware'y zostaną wykonane w takiej kolejności w jakiej zostały przekazane jako argumentu funkcji applyMiddleware. Oznacza to, że w przypadku akcji INCREASE_COUNTER_VALUE, gdy wartość licznika będzie większa bądź równa 10 nie zostanie ona wypisana do konsoli. Jeżeli chcemy logować wszystkie akcje należałoby umieścić loggerMiddleware jako pierwszy argument.

Powyższe informacje wystarczą ci, aby zaimplementować własny middleware w Reduxie. Dobrze jest jednak poznać w jaki sposób Redux obsługuje middleware'y.

Jak działa funkcja applyMiddleware i czym jest enhancer

Mogłoby się wydawać, że implementacja middleware'ów w Reduxie jest skomplikowana i dotyka wielu jego obszarów. Jednak tak na prawdę jest zrobiona w bardzo sprytny sposób i jest to tylko kilka linijek kodu. Tak wygląda kod źródłowy funkcji applyMiddleware.

export default function applyMiddleware(...middlewares) {
  return createStore => (...args) => {
    const store = createStore(...args);
    let dispatch = () => {
      throw new Error(
        'Dispatching while constructing your middleware is not allowed. ' +
          'Other middleware would not be applied to this dispatch.'
      );
    };

    const middlewareAPI = {
      getState: store.getState,
      dispatch: (...args) => dispatch(...args),
    };
    const chain = middlewares.map(middleware => middleware(middlewareAPI));
    dispatch = compose(...chain)(store.dispatch);

    return {
      ...store,
      dispatch,
    };
  };
}

Przypomnijmy sobie w jaki sposób używane jest applyMiddleware

const store = createStore(reducer, initialState, applyMiddleware(loggerMiddleware));

Funkcja applyMiddleware wywoływana jest najpierw z middlewarami, które do niej przekażemy. Następnie w momencie tworzenia store'a przez Reduxa dostarcza jej argumenty createStore oraz (reducer, InitialState). Finalnie takie wywołanie wyglądałoby w ten sposób:

applyMiddleware(loggerMiddleware)(createStore)(reducer, initialState);

Oznacza to, że applyMiddleware ma dostęp do wszystkich parametrów pozwalających na stworzenie swojej wersji store'a. Jak można zauważyć zwraca on { …store, dispatch }, czyli podmienia on istniejący store na własną implementację.

W taki właśnie sposób działa enhancer. Pozwala na podmianę orygialnego store`a na własną implementację. Innym przykładem enhancera jest redux-devtools-extension.

Implementacja dispatch w applyMiddleware

Jak już wspominałem applyMiddleware spełnia swoją funkcję przed podmianę dispatch na własną implementację.

const middlewareAPI = {
  getState: store.getState,
  dispatch: (...args) => dispatch(...args),
};
const chain = middlewares.map(middleware => middleware(middlewareAPI));
dispatch = compose(...chain)(store.dispatch);
return {
  ...store,
  dispatch,
};

Na przykładzie loggerMiddleware dispatch będzie wyglądał w ten sposób:

dispatch = loggerMiddleware({ getState, dispatch })(store.dispatch);

A po wywołaniu takiego dispatcha przez Reduxa loggerMiddleware dostanie akcję jako argument funkcji.

dispatch(action); // loggerMiddleware({ getState, dispatch })(store.dispatch)(action)

Wywołanie loggerMiddleware przez applyMiddleware

Podsumowując, argumenty store oraz next zostaną dostarczone do middleware'a w momencie tworzenia store'a. Następnie, gdy Redux wykonuje akcję (poprzez dispatch), tak na prawdę wywołuje po raz kolejny funkcję zwróconą z loggerMiddleware tym razem z argumentem action.

Argument next w loggerMiddleware jest właściwie oryginalnym dispatchem i przekaże on naszą akcję do reducerów, które następnie zaktualizują state.

Implementacja wielu middleware'ów w applyMiddleware

Implementacja jednego middleware'u wydaje się dosyć prosta - najpierw wywoływany jest middleware, który w rezultacie wywołuje dispatch. Co w przypadku, gdy chcemy zwiększyć ilość middleware'ów obsługiwanych przez store?

Przywołam jeszcze raz implementację applyMiddleware:

const middlewareAPI = {
  getState: store.getState,
  dispatch: (...args) => dispatch(...args),
};
const chain = middlewares.map(middleware => middleware(middlewareAPI));
dispatch = compose(...chain)(store.dispatch);

return {
  ...store,
  dispatch,
};

Jak widzimy wywołane middleware'y są zapisywane do zmiennej chain, która następnie przekazywana jest do funkcji compose. compose pozwala nam na kompozycję wielu funkcji, gdybyśmy chcieli zrobić to ręcznie wyglądałoby to tak:

dispatch = blockCounterMiddleware(API)(
  loggerMiddleware(API)(store.dispatch)
);

Wygląda to dosyć skomplikowanie. Spróbujmy wywołać akcję na takim dispatchu.

blockCounterMiddleware(API)(
  loggerMiddleware(API)(store.dispatch)
)(action);

W rezultacie najpierw zostanie wywołany middleware blockCounterMiddleware, który jako argument next zamiast standardowego dispatcha otrzyma loggerMiddleware(API)(store.dispatch).

blockCounterMiddleware następnie wywoła next(action) czyli tak na prawdę loggerMiddleware(API)(store.dispatch)(action).

W rezultacie zostanie wywołana taka sama funkcja jak w przypadku implementacji pojedynczego middleware'a. loggerMiddleware poprzez next(action) wywołuje oryginalny dispatch z akcją, która przekazywana jest do reducerów.

Analogicznie działa to w przypadku większej ilości middleware'ów. Jako funkcję next otrzymają one kolejny middleware, a ostatni.z nich wywoła dispatch.

middleware1(API)(
	middleware2(API)(
		middleware3(API)(store.dispatch)
	)
)(action)

Dołącz do Newslettera

A będziesz na bieżąco ze wszystkimi postami na moim blogu.