Immutabilità in JavaScript usando Redux
Pubblicato: 2022-03-11In un ecosistema in continua crescita di applicazioni JavaScript ricche e complicate, c'è più stato da gestire che mai: l'utente corrente, l'elenco dei post caricati, ecc.
Qualsiasi insieme di dati che necessita di una cronologia degli eventi può essere considerato con stato. La gestione dello stato può essere difficile e soggetta a errori, ma lavorare con dati immutabili (piuttosto che mutabili) e alcune tecnologie di supporto, vale a dire Redux, ai fini di questo articolo, può aiutare in modo significativo.
I dati immutabili hanno delle restrizioni, vale a dire che non possono essere modificati una volta creati, ma hanno anche molti vantaggi, in particolare nell'uguaglianza di riferimento rispetto a valore, che può velocizzare notevolmente le applicazioni che si basano sul confronto frequente dei dati (verificando se è necessario aggiornare qualcosa , Per esempio).
L'uso di stati immutabili ci consente di scrivere codice in grado di dire rapidamente se lo stato è cambiato, senza la necessità di fare un confronto ricorsivo sui dati, che di solito è molto, molto più veloce.
Questo articolo tratterà le applicazioni pratiche di Redux nella gestione dello stato tramite creatori di azioni, funzioni pure, riduttori composti, azioni impure con Redux-saga e Redux Thunk e, infine, l'uso di Redux con React. Detto questo, ci sono molte alternative a Redux, come le librerie basate su MobX, Relay e Flux.
Perché Redux?
L'aspetto chiave che separa Redux dalla maggior parte degli altri contenitori di stato come MobX, Relay e la maggior parte delle altre implementazioni basate su Flux è che Redux ha un singolo stato che può essere modificato solo tramite "azioni" (oggetti JavaScript semplici), che vengono inviati al Negozio Redux. La maggior parte degli altri archivi dati ha lo stato contenuto nei componenti React stessi, consente di avere più archivi e/o utilizzare lo stato mutabile.
Questo a sua volta fa sì che il riduttore del negozio, una funzione pura che opera su dati immutabili, esegua e potenzialmente aggiorni lo stato. Questo processo impone un flusso di dati unidirezionale, più facile da capire e più deterministico.
Poiché i riduttori Redux sono funzioni pure che operano su dati immutabili, producono sempre lo stesso output con lo stesso input, rendendoli facili da testare. Ecco un esempio di riduttore:
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
La gestione di funzioni pure consente a Redux di supportare facilmente molti casi d'uso che generalmente non sono facilmente eseguibili con lo stato mutativo, come ad esempio:
- Viaggio nel tempo (tornare indietro nel tempo a uno stato precedente)
- Registrazione (Traccia ogni singola azione per capire cosa ha causato una mutazione nel negozio)
- Ambienti collaborativi (come GoogleDocs, in cui le azioni sono semplici oggetti JavaScript e possono essere serializzate, inviate via cavo e riprodotte su un'altra macchina)
- Facile segnalazione dei bug (basta inviare l'elenco delle azioni inviate e riprodurle per ottenere lo stesso identico stato)
- Rendering ottimizzato (almeno nei framework che rendono il DOM virtuale in funzione dello stato, come React: a causa dell'immutabilità, puoi facilmente capire se qualcosa è cambiato confrontando i riferimenti, invece di confrontare ricorsivamente gli oggetti)
- Testa facilmente i tuoi riduttori, poiché le funzioni pure possono essere facilmente testate in unità
Creatori di azioni
I creatori di azioni di Redux aiutano a mantenere il codice pulito e testabile. Ricorda che le "azioni" in Redux non sono altro che semplici oggetti JavaScript che descrivono una mutazione che dovrebbe verificarsi. Detto questo, scrivere gli stessi oggetti più e più volte è ripetitivo e soggetto a errori.
Un creatore di azioni in Redux è semplicemente una funzione di supporto che restituisce un semplice oggetto JavaScript che descrive una mutazione. Questo aiuta a ridurre il codice ripetitivo e mantiene tutte le tue azioni in un unico posto:
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 }
Utilizzo di Redux con librerie immutabili
Sebbene la natura stessa dei riduttori e delle azioni li renda facili da testare, senza una libreria di supporto per l'immutabilità, non c'è nulla che ti protegga dalla mutazione degli oggetti, il che significa che i test per tutti i tuoi riduttori devono essere particolarmente robusti.
Considera il seguente esempio di codice di un problema che incontrerai senza una libreria per proteggerti:
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 questo esempio di codice, il viaggio nel tempo verrà interrotto poiché lo stato precedente ora sarà lo stesso dello stato corrente, i componenti puri potrebbero potenzialmente non aggiornarsi (o rieseguire il rendering) poiché il riferimento allo stato non è cambiato anche se i dati in esso contenuti contiene è cambiato e le mutazioni sono molto più difficili da ragionare.
Senza una libreria di immutabilità, perdiamo tutti i vantaggi offerti da Redux. Si consiglia pertanto di utilizzare una libreria di supporto per l'immutabilità, come immutable.js o seamless-immutable, soprattutto quando si lavora in un team di grandi dimensioni con più mani che toccano il codice.
Indipendentemente dalla libreria che utilizzi, Redux si comporterà allo stesso modo. Confrontiamo i pro e i contro di entrambi in modo da poter scegliere quello più adatto al tuo caso d'uso:
Immutabile.js
Immutable.js è una libreria, creata da Facebook, con uno stile più funzionale su strutture di dati, come Mappe, Elenchi, Insiemi e Sequenze. La sua libreria di strutture di dati persistenti immutabili esegue la minor quantità di copie possibile tra stati diversi.
Professionisti:
- Condivisione strutturale
- Più efficiente negli aggiornamenti
- Memoria più efficiente
- Ha una suite di metodi di supporto per gestire gli aggiornamenti
Contro:
- Non funziona perfettamente con le librerie JS esistenti (es. lodash, ramda)
- Richiede la conversione da e verso (toJS / fromJS), soprattutto durante l'idratazione/disidratazione e il rendering
Senza soluzione di continuità immutabile
Seamless-immutable è una libreria per dati immutabili che è retrocompatibile fino a ES5.
Si basa sulle funzioni di definizione delle proprietà ES5, come defineProperty(..)
per disabilitare le mutazioni sugli oggetti. In quanto tale, è completamente compatibile con le librerie esistenti come lodash e Ramda. Può anche essere disabilitato nelle build di produzione, fornendo un aumento delle prestazioni potenzialmente significativo.
Professionisti:
- Funziona perfettamente con le librerie JS esistenti (es. lodash, ramda)
- Nessun codice aggiuntivo necessario per supportare la conversione
- I controlli possono essere disabilitati nelle build di produzione, aumentando le prestazioni
Contro:
- Nessuna condivisione strutturale: gli oggetti/array vengono copiati in modo superficiale, rendendolo più lento per set di dati di grandi dimensioni
- Non così efficiente in termini di memoria
Redux e riduttori multipli
Un'altra caratteristica utile di Redux è la possibilità di comporre i riduttori insieme. Ciò consente di creare applicazioni molto più complicate e, in un'applicazione di qualsiasi dimensione apprezzabile, si avranno inevitabilmente più tipi di stato (utente corrente, l'elenco dei post caricati, eccetera). Redux supporta (e incoraggia) questo caso d'uso fornendo naturalmente la funzione combineReducers
:
import { combineReducers } from 'redux' import currentUserReducer from './currentUserReducer' import postsListReducer from './postsListReducer' export default combineReducers({ currentUser: currentUserReducer, postsList: postsListReducer, })
Con il codice sopra, puoi avere un componente che si basa su currentUser
e un altro componente che si basa su postsList
. Ciò migliora anche le prestazioni poiché ogni singolo componente si iscriverà solo a qualsiasi ramo dell'albero lo riguardi.
Azioni impure in Redux
Per impostazione predefinita, puoi inviare a Redux solo oggetti JavaScript semplici. Con il middleware, tuttavia, Redux può supportare azioni impure come ottenere l'ora corrente, eseguire una richiesta di rete, scrivere un file su disco e così via.
'Middleware' è il termine utilizzato per le funzioni che possono intercettare le azioni inviate. Una volta intercettato, può eseguire operazioni come trasformare l'azione o inviare un'azione asincrona, proprio come il middleware in altri framework (come Express.js).
Due librerie middleware molto comuni sono Redux Thunk e Redux-saga. Redux Thunk è scritto in uno stile imperativo, mentre Redux-saga è scritto in uno stile funzionale. Confrontiamo entrambi.

Redux Thunk
Redux Thunk supporta azioni impure all'interno di Redux utilizzando thunk, funzioni che restituiscono altre funzioni concatenabili. Per utilizzare Redux-Thunk, devi prima montare il middleware Redux Thunk nello store:
import { createStore, applyMiddleware } from 'redux' import thunk from 'redux-thunk' const store = createStore( myRootReducer, applyMiddleware(thunk), // here, we apply the thunk middleware to R )
Ora possiamo eseguire azioni impure (come eseguire una chiamata API) inviando un thunk al negozio 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 } )
È importante notare che l'uso di thunk può rendere difficile il test del codice e rendere più difficile ragionare attraverso il flusso del codice.
Redux-saga
Redux-saga supporta azioni impure attraverso una funzionalità ES6 (ES2015) chiamata generatori e una libreria di helper funzionali/puri. Il bello dei generatori è che possono essere ripristinati e messi in pausa e il loro contratto API li rende estremamente facili da testare.
Vediamo come possiamo migliorare la leggibilità e la testabilità del precedente metodo thunk usando le saghe!
Innanzitutto, montiamo il middleware Redux-saga nel nostro negozio:
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)
Si noti che la run(..)
deve essere chiamata con la saga per iniziare l'esecuzione.
Ora creiamo la nostra 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) }
Abbiamo definito due funzioni del generatore, una che recupera l'elenco degli utenti e la rootSaga
. Si noti che non abbiamo chiamato direttamente api.fetchUsers
ma lo abbiamo invece restituito in un oggetto call. Questo perché Redux-saga intercetta l'oggetto chiamata ed esegue la funzione contenuta al suo interno per creare un ambiente puro (per quanto riguarda i generatori).
rootSaga
restituisce una singola chiamata a una funzione chiamata takeEvery,
che accetta ogni azione inviata con un tipo di USERS_FETCH
e chiama la saga fetchUsers
con l'azione eseguita. Come possiamo vedere, questo crea un modello di effetti collaterali molto prevedibile per Redux, che lo rende facile da testare!
Saghe di prova
Vediamo come i generatori rendono le nostre saghe facili da testare. Useremo moka in questa parte per eseguire i nostri unit test e chai per le asserzioni.
Poiché le saghe producono semplici oggetti JavaScript e vengono eseguite all'interno di un generatore, possiamo facilmente verificare che eseguano il comportamento corretto senza alcuna presa in giro! Tieni presente che call
, take
, put
, etc sono solo semplici oggetti JavaScript che vengono intercettati dal middleware 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)) }) })
Lavorare con Reagire
Sebbene Redux non sia legato a nessuna libreria complementare specifica, funziona particolarmente bene con React.js poiché i componenti di React sono funzioni pure che prendono uno stato come input e producono un DOM virtuale come output.
React-Redux è una libreria di supporto per React e Redux che elimina la maggior parte del duro lavoro che collega i due. Per utilizzare in modo più efficace React-Redux, esaminiamo la nozione di componenti di presentazione e componenti di contenitore.
I componenti di presentazione descrivono come dovrebbero apparire le cose visivamente, a seconda esclusivamente degli oggetti di scena da renderizzare; invocano callback dagli oggetti di scena per inviare azioni. Sono scritti a mano, completamente puri e non sono legati a sistemi di gestione dello stato come Redux.
I componenti del contenitore, d'altra parte, descrivono come dovrebbero funzionare le cose, sono a conoscenza di Redux, inviano azioni Redux direttamente per eseguire le mutazioni e sono generalmente generati da React-Redux. Sono spesso abbinati a una componente di presentazione, fornendo i suoi oggetti di scena.
Scriviamo un componente di presentazione e colleghiamolo a Redux tramite 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, }
Nota che questo è un componente "stupido" che si basa completamente sui suoi supporti per funzionare. Questo è fantastico, perché rende il componente React facile da testare e facile da comporre. Diamo un'occhiata a come collegare questo componente a Redux ora, ma prima analizziamo cos'è un componente di ordine superiore.
Componenti di ordine superiore
React-Redux fornisce una funzione di supporto chiamata connect( .. )
che crea un componente di ordine superiore da un componente React "stupido" che è a conoscenza di Redux.
React enfatizza l'estendibilità e la riutilizzabilità attraverso la composizione, ovvero quando si avvolgono i componenti in altri componenti. Il wrapping di questi componenti può modificarne il comportamento o aggiungere nuove funzionalità. Vediamo come possiamo creare un componente di ordine superiore dal nostro componente di presentazione che sia a conoscenza di Redux, un componente contenitore.
Ecco come lo fai:
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
Si noti che abbiamo definito due funzioni, mapStateToProps
e mapDispatchToProps
.
mapStateToProps
è una pura funzione di (state: Object) che restituisce un oggetto calcolato dallo stato Redux. Questo oggetto verrà unito agli oggetti di scena passati al componente avvolto. Questo è anche noto come selettore, poiché seleziona parti dello stato Redux da unire agli oggetti di scena del componente.
mapDispatchToProps
è anche una funzione pura, ma una di (dispatch: (Action) => void) che restituisce un oggetto calcolato dalla funzione di invio di Redux. Anche questo oggetto verrà unito agli oggetti di scena passati al componente avvolto.
Ora per utilizzare il nostro componente contenitore dobbiamo usare il componente Provider
in React-Redux per dire al componente contenitore quale negozio usare:
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') )
Il componente Provider
propaga il negozio a tutti i componenti figlio che si iscrivono al negozio Redux, mantenendo tutto in un unico posto e riducendo i punti di errore o mutazione!
Costruisci fiducia nel codice con Redux
Con questa nuova conoscenza di Redux, delle sue numerose librerie di supporto e della sua connessione al framework con React.js, puoi facilmente limitare il numero di mutazioni nella tua applicazione attraverso il controllo dello stato. Il controllo dello stato forte, a sua volta, ti consente di muoverti più velocemente e di creare una solida base di codice con maggiore sicurezza.