Niezmienność w JavaScript przy użyciu Redux
Opublikowany: 2022-03-11W stale rosnącym ekosystemie bogatych i skomplikowanych aplikacji JavaScript jest więcej stanów do zarządzania niż kiedykolwiek wcześniej: bieżący użytkownik, lista załadowanych postów itp.
Każdy zestaw danych, który wymaga historii zdarzeń, można uznać za stanowy. Zarządzanie stanem może być trudne i podatne na błędy, ale praca z niezmiennymi danymi (zamiast mutowalnymi) i pewnymi technologiami pomocniczymi - mianowicie Redux, na potrzeby tego artykułu - może znacznie pomóc.
Niezmienne dane mają ograniczenia, a mianowicie nie można ich zmienić po utworzeniu, ale mają też wiele zalet, szczególnie w odniesieniu do równości referencji i wartości, co może znacznie przyspieszyć aplikacje, które polegają na częstym porównywaniu danych (sprawdzanie, czy coś wymaga aktualizacji , na przykład).
Korzystanie ze stanów niezmiennych pozwala nam napisać kod, który może szybko stwierdzić, czy stan się zmienił, bez konieczności wykonywania rekurencyjnego porównywania danych, co zwykle jest znacznie, znacznie szybsze.
W tym artykule omówimy praktyczne zastosowania Redux podczas zarządzania stanem poprzez kreatory akcji, czyste funkcje, skomponowane reduktory, nieczyste akcje z Redux-saga i Redux Thunk i wreszcie użycie Redux z React. To powiedziawszy, istnieje wiele alternatyw dla Redux, takich jak biblioteki oparte na MobX, Relay i Flux.
Dlaczego Redux?
Kluczowym aspektem, który oddziela Redux od większości innych kontenerów stanów, takich jak MobX, Relay i większości innych implementacji opartych na Flux, jest to, że Redux ma pojedynczy stan, który można modyfikować tylko za pomocą „akcji” (zwykłych obiektów JavaScript), które są wysyłane do Sklep Redux. Większość innych magazynów danych ma stan zawarty w samych komponentach Reacta, pozwala na posiadanie wielu magazynów i/lub używanie stanu zmiennego.
To z kolei powoduje, że reduktor sklepu, czysta funkcja działająca na niezmiennych danych, wykonuje i potencjalnie aktualizuje stan. Proces ten wymusza jednokierunkowy przepływ danych, który jest łatwiejszy do zrozumienia i bardziej deterministyczny.
Ponieważ reduktory Redux są czystymi funkcjami działającymi na niezmiennych danych, zawsze generują te same dane wyjściowe przy tych samych danych wejściowych, co ułatwia ich testowanie. Oto przykład reduktora:
import Immutable from 'seamless-immutable' const initialState = Immutable([]) // create immutable array via seamless-immutable /** * a reducer takes a state (the current state) and an action object (a plain JavaScript object that was dispatched via dispatch(..) and potentially returns a new state. */ function addUserReducer(state = initialState, action) { if (action.type === 'USERS_ADD') { return state.concat(action.payload) } return state // note that a reducer MUST return a value } // somewhere else... store.dispatch({ type: 'USERS_ADD', payload: user }) // dispatch an action that causes the reducer to execute and add the user
Zajmowanie się czystymi funkcjami umożliwia Reduxowi łatwą obsługę wielu przypadków użycia, które generalnie nie są łatwe do wykonania w stanie mutacyjnym, takich jak:
- Podróż w czasie (cofanie się w czasie do poprzedniego stanu)
- Logowanie (śledź każdą akcję, aby dowiedzieć się, co spowodowało mutację w sklepie)
- Środowiska współpracy (takie jak GoogleDocs, gdzie akcje są zwykłymi obiektami JavaScript i mogą być serializowane, przesyłane przez sieć i odtwarzane na innym komputerze)
- Łatwe zgłaszanie błędów (wystarczy wysłać listę wysłanych akcji i odtworzyć je, aby uzyskać dokładnie ten sam stan)
- Zoptymalizowane renderowanie (przynajmniej we frameworkach, które renderują wirtualny DOM jako funkcję stanu, takich jak React: ze względu na niezmienność, możesz łatwo stwierdzić, czy coś się zmieniło, porównując referencje, w przeciwieństwie do rekursywnego porównywania obiektów)
- Z łatwością przetestuj swoje reduktory, ponieważ czyste funkcje można łatwo przetestować jednostkowo
Twórcy akcji
Twórcy akcji Redux pomagają w utrzymaniu kodu w czystości i testowalności. Pamiętaj, że „akcje” w Redux to nic innego jak zwykłe obiekty JavaScript opisujące mutację, która powinna nastąpić. Biorąc to pod uwagę, zapisywanie w kółko tych samych obiektów jest powtarzalne i podatne na błędy.
Kreator akcji w Redux to po prostu funkcja pomocnicza, która zwraca zwykły obiekt JavaScript opisujący mutację. Pomaga to zredukować powtarzający się kod i utrzymuje wszystkie Twoje działania w jednym miejscu:
export function usersFetched(users) { return { type: 'USERS_FETCHED', payload: users, } } export function usersFetchFailed(err) { return { type: 'USERS_FETCH_FAILED', payload: err, } } // reducer somewhere else... const initialState = Immutable([]) // create immutable array via seamless-immutable /** * a reducer takes a state (the current state) and an action object (a plain JavaScript object that was dispatched via dispatch(..) and potentially returns a new state. */ function usersFetchedReducer(state = initialState, action) { if (action.type === 'USERS_FETCHED') { return Immutable(action.payload) } return state // note that a reducer MUST return a value }
Używanie Redux z niezmiennymi bibliotekami
Chociaż sama natura reduktorów i akcji ułatwia ich testowanie, bez biblioteki pomocniczej niezmienności, nic nie chroni przed mutowaniem obiektów, co oznacza, że testy dla wszystkich reduktorów muszą być szczególnie niezawodne.
Rozważ następujący przykład kodu problemu, który napotkasz bez biblioteki, która Cię chroni:
const initialState = [] function addUserReducer(state = initialState, action) { if (action.type === 'USERS_ADD') { state.push(action.payload) // NOTE: mutating action!! return state } return state // note that a reducer MUST return a value }
W tym przykładzie kodu podróż w czasie zostanie przerwana, ponieważ poprzedni stan będzie teraz taki sam jak stan bieżący, czyste komponenty mogą potencjalnie nie zostać zaktualizowane (lub ponownie renderowane), ponieważ odniesienie do stanu nie uległo zmianie, mimo że dane, które Zawartość uległa zmianie, a mutacje są znacznie trudniejsze do zrozumienia.
Bez biblioteki niezmienności tracimy wszystkie korzyści, które zapewnia Redux. Dlatego zdecydowanie zaleca się korzystanie z biblioteki pomocniczej niemutowalności, takiej jak immutable.js lub seamless-immutable, zwłaszcza podczas pracy w dużym zespole z wieloma rękami dotykającymi kodu.
Bez względu na to, której biblioteki używasz, Redux będzie zachowywał się tak samo. Porównajmy zalety i wady obu, abyś mógł wybrać ten, który najlepiej pasuje do Twojego przypadku użycia:
Niezmienny.js
Immutable.js to biblioteka zbudowana przez Facebooka, z bardziej funkcjonalnym stylem obejmującym struktury danych, takie jak mapy, listy, zestawy i sekwencje. Jego biblioteka niezmiennych trwałych struktur danych wykonuje najmniejszą możliwą ilość kopiowania między różnymi stanami.
Plusy:
- Udostępnianie strukturalne
- Bardziej wydajny w aktualizacjach
- Bardziej wydajna pamięć
- Posiada zestaw metod pomocniczych do zarządzania aktualizacjami
Cons:
- Nie działa bezproblemowo z istniejącymi bibliotekami JS (tj. lodash, ramda)
- Wymaga konwersji do i z (toJS / fromJS), szczególnie podczas hydratacji / odwodnienia i renderowania
Bezproblemowo-niezmienny
Seamless-immutable to biblioteka dla niezmiennych danych, która jest wstecznie kompatybilna z ES5.
Opiera się na funkcjach definicji właściwości ES5, takich jak defineProperty(..)
do wyłączania mutacji na obiektach. Jako taki jest w pełni kompatybilny z istniejącymi bibliotekami, takimi jak lodash i Ramda. Można go również wyłączyć w kompilacjach produkcyjnych, zapewniając potencjalnie znaczny wzrost wydajności.
Plusy:
- Działa bezproblemowo z istniejącymi bibliotekami JS (np. lodash, ramda)
- Do obsługi konwersji nie jest potrzebny dodatkowy kod
- Kontrole można wyłączyć w kompilacjach produkcyjnych, zwiększając wydajność
Cons:
- Brak współdzielenia strukturalnego - obiekty/macierze są płytko kopiowane, co spowalnia przy dużych zbiorach danych
- Nie tak wydajna pamięć
Redux i wiele reduktorów
Kolejną przydatną funkcją Redux jest możliwość wspólnego komponowania reduktorów. Pozwala to na tworzenie znacznie bardziej skomplikowanych aplikacji, a w aplikacji o dowolnym znaczącym rozmiarze nieuchronnie będziesz mieć wiele typów stanów (bieżący użytkownik, załadowana lista postów, itp). Redux obsługuje (i zachęca) do tego przypadku użycia, naturalnie udostępniając funkcję combineReducers
:
import { combineReducers } from 'redux' import currentUserReducer from './currentUserReducer' import postsListReducer from './postsListReducer' export default combineReducers({ currentUser: currentUserReducer, postsList: postsListReducer, })
Za pomocą powyższego kodu możesz mieć komponent, który opiera się na currentUser
i inny komponent, który opiera się na postsList
. Poprawia to również wydajność, ponieważ każdy pojedynczy komponent będzie subskrybował tylko te gałęzie drzewa, które go dotyczą.
Nieczyste działania w Redux
Domyślnie możesz wysyłać do Redux tylko zwykłe obiekty JavaScript. Jednak w przypadku oprogramowania pośredniczącego Redux może obsługiwać nieczyste działania, takie jak pobieranie aktualnego czasu, wykonywanie żądania sieciowego, zapisywanie pliku na dysku i tak dalej.
„Oprogramowanie pośredniczące” to termin używany dla funkcji, które mogą przechwytywać wysyłane akcje. Po przechwyceniu może wykonywać takie czynności, jak przekształcanie akcji lub wysyłanie akcji asynchronicznej, podobnie jak oprogramowanie pośredniczące w innych platformach (takich jak Express.js).
Dwie bardzo popularne biblioteki oprogramowania pośredniego to Redux Thunk i Redux-saga. Redux Thunk jest napisany w stylu imperatywnym, podczas gdy Redux-saga jest napisany w stylu funkcjonalnym. Porównajmy oba.

Redux Thunk
Redux Thunk obsługuje nieczyste działania w Redux, używając thunks, funkcji, które zwracają inne funkcje z możliwością tworzenia łańcuchów. Aby korzystać z Redux-Thunk, musisz najpierw zamontować w sklepie oprogramowanie pośredniczące Redux Thunk:
import { createStore, applyMiddleware } from 'redux' import thunk from 'redux-thunk' const store = createStore( myRootReducer, applyMiddleware(thunk), // here, we apply the thunk middleware to R )
Teraz możemy wykonywać nieczyste akcje (takie jak wykonanie wywołania API), wysyłając thunk do sklepu Redux:
store.dispatch( dispatch => { return api.fetchUsers() .then(users => dispatch(usersFetched(users)) // usersFetched is a function that returns a plain JavaScript object (Action) .catch(err => dispatch(usersFetchError(err)) // same with usersFetchError } )
Należy pamiętać, że używanie thunków może utrudnić testowanie kodu i rozumowanie w przepływie kodu.
Redux-saga
Redux-saga obsługuje nieczyste działania poprzez funkcję ES6 (ES2015) zwaną generatorami oraz bibliotekę funkcjonalnych/czystych pomocników. Wspaniałą rzeczą w generatorach jest to, że można je wznawiać i wstrzymywać, a ich kontrakt API sprawia, że są niezwykle łatwe do przetestowania.
Zobaczmy, jak możemy poprawić czytelność i testowalność poprzedniej metody thunk przy użyciu sag!
Najpierw zamontujmy oprogramowanie pośredniczące Redux-saga w naszym sklepie:
import { createStore, applyMiddleware } from 'redux' import createSagaMiddleware from 'redux-saga' import rootReducer from './rootReducer' import rootSaga from './rootSaga' // create the saga middleware const sagaMiddleware = createSagaMiddleware() // mount the middleware to the store const store = createStore( rootReducer, applyMiddleware(sagaMiddleware), ) // run our saga! sagaMiddleware.run(rootSaga)
Zwróć uwagę, że funkcja run(..)
musi zostać wywołana wraz z saga, aby rozpocząć wykonywanie.
Teraz stwórzmy naszą sagę:
import { call, put, takeEvery } from 'redux-saga/effects' // these are saga effects we'll use export function *fetchUsers(action) { try { const users = yield call(api.fetchUsers) yield put(usersFetched(users)) } catch (err) { yield put(usersFetchFailed(err)) } } export default function *rootSaga() { yield takeEvery('USERS_FETCH', fetchUsers) }
Zdefiniowaliśmy dwie funkcje generatora, jedną pobierającą listę użytkowników i rootSaga
. Zauważ, że nie api.fetchUsers
bezpośrednio, ale zamiast tego otrzymaliśmy go w obiekcie wywołania. Dzieje się tak, ponieważ Redux-saga przechwytuje obiekt wywołania i wykonuje zawartą w nim funkcję, aby stworzyć czyste środowisko (jeśli chodzi o twoje generatory).
rootSaga
zwraca pojedyncze wywołanie funkcji o nazwie takeEvery,
która wykonuje każdą akcję wykonaną z typem USERS_FETCH
i wywołuje sagę fetchUsers
z podjętą akcją. Jak widać, tworzy to bardzo przewidywalny model skutków ubocznych dla Redux, co ułatwia testowanie!
Testowanie sag
Zobaczmy, jak generatory ułatwiają testowanie naszych sag. W tej części użyjemy mokki, aby uruchomić nasze testy jednostkowe i chai dla asercji.
Ponieważ sagi dostarczają zwykłych obiektów JavaScript i są uruchamiane w generatorze, możemy łatwo przetestować, czy zachowują się prawidłowo, bez żadnych drań! Należy pamiętać, że call
, take
, put
, itd. są zwykłymi obiektami JavaScript, które są przechwytywane przez oprogramowanie pośredniczące Redux-saga.
import { take, call } from 'redux-saga/effects' import { expect } from 'chai' import { rootSaga, fetchUsers } from '../rootSaga' describe('saga unit test', () => { it('should take every USERS_FETCH action', () => { const gen = rootSaga() // create our generator iterable expect(gen.next().value).to.be.eql(take('USERS_FETCH')) // assert the yield block does have the expected value expect(gen.next().done).to.be.equal(false) // assert that the generator loops infinitely }) it('should fetch the users if successful', () => { const gen = fetchUsers() expect(gen.next().value).to.be.eql(call(api.fetchUsers)) // expect that the call effect was yielded const users = [ user1, user2 ] // some mock response expect(gen.next(users).value).to.be.eql(put(usersFetched(users)) }) it('should fail if API fails', () => { const gen = fetchUsers() expect(gen.next().value).to.be.eql(call(api.fetchUsers)) // expect that the call effect was yielded const err = { message: 'authentication failed' } // some mock error expect(gen.throw(err).value).to.be.eql(put(usersFetchFailed(err)) }) })
Praca z React
Chociaż Redux nie jest powiązany z żadną konkretną biblioteką towarzyszącą, działa szczególnie dobrze z React.js, ponieważ komponenty React są czystymi funkcjami, które przyjmują stan jako dane wejściowe i wytwarzają wirtualny DOM jako dane wyjściowe.
React-Redux to biblioteka pomocnicza dla React i Redux, która eliminuje większość ciężkiej pracy łączącej te dwa. Aby jak najefektywniej korzystać z React-Redux, przejdźmy do pojęcia komponentów prezentacyjnych i komponentów kontenerowych.
Komponenty prezentacyjne opisują, jak rzeczy powinny wyglądać wizualnie, w zależności wyłącznie od ich rekwizytów do renderowania; wywołują one wywołania zwrotne z rekwizytów do akcji wysyłania. Są napisane ręcznie, całkowicie czyste i nie są powiązane z systemami zarządzania stanem, takimi jak Redux.
Z drugiej strony komponenty kontenera opisują, jak rzeczy powinny działać, są świadome Redux, wysyłają akcje Redux bezpośrednio w celu wykonania mutacji i są generalnie generowane przez React-Redux. Często są połączone z komponentem prezentacyjnym, dostarczając jego rekwizytów.
Napiszmy komponent prezentacyjny i połączmy go z Redux przez React-Redux:
const HelloWorld = ({ count, onButtonClicked }) => ( <div> <span>Hello! You've clicked the button {count} times!</span> <button onClick={onButtonClicked}>Click me</button> </div> ) HelloWorld.propTypes = { count: PropTypes.number.isRequired, onButtonClicked: PropTypes.func.isRequired, }
Zauważ, że jest to „głupi” komponent, który całkowicie opiera się na swoich rekwizytach, aby funkcjonować. To świetnie, ponieważ sprawia, że komponent React jest łatwy do testowania i komponowania. Przyjrzyjmy się teraz, jak podłączyć ten komponent do Redux, ale najpierw omówmy, czym jest komponent wyższego rzędu.
Komponenty wyższego rzędu
React-Redux udostępnia funkcję pomocniczą o nazwie connect( .. )
, która tworzy komponent wyższego rzędu z „głupiego” komponentu Reacta, który jest świadomy Redux.
React kładzie nacisk na rozszerzalność i ponowne wykorzystanie poprzez skład, czyli owijanie komponentów w inne komponenty. Zawijanie tych komponentów może zmienić ich zachowanie lub dodać nowe funkcje. Zobaczmy, jak możemy utworzyć komponent wyższego rzędu z naszego komponentu prezentacyjnego, który jest świadomy Redux - komponent kontenera.
Oto jak to robisz:
import { connect } from 'react-redux' const mapStateToProps = state => { // state is the state of our store // return the props that we want to use for our component return { count: state.count, } } const mapDispatchToProps = dispatch => { // dispatch is our store dispatch function // return the props that we want to use for our component return { onButtonClicked: () => { dispatch({ type: 'BUTTON_CLICKED' }) }, } } // create our enhancer function const enhancer = connect(mapStateToProps, mapDispatchToProps) // wrap our "dumb" component with the enhancer const HelloWorldContainer = enhancer(HelloWorld) // and finally we export it export default HelloWorldContainer
Zauważ, że zdefiniowaliśmy dwie funkcje, mapStateToProps
i mapDispatchToProps
.
mapStateToProps
to czysta funkcja (stan: Obiekt), która zwraca obiekt obliczony ze stanu Redux. Ten obiekt zostanie scalony z rekwizytami przekazanymi do opakowanego komponentu. Nazywa się to również selektorem, ponieważ wybiera części stanu Redux, które mają zostać scalone z właściwościami komponentu.
mapDispatchToProps
jest również czystą funkcją, ale jedną z (dispatch: (Action) => void), która zwraca obiekt obliczony z funkcji wysyłania Redux. Ten obiekt zostanie również scalony z rekwizytami przekazanymi do opakowanego komponentu.
Teraz, aby użyć naszego komponentu kontenera, musimy użyć komponentu Provider
w React-Redux, aby powiedzieć komponentowi kontenera, którego sklepu użyć:
import { Provider } from 'react-redux' import { render } from 'react-dom' import store from './store' // where ever your Redux store resides import HelloWorld from './HelloWorld' render( ( <Provider store={store}> <HelloWorld /> </Provider> ), document.getElementById('container') )
Komponent Provider
propaguje sklep do wszystkich komponentów podrzędnych, które subskrybują sklep Redux, utrzymując wszystko w jednym miejscu i redukując liczbę błędów lub mutacji!
Zbuduj pewność kodu dzięki Redux
Dzięki tej nowo odkrytej wiedzy o Redux, jego licznych bibliotekach pomocniczych i połączeniu frameworka z React.js, możesz łatwo ograniczyć liczbę mutacji w swojej aplikacji poprzez kontrolę stanu. Z kolei silna kontrola stanu umożliwia szybsze poruszanie się i tworzenie solidnej bazy kodu z większą pewnością.