Imuabilitate în JavaScript folosind Redux
Publicat: 2022-03-11Într-un ecosistem în continuă creștere de aplicații JavaScript bogate și complicate, există mai multe stări de gestionat decât oricând: utilizatorul actual, lista de postări încărcate etc.
Orice set de date care necesită un istoric al evenimentelor poate fi considerat cu stare. Gestionarea stării poate fi dificilă și predispusă la erori, dar lucrul cu date imuabile (mai degrabă decât mutabile) și anumite tehnologii de suport - și anume Redux, în scopul acestui articol - poate ajuta semnificativ.
Datele imuabile au restricții, și anume că nu pot fi modificate odată ce sunt create, dar au și multe beneficii, în special în ceea ce privește egalitatea de referință versus valoare, care poate accelera foarte mult aplicațiile care se bazează pe compararea frecventă a datelor (verificarea dacă ceva trebuie actualizat , de exemplu).
Folosirea stărilor imuabile ne permite să scriem cod care poate spune rapid dacă starea s-a schimbat, fără a fi nevoie să facem o comparație recursivă a datelor, care este de obicei mult, mult mai rapidă.
Acest articol va acoperi aplicațiile practice ale Redux atunci când gestionați starea prin creatori de acțiuni, funcții pure, reductoare compuse, acțiuni impure cu Redux-saga și Redux Thunk și, în sfârșit, utilizarea Redux cu React. Acestea fiind spuse, există o mulțime de alternative la Redux, cum ar fi bibliotecile bazate pe MobX, Relay și Flux.
De ce Redux?
Aspectul cheie care separă Redux de majoritatea altor containere de stat, cum ar fi MobX, Relay și majoritatea altor implementări bazate pe Flux este că Redux are o singură stare care poate fi modificată doar prin „acțiuni” (obiecte JavaScript simple), care sunt trimise către Magazin Redux. Majoritatea celorlalte depozite de date au starea conținută în componentele React, vă permit să aveți mai multe magazine și/sau să utilizați starea mutabilă.
Acest lucru determină, la rândul său, reductorul magazinului, o funcție pură care operează pe date imuabile, să execute și să actualizeze potențial starea. Acest proces impune fluxul de date unidirecțional, care este mai ușor de înțeles și mai determinist.
Deoarece reductoarele Redux sunt funcții pure care funcționează pe date imuabile, ele produc întotdeauna aceeași ieșire având aceeași intrare, făcându-le ușor de testat. Iată un exemplu de reductor:
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
Operarea cu funcții pure permite Redux să suporte cu ușurință multe cazuri de utilizare care, în general, nu sunt ușor de realizat cu starea mutativă, cum ar fi:
- Călătoria în timp (întoarcerea în timp la o stare anterioară)
- Înregistrare (urmăriți fiecare acțiune pentru a afla ce a cauzat o mutație în magazin)
- Medii de colaborare (cum ar fi GoogleDocs, unde acțiunile sunt obiecte JavaScript simple și pot fi serializate, trimise prin cablu și redate pe o altă mașină)
- Raportare simplă a erorilor (doar trimiteți lista acțiunilor trimise și reluați-le pentru a obține exact aceeași stare)
- Redare optimizată (cel puțin în cadrele care redau DOM virtual în funcție de stare, cum ar fi React: datorită imuabilității, puteți spune cu ușurință dacă ceva s-a schimbat prin compararea referințelor, spre deosebire de compararea recursivă a obiectelor)
- Testați cu ușurință reductoarele, deoarece funcțiile pure pot fi testate cu ușurință în unitate
Creatori de acțiune
Creatorii de acțiuni Redux ajută la menținerea codului curat și testabil. Amintiți-vă că „acțiunile” din Redux nu sunt altceva decât obiecte JavaScript simple care descriu o mutație care ar trebui să apară. Acestea fiind spuse, scrierea acelorași obiecte din nou și din nou este repetitivă și predispusă la erori.
Un creator de acțiuni în Redux este pur și simplu o funcție de ajutor care returnează un obiect JavaScript simplu care descrie o mutație. Acest lucru ajută la reducerea codului repetitiv și vă păstrează toate acțiunile într-un singur loc:
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 }
Utilizarea Redux cu biblioteci imuabile
În timp ce însăși natura reductoarelor și acțiunilor le face ușor de testat, fără o bibliotecă de ajutor de imuabilitate, nimic nu vă protejează de obiectele mutante, ceea ce înseamnă că testele pentru toate reductoarele dvs. trebuie să fie deosebit de robuste.
Luați în considerare următorul exemplu de cod al unei probleme cu care vă veți întâlni fără o bibliotecă care să vă protejeze:
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 }
În acest exemplu de cod, călătoria în timp va fi întreruptă, deoarece starea anterioară va fi acum aceeași cu starea curentă, componentele pure ar putea să nu se actualizeze (sau să nu se redeze din nou), deoarece referința la stare nu s-a schimbat chiar dacă datele pe care le-au conține s-a schimbat, iar mutațiile sunt mult mai greu de explicat.
Fără o bibliotecă de imuabilitate, pierdem toate beneficiile pe care le oferă Redux. Prin urmare, este foarte recomandat să utilizați o bibliotecă de ajutor pentru imutabilitate, cum ar fi immutable.js sau seamless-immutable, mai ales atunci când lucrați într-o echipă mare cu mai multe mâini care ating codul.
Indiferent de bibliotecă pe care o utilizați, Redux se va comporta la fel. Să comparăm avantajele și dezavantajele ambelor, astfel încât să puteți alege cea mai potrivită pentru cazul dvs. de utilizare:
Imuabil.js
Immutable.js este o bibliotecă, construită de Facebook, cu un stil mai funcțional pentru structurile de date, cum ar fi hărți, liste, seturi și secvențe. Biblioteca sa de structuri de date persistente imuabile realizează cea mai mică cantitate de copiere posibilă între diferite stări.
Pro:
- Partajarea structurală
- Mai eficient la actualizări
- Memoria mai eficientă
- Are o suită de metode de ajutor pentru a gestiona actualizările
Contra:
- Nu funcționează perfect cu bibliotecile JS existente (adică lodash, ramda)
- Necesită conversie la și de la (la JS / de la JS), în special în timpul hidratării / deshidratării și redării
Perfect-imuabil
Seamless-immutable este o bibliotecă pentru date imuabile care este compatibilă înapoi până la ES5.
Se bazează pe funcții de definire a proprietăților ES5, cum ar fi defineProperty(..)
pentru a dezactiva mutațiile pe obiecte. Ca atare, este pe deplin compatibil cu bibliotecile existente precum lodash și Ramda. De asemenea, poate fi dezactivat în versiunile de producție, oferind un potențial câștig semnificativ de performanță.
Pro:
- Funcționează perfect cu bibliotecile JS existente (adică lodash, ramda)
- Nu este nevoie de cod suplimentar pentru a sprijini conversia
- Verificările pot fi dezactivate în versiunile de producție, crescând performanța
Contra:
- Fără partajare structurală - obiectele/matricele sunt copiate la mică adâncime, ceea ce face mai lent pentru seturi mari de date
- Nu la fel de eficient în memorie
Redux și reductoare multiple
O altă caracteristică utilă a Redux este capacitatea de a compune reductoare împreună. Acest lucru vă permite să creați aplicații mult mai complicate, iar într-o aplicație de orice dimensiune apreciabilă, veți avea inevitabil mai multe tipuri de stare (utilizator curent, lista de postări încărcate, etc). Redux sprijină (și încurajează) acest caz de utilizare, oferind în mod natural funcția combineReducers
:
import { combineReducers } from 'redux' import currentUserReducer from './currentUserReducer' import postsListReducer from './postsListReducer' export default combineReducers({ currentUser: currentUserReducer, postsList: postsListReducer, })
Cu codul de mai sus, puteți avea o componentă care se bazează pe currentUser
și o altă componentă care se bazează pe postsList
. Acest lucru îmbunătățește, de asemenea, performanța, deoarece orice componentă se va abona doar la orice ramuri a arborelui care o privește.
Acțiuni impure în Redux
În mod implicit, puteți trimite doar obiecte JavaScript simple către Redux. Cu middleware, totuși, Redux poate suporta acțiuni impure, cum ar fi obținerea orei curente, efectuarea unei solicitări de rețea, scrierea unui fișier pe disc și așa mai departe.
„Middleware” este termenul folosit pentru funcțiile care pot intercepta acțiunile trimise. Odată interceptat, poate face lucruri precum transformarea acțiunii sau trimiterea unei acțiuni asincrone, la fel ca middleware-ul în alte cadre (cum ar fi Express.js).
Două biblioteci middleware foarte comune sunt Redux Thunk și Redux-saga. Redux Thunk este scris într-un stil imperativ, în timp ce Redux-saga este scris într-un stil funcțional. Să le comparăm pe amândouă.

Redux Thunk
Redux Thunk acceptă acțiunile impure din Redux prin utilizarea thunks, funcții care returnează alte funcții care pot fi înlănțuite. Pentru a utiliza Redux-Thunk, mai întâi trebuie să montați middleware-ul Redux Thunk în magazin:
import { createStore, applyMiddleware } from 'redux' import thunk from 'redux-thunk' const store = createStore( myRootReducer, applyMiddleware(thunk), // here, we apply the thunk middleware to R )
Acum putem efectua acțiuni impure (cum ar fi efectuarea unui apel API) trimițând un mesaj către magazinul 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 } )
Este important să rețineți că utilizarea thunks poate face codul greu de testat și îngreunează raționarea prin fluxul de cod.
Redux-saga
Redux-saga acceptă acțiuni impure printr-o caracteristică ES6 (ES2015) numită generatoare și o bibliotecă de ajutoare funcționale/pure. Lucrul grozav despre generatoare este că pot fi reluate și întrerupte, iar contractul lor API le face extrem de ușor de testat.
Să vedem cum putem îmbunătăți lizibilitatea și testabilitatea metodei thunk anterioare folosind saga!
Mai întâi, să instalăm middleware-ul Redux-saga în magazinul nostru:
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)
Rețineți că funcția run(..)
trebuie apelată împreună cu saga pentru ca aceasta să înceapă executarea.
Acum să ne creăm saga:
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) }
Am definit două funcții generatoare, una care preia lista de utilizatori și rootSaga
. Observați că nu am apelat direct api.fetchUsers
, ci l-am dat într-un obiect de apel. Acest lucru se datorează faptului că Redux-saga interceptează obiectul de apel și execută funcția conținută în interior pentru a crea un mediu pur (în ceea ce privește generatoarele dvs.).
rootSaga
dă un singur apel la o funcție numită takeEvery,
care efectuează fiecare acțiune trimisă cu un tip de USERS_FETCH
și apelează saga fetchUsers
cu acțiunea pe care a întreprins-o. După cum putem vedea, acest lucru creează un model de efecte secundare foarte previzibil pentru Redux, ceea ce îl face ușor de testat!
Sagas de testare
Să vedem cum generatoarele fac saga noastră ușor de testat. Vom folosi mocha în această parte pentru a rula testele noastre unitare și chai pentru afirmații.
Deoarece saga generează obiecte JavaScript simple și sunt rulate într-un generator, putem testa cu ușurință dacă au comportamentul corect, fără nicio batjocură! Rețineți că call
, take
, put
etc. sunt doar obiecte JavaScript simple care sunt interceptate de middleware-ul 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)) }) })
Lucrul cu React
Deși Redux nu este legat de nicio bibliotecă însoțitoare specifică, funcționează mai ales bine cu React.js, deoarece componentele React sunt funcții pure care iau o stare ca intrare și produc un DOM virtual ca ieșire.
React-Redux este o bibliotecă de ajutor pentru React și Redux care elimină cea mai mare parte din munca grea care leagă cele două. Pentru a utiliza cel mai eficient React-Redux, să trecem peste noțiunea de componente de prezentare și componente de container.
Componentele de prezentare descriu modul în care lucrurile ar trebui să arate vizual, în funcție doar de elementele de recuzită care trebuie redate; ei invocă apeluri de la recuzită pentru acțiunile de expediere. Sunt scrise de mână, complet pure și nu sunt legate de sisteme de management de stat precum Redux.
Componentele containerului, pe de altă parte, descriu cum ar trebui să funcționeze lucrurile, sunt la curent cu Redux, trimit direct acțiuni Redux pentru a efectua mutații și sunt în general generate de React-Redux. Ele sunt adesea asociate cu o componentă de prezentare, oferind recuzita acesteia.
Să scriem o componentă de prezentare și să o conectăm la Redux prin 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, }
Rețineți că aceasta este o componentă „prost” care se bazează complet pe recuzita pentru a funcționa. Acest lucru este grozav, deoarece face componenta React ușor de testat și ușor de compus. Să ne uităm la cum să conectăm această componentă la Redux acum, dar mai întâi să discutăm ce este o componentă de ordin superior.
Componente de ordin superior
React-Redux oferă o funcție de ajutor numită connect( .. )
care creează o componentă de ordin superior dintr-o componentă React „proastă” care cunoaște Redux.
React subliniază extensibilitatea și reutilizarea prin compoziție, care este atunci când înfășurați componentele în alte componente. Împachetarea acestor componente le poate schimba comportamentul sau adăuga funcționalități noi. Să vedem cum putem crea o componentă de ordin superior din componenta noastră de prezentare care este conștientă de Redux - o componentă container.
Iată cum o faci:
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
Rețineți că am definit două funcții, mapStateToProps
și mapDispatchToProps
.
mapStateToProps
este o funcție pură a (state: Object) care returnează un obiect calculat din starea Redux. Acest obiect va fi îmbinat cu elementele de recuzită transmise componentului înfășurat. Acesta este cunoscut și sub numele de selector, deoarece selectează părți din starea Redux pentru a fi îmbinate în elementele de recuzită ale componentei.
mapDispatchToProps
este, de asemenea, o funcție pură, dar una dintre (dispatch: (Action) => void) care returnează un obiect calculat din funcția de expediere Redux. Acest obiect va fi, de asemenea, îmbinat cu elementele de recuzită transmise componentului înfășurat.
Acum, pentru a folosi componenta containerului, trebuie să folosim componenta Provider
din React-Redux pentru a spune componentului container ce magazin să folosească:
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') )
Componenta Provider
propagă magazinul până la orice componente copil care se abonează la magazinul Redux, păstrând totul într-un singur loc și reducând punctele de eroare sau mutație!
Creați încredere în cod cu Redux
Cu aceste noi cunoștințe despre Redux, numeroasele biblioteci de suport și conexiunea sa cadru cu React.js, puteți limita cu ușurință numărul de mutații din aplicația dvs. prin controlul de stat. Controlul puternic al stării, la rândul său, vă permite să vă mișcați mai rapid și să creați o bază de cod solidă cu mai multă încredere.