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