Unveränderlichkeit in JavaScript mit Redux
Veröffentlicht: 2022-03-11In einem ständig wachsenden Ökosystem aus reichhaltigen und komplizierten JavaScript-Anwendungen müssen mehr Status verwaltet werden als je zuvor: der aktuelle Benutzer, die Liste der geladenen Beiträge usw.
Jeder Datensatz, der einen Ereignisverlauf benötigt, kann als zustandsbehaftet betrachtet werden. Das Verwalten des Zustands kann schwierig und fehleranfällig sein, aber das Arbeiten mit unveränderlichen Daten (anstelle von veränderlichen) und bestimmten unterstützenden Technologien – für die Zwecke dieses Artikels insbesondere Redux – kann erheblich helfen.
Unveränderliche Daten haben Einschränkungen, nämlich dass sie nach ihrer Erstellung nicht mehr geändert werden können, aber sie haben auch viele Vorteile, insbesondere in Bezug auf die Gleichheit von Referenzen und Werten, was Anwendungen erheblich beschleunigen kann, die auf häufigem Vergleichen von Daten beruhen (Prüfen, ob etwas aktualisiert werden muss). , zum Beispiel).
Durch die Verwendung unveränderlicher Zustände können wir Code schreiben, der schnell erkennen kann, ob sich der Zustand geändert hat, ohne einen rekursiven Vergleich der Daten durchführen zu müssen, was normalerweise viel, viel schneller ist.
Dieser Artikel behandelt die praktischen Anwendungen von Redux bei der Verwaltung von Zuständen durch Aktionsersteller, reine Funktionen, zusammengesetzte Reduzierer, unreine Aktionen mit Redux-Saga und Redux Thunk und schließlich die Verwendung von Redux mit React. Allerdings gibt es viele Alternativen zu Redux, wie z. B. MobX-, Relay- und Flux-basierte Bibliotheken.
Warum Redux?
Der Schlüsselaspekt, der Redux von den meisten anderen Zustandscontainern wie MobX, Relay und den meisten anderen Flux-basierten Implementierungen unterscheidet, ist, dass Redux einen einzigen Zustand hat, der nur über „Aktionen“ (einfache JavaScript-Objekte) geändert werden kann, die an die gesendet werden Redux-Laden. Die meisten anderen Datenspeicher haben den Zustand, der in den React-Komponenten selbst enthalten ist, ermöglichen es Ihnen, mehrere Speicher zu haben und/oder veränderliche Zustände zu verwenden.
Dies wiederum bewirkt, dass der Reduzierer des Speichers, eine reine Funktion, die mit unveränderlichen Daten arbeitet, den Zustand ausführt und möglicherweise aktualisiert. Dieser Prozess erzwingt einen unidirektionalen Datenfluss, der einfacher zu verstehen und deterministischer ist.
Da Redux-Reduzierer reine Funktionen sind, die mit unveränderlichen Daten arbeiten, erzeugen sie bei gleicher Eingabe immer die gleiche Ausgabe, wodurch sie leicht zu testen sind. Hier ist ein Beispiel für einen Reduzierer:
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
Der Umgang mit reinen Funktionen ermöglicht es Redux, viele Anwendungsfälle zu unterstützen, die im Allgemeinen nicht einfach mit mutativen Zuständen durchgeführt werden können, wie zum Beispiel:
- Zeitreise (Zeitreise in einen früheren Zustand)
- Protokollierung (Verfolgen Sie jede einzelne Aktion, um herauszufinden, was eine Mutation im Geschäft verursacht hat)
- Kollaborative Umgebungen (z. B. GoogleDocs, wo Aktionen einfache JavaScript-Objekte sind und serialisiert, über das Netzwerk gesendet und auf einem anderen Computer wiedergegeben werden können)
- Einfache Fehlerberichterstattung (Senden Sie einfach die Liste der gesendeten Aktionen und wiederholen Sie sie, um genau denselben Status zu erhalten)
- Optimiertes Rendering (Zumindest in Frameworks, die virtuelles DOM als Funktion des Zustands rendern, wie z. B. React: Aufgrund der Unveränderlichkeit können Sie leicht feststellen, ob sich etwas geändert hat, indem Sie Referenzen vergleichen, anstatt die Objekte rekursiv zu vergleichen)
- Testen Sie Ihre Reduzierer ganz einfach, da reine Funktionen einfach getestet werden können
Aktionsersteller
Die Aktionsersteller von Redux helfen dabei, den Code sauber und testbar zu halten. Denken Sie daran, dass „Aktionen“ in Redux nichts anderes sind als einfache JavaScript-Objekte, die eine Mutation beschreiben, die auftreten sollte. Davon abgesehen wiederholt sich das wiederholte Ausschreiben der gleichen Objekte und ist fehleranfällig.
Ein Aktionsersteller in Redux ist einfach eine Hilfsfunktion, die ein einfaches JavaScript-Objekt zurückgibt, das eine Mutation beschreibt. Dies trägt dazu bei, sich wiederholenden Code zu reduzieren, und hält alle Ihre Aktionen an einem Ort:
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 }
Verwenden von Redux mit unveränderlichen Bibliotheken
Während die Natur von Reducern und Aktionen es einfach macht, sie zu testen, gibt es ohne eine Unveränderlichkeits-Hilfsbibliothek nichts, was Sie vor mutierenden Objekten schützt, was bedeutet, dass die Tests für alle Ihre Reducer besonders robust sein müssen.
Betrachten Sie das folgende Codebeispiel eines Problems, auf das Sie ohne eine Bibliothek zu Ihrem Schutz stoßen werden:
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 }
In diesem Codebeispiel wird die Zeitreise unterbrochen, da der vorherige Zustand jetzt derselbe wie der aktuelle Zustand ist, reine Komponenten möglicherweise nicht aktualisiert (oder neu gerendert) werden, da sich der Verweis auf den Zustand nicht geändert hat, obwohl die Daten es sind enthält hat sich geändert, und Mutationen sind viel schwieriger zu durchdenken.
Ohne eine Unveränderlichkeitsbibliothek verlieren wir alle Vorteile, die Redux bietet. Es wird daher dringend empfohlen, eine Unveränderlichkeits-Hilfsbibliothek wie immutable.js oder seamless-immutable zu verwenden, insbesondere wenn Sie in einem großen Team mit mehreren Händen am Code arbeiten.
Unabhängig davon, welche Bibliothek Sie verwenden, verhält sich Redux gleich. Lassen Sie uns die Vor- und Nachteile beider vergleichen, damit Sie diejenige auswählen können, die für Ihren Anwendungsfall am besten geeignet ist:
Unveränderlich.js
Immutable.js ist eine von Facebook erstellte Bibliothek mit einem funktionaleren Stil für Datenstrukturen wie Karten, Listen, Sätze und Sequenzen. Seine Bibliothek aus unveränderlichen persistenten Datenstrukturen führt die geringstmögliche Menge an Kopiervorgängen zwischen verschiedenen Zuständen durch.
Vorteile:
- Strukturelles Teilen
- Effizienter bei Updates
- Speichereffizienter
- Verfügt über eine Reihe von Hilfsmethoden zum Verwalten von Updates
Nachteile:
- Funktioniert nicht nahtlos mit bestehenden JS-Bibliotheken (z. B. Lodash, Ramda)
- Erfordert die Konvertierung nach und von (toJS / fromJS), insbesondere während der Hydratation / Dehydratisierung und des Renderns
Nahtlos unveränderlich
Seamless-immutable ist eine Bibliothek für unveränderliche Daten, die bis zu ES5 abwärtskompatibel ist.
Es basiert auf Eigenschaftendefinitionsfunktionen von ES5, wie z. B. defineProperty(..)
, um Mutationen an Objekten zu deaktivieren. Als solches ist es vollständig kompatibel mit bestehenden Bibliotheken wie Lodash und Ramda. Es kann auch in Produktions-Builds deaktiviert werden, was einen potenziell erheblichen Leistungsgewinn bietet.
Vorteile:
- Funktioniert nahtlos mit bestehenden JS-Bibliotheken (z. B. Lodash, Ramda)
- Kein zusätzlicher Code erforderlich, um die Konvertierung zu unterstützen
- Überprüfungen können in Produktions-Builds deaktiviert werden, um die Leistung zu steigern
Nachteile:
- Keine strukturelle gemeinsame Nutzung - Objekte / Arrays werden flach kopiert, was es bei großen Datensätzen langsamer macht
- Nicht so speichereffizient
Redux und Multiple Reducer
Eine weitere nützliche Funktion von Redux ist die Möglichkeit, Reducer zusammen zu komponieren. Dies ermöglicht Ihnen, viel kompliziertere Anwendungen zu erstellen, und in einer Anwendung von nennenswerter Größe werden Sie unweigerlich mehrere Statustypen haben (aktueller Benutzer, die Liste der geladenen Posts, etc). Redux unterstützt (und fördert) diesen Anwendungsfall, indem es natürlich die Funktion combineReducers
:
import { combineReducers } from 'redux' import currentUserReducer from './currentUserReducer' import postsListReducer from './postsListReducer' export default combineReducers({ currentUser: currentUserReducer, postsList: postsListReducer, })
Mit dem obigen Code können Sie eine Komponente haben, die sich auf den aktuellen Benutzer stützt, und eine andere Komponente, die sich auf die currentUser
postsList
. Dies verbessert auch die Leistung, da jede einzelne Komponente nur die Zweige des Baums abonniert, die sie betreffen.
Unreine Aktionen in Redux
Standardmäßig können Sie nur einfache JavaScript-Objekte an Redux senden. Mit Middleware kann Redux jedoch unreine Aktionen unterstützen, wie z. B. das Abrufen der aktuellen Uhrzeit, das Durchführen einer Netzwerkanforderung, das Schreiben einer Datei auf die Festplatte und so weiter.
„Middleware“ ist der Begriff für Funktionen, die gesendete Aktionen abfangen können. Einmal abgefangen, kann es Dinge tun wie die Aktion transformieren oder eine asynchrone Aktion auslösen, ähnlich wie Middleware in anderen Frameworks (wie Express.js).
Zwei sehr verbreitete Middleware-Bibliotheken sind Redux Thunk und Redux-Saga. Redux Thunk ist in einem imperativen Stil geschrieben, während Redux-saga in einem funktionalen Stil geschrieben ist. Vergleichen wir beide.

Redux-Think
Redux Thunk unterstützt unreine Aktionen innerhalb von Redux durch die Verwendung von Thunks, Funktionen, die andere verkettbare Funktionen zurückgeben. Um Redux-Thunk zu verwenden, müssen Sie zuerst die Redux-Thunk-Middleware im Store mounten:
import { createStore, applyMiddleware } from 'redux' import thunk from 'redux-thunk' const store = createStore( myRootReducer, applyMiddleware(thunk), // here, we apply the thunk middleware to R )
Jetzt können wir unreine Aktionen ausführen (z. B. das Ausführen eines API-Aufrufs), indem wir einen Thunk an den Redux-Speicher senden:
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 } )
Es ist wichtig zu beachten, dass die Verwendung von Thunks das Testen Ihres Codes erschweren kann und es schwieriger macht, durch den Codefluss zu argumentieren.
Redux-Saga
Redux-Saga unterstützt unreine Aktionen durch eine ES6 (ES2015)-Funktion namens Generatoren und eine Bibliothek mit funktionalen/reinen Helfern. Das Tolle an Generatoren ist, dass sie fortgesetzt und angehalten werden können, und ihr API-Vertrag macht sie extrem einfach zu testen.
Sehen wir uns an, wie wir die Lesbarkeit und Testbarkeit der vorherigen Thunk-Methode mithilfe von Sagas verbessern können!
Lassen Sie uns zuerst die Redux-Saga-Middleware in unserem Store mounten:
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)
Beachten Sie, dass die Funktion run(..)
mit der Saga aufgerufen werden muss, damit sie mit der Ausführung beginnt.
Lassen Sie uns jetzt unsere Saga erstellen:
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) }
Wir haben zwei Generatorfunktionen definiert, eine zum Abrufen der Benutzerliste und die rootSaga
. Beachten Sie, dass wir api.fetchUsers
nicht direkt aufgerufen, sondern stattdessen in einem Aufrufobjekt zurückgegeben haben. Dies liegt daran, dass Redux-Saga das Aufrufobjekt abfängt und die darin enthaltene Funktion ausführt, um eine reine Umgebung zu erstellen (soweit Ihre Generatoren betroffen sind).
rootSaga
liefert einen einzelnen Aufruf an eine Funktion namens takeEvery,
die jede Aktion übernimmt, die mit einem Typ von USERS_FETCH
, und die fetchUsers
Saga mit der ausgeführten Aktion aufruft. Wie wir sehen können, erzeugt dies ein sehr vorhersehbares Nebenwirkungsmodell für Redux, wodurch es einfach zu testen ist!
Sagen testen
Mal sehen, wie Generatoren unsere Sagas einfach zu testen machen. Wir werden in diesem Teil mocha verwenden, um unsere Unit-Tests auszuführen, und chai für Behauptungen.
Da Sagas einfache JavaScript-Objekte liefern und innerhalb eines Generators ausgeführt werden, können wir ganz einfach testen, ob sie das richtige Verhalten zeigen, ohne jegliche Mocks! Denken Sie daran, dass call
, take
, put
usw. nur einfache JavaScript-Objekte sind, die von der Middleware der Redux-Saga abgefangen werden.
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)) }) })
Arbeiten mit React
Obwohl Redux nicht an eine bestimmte Begleitbibliothek gebunden ist, funktioniert es besonders gut mit React.js, da React-Komponenten reine Funktionen sind, die einen Zustand als Eingabe annehmen und ein virtuelles DOM als Ausgabe erzeugen.
React-Redux ist eine Hilfsbibliothek für React und Redux, die den größten Teil der harten Arbeit beim Verbinden der beiden eliminiert. Um React-Redux am effektivsten zu nutzen, lassen Sie uns den Begriff der Präsentationskomponenten und Containerkomponenten durchgehen.
Präsentationskomponenten beschreiben, wie die Dinge visuell aussehen sollen, abhängig nur von ihren zu rendernden Requisiten; Sie rufen Rückrufe von Requisiten auf, um Aktionen auszuführen. Sie sind von Hand geschrieben, völlig rein und nicht an Zustandsverwaltungssysteme wie Redux gebunden.
Container-Komponenten hingegen beschreiben, wie Dinge funktionieren sollen, kennen Redux, senden Redux-Aktionen direkt, um Mutationen durchzuführen, und werden im Allgemeinen von React-Redux generiert. Sie werden oft mit einer Präsentationskomponente gepaart, die ihre Requisiten liefert.
Lassen Sie uns eine Präsentationskomponente schreiben und sie über React-Redux mit Redux verbinden:
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, }
Beachten Sie, dass dies eine „dumme“ Komponente ist, die sich vollständig auf ihre Requisiten verlässt, um zu funktionieren. Das ist großartig, weil es die React-Komponente einfach zu testen und einfach zu komponieren macht. Schauen wir uns jetzt an, wie diese Komponente mit Redux verbunden wird, aber zuerst wollen wir uns damit befassen, was eine Komponente höherer Ordnung ist.
Komponenten höherer Ordnung
React-Redux bietet eine Hilfsfunktion namens connect( .. )
, die eine Komponente höherer Ordnung aus einer „dummen“ React-Komponente erstellt, die Redux kennt.
React betont die Erweiterbarkeit und Wiederverwendbarkeit durch Komposition, d. h. wenn Sie Komponenten in andere Komponenten einbetten. Das Umschließen dieser Komponenten kann ihr Verhalten ändern oder neue Funktionen hinzufügen. Mal sehen, wie wir aus unserer Präsentationskomponente eine Komponente höherer Ordnung erstellen können, die Redux kennt - eine Containerkomponente.
So geht's:
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
Beachten Sie, dass wir zwei Funktionen definiert haben, mapStateToProps
und mapDispatchToProps
.
mapStateToProps
ist eine reine Funktion von (Zustand: Objekt), die ein aus dem Redux-Zustand berechnetes Objekt zurückgibt. Dieses Objekt wird mit den Requisiten zusammengeführt, die an die verpackte Komponente übergeben werden. Dies wird auch als Selektor bezeichnet, da es Teile des Redux-Zustands auswählt, die mit den Requisiten der Komponente zusammengeführt werden sollen.
mapDispatchToProps
ist ebenfalls eine reine Funktion, aber eine von (dispatch: (Action) => void), die ein von der Redux-Dispatch-Funktion berechnetes Objekt zurückgibt. Dieses Objekt wird ebenfalls mit den Requisiten zusammengeführt, die an die umschlossene Komponente übergeben werden.
Um nun unsere Container-Komponente zu verwenden, müssen wir die Provider
-Komponente in React-Redux verwenden, um der Container-Komponente mitzuteilen, welcher Store verwendet werden soll:
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') )
Die Provider
-Komponente gibt den Speicher an alle untergeordneten Komponenten weiter, die den Redux-Speicher abonnieren, wodurch alles an einem Ort bleibt und Fehler- oder Mutationspunkte reduziert werden!
Bauen Sie Code-Vertrauen mit Redux auf
Mit diesem neu gewonnenen Wissen über Redux, seinen zahlreichen unterstützenden Bibliotheken und seiner Framework-Verbindung mit React.js können Sie die Anzahl der Mutationen in Ihrer Anwendung durch Zustandskontrolle leicht begrenzen. Eine starke Zustandskontrolle wiederum ermöglicht es Ihnen, sich schneller zu bewegen und eine solide Codebasis mit mehr Vertrauen zu erstellen.