Migliori riduttori con Immer
Pubblicato: 2022-03-10Come sviluppatore React, dovresti già avere familiarità con il principio che lo stato non dovrebbe essere mutato direttamente. Ti starai chiedendo cosa significhi (la maggior parte di noi aveva quella confusione quando abbiamo iniziato).
Questo tutorial renderà giustizia a questo: capirai cos'è lo stato immutabile e la necessità di esso. Imparerai anche come utilizzare Immer per lavorare con lo stato immutabile e i vantaggi del suo utilizzo. Puoi trovare il codice in questo articolo in questo repository Github.
Immutabilità in JavaScript e perché è importante
Immer.js è una piccola libreria JavaScript è stata scritta da Michel Weststrate la cui missione dichiarata è consentire di "lavorare con lo stato immutabile in un modo più conveniente".
Ma prima di immergerci in Immer, facciamo un rapido aggiornamento sull'immutabilità in JavaScript e sul perché è importante in un'applicazione React.
L'ultimo standard ECMAScript (aka JavaScript) definisce nove tipi di dati integrati. Di questi nove tipi, ce ne sono sei che vengono definiti valori/tipi primitive
. Queste sei primitive sono undefined
, number
, string
, boolean
, bigint
e symbol
. Un semplice controllo con l'operatore typeof
di JavaScript rivelerà i tipi di questi tipi di dati.
console.log(typeof 5) // number console.log(typeof 'name') // string console.log(typeof (1 < 2)) // boolean console.log(typeof undefined) // undefined console.log(typeof Symbol('js')) // symbol console.log(typeof BigInt(900719925474)) // bigint
Una primitive
è un valore che non è un oggetto e non ha metodi. La cosa più importante per la nostra discussione attuale è il fatto che il valore di una primitiva non può essere cambiato una volta che è stata creata. Pertanto, si dice che le primitive siano immutable
.
I restanti tre tipi sono null
, object
e function
. Possiamo anche controllare i loro tipi usando l'operatore typeof
.
console.log(typeof null) // object console.log(typeof [0, 1]) // object console.log(typeof {name: 'name'}) // object const f = () => ({}) console.log(typeof f) // function
Questi tipi sono mutable
. Ciò significa che i loro valori possono essere modificati in qualsiasi momento dopo la loro creazione.
Ti starai chiedendo perché ho l'array [0, 1]
lassù. Bene, in JavaScriptland, un array è semplicemente un tipo speciale di oggetto. Nel caso ti stia anche chiedendo null
e come sia diverso da undefined
. undefined
significa semplicemente che non abbiamo impostato un valore per una variabile mentre null
è un caso speciale per gli oggetti. Se sai che qualcosa dovrebbe essere un oggetto ma l'oggetto non è presente, restituisci semplicemente null
.
Per illustrare con un semplice esempio, prova a eseguire il codice seguente nella console del browser.
console.log('aeiou'.match(/[x]/gi)) // null console.log('xyzabc'.match(/[x]/gi)) // [ 'x' ]
String.prototype.match
dovrebbe restituire un array, che è un tipo di object
. Quando non riesce a trovare un tale oggetto, restituisce null
. Anche il ritorno undefined
non avrebbe senso qui.
Basta con quello. Torniamo a parlare di immutabilità.
Secondo i documenti MDN:
"Tutti i tipi tranne gli oggetti definiscono valori immutabili (ovvero valori che non possono essere modificati)."
Questa istruzione include funzioni perché sono un tipo speciale di oggetto JavaScript. Vedi qui la definizione della funzione.
Diamo una rapida occhiata a cosa significano in pratica i tipi di dati mutabili e immutabili. Prova a eseguire il codice seguente nella console del browser.
let a = 5; let b = a console.log(`a: ${a}; b: ${b}`) // a: 5; b: 5 b = 7 console.log(`a: ${a}; b: ${b}`) // a: 5; b: 7
I nostri risultati mostrano che anche se b
è “derivato” da a
, la modifica del valore di b
non influisce sul valore di a
. Ciò deriva dal fatto che quando il motore JavaScript esegue l'istruzione b = a
, crea una nuova posizione di memoria separata, inserisce 5
e punta b
in quella posizione.
E gli oggetti? Considera il codice seguente.
let c = { name: 'some name'} let d = c; console.log(`c: ${JSON.stringify(c)}; d: ${JSON.stringify(d)}`) // {"name":"some name"}; d: {"name":"some name"} d.name = 'new name' console.log(`c: ${JSON.stringify(c)}; d: ${JSON.stringify(d)}`) // {"name":"new name"}; d: {"name":"new name"}
Possiamo vedere che cambiando la proprietà name tramite la variabile d
cambia anche in c
. Ciò deriva dal fatto che quando il motore JavaScript esegue l'istruzione, c = { name: 'some name
'
}
, il motore JavaScript crea uno spazio in memoria, inserisce l'oggetto all'interno e punta c
su di esso. Quindi, quando esegue l'istruzione d = c
, il motore JavaScript punta semplicemente d
nella stessa posizione. Non crea una nuova posizione di memoria. Pertanto, qualsiasi modifica agli elementi in d
è implicitamente un'operazione sugli elementi in c
. Senza molti sforzi, possiamo capire perché questo è un problema in arrivo.
Immagina di sviluppare un'applicazione React e da qualche parte vuoi mostrare il nome dell'utente come some name
leggendo dalla variabile c
. Ma da qualche altra parte avevi introdotto un bug nel tuo codice manipolando l'oggetto d
. Ciò comporterebbe la visualizzazione del nome dell'utente come new name
. Se c
e d
fossero primitivi non avremmo questo problema. Ma le primitive sono troppo semplici per il tipo di stato che una tipica applicazione React deve mantenere.
Si tratta dei motivi principali per cui è importante mantenere uno stato immutabile nell'applicazione. Ti incoraggio a controllare alcune altre considerazioni leggendo questa breve sezione del README di Immutable.js: il caso dell'immutabilità.
Avendo capito perché abbiamo bisogno dell'immutabilità in un'applicazione React, diamo ora un'occhiata a come Immer affronta il problema con la sua funzione di produce
.
Funzione di produce
di Immer
L'API principale di Immer è molto piccola e la funzione principale con cui lavorerai è la funzione di produce
. produce
prende semplicemente uno stato iniziale e un callback che definisce come lo stato dovrebbe essere mutato. La stessa richiamata riceve una bozza (identica, ma comunque una copia) della copia dello stato a cui effettua tutti gli aggiornamenti previsti. Infine, produce
un nuovo stato immutabile con tutte le modifiche applicate.
Il modello generale per questo tipo di aggiornamento dello stato è:
// produce signature produce(state, callback) => nextState
Vediamo come funziona in pratica.
import produce from 'immer' const initState = { pets: ['dog', 'cat'], packages: [ { name: 'react', installed: true }, { name: 'redux', installed: true }, ], } // to add a new package const newPackage = { name: 'immer', installed: false } const nextState = produce(initState, draft => { draft.packages.push(newPackage) })
Nel codice sopra, passiamo semplicemente lo stato iniziale e un callback che specifica come vogliamo che avvengano le mutazioni. E 'così semplice. Non abbiamo bisogno di toccare nessun'altra parte dello stato. Lascia initState
intatto e condivide strutturalmente quelle parti dello stato che non abbiamo toccato tra l'inizio e i nuovi stati. Una di queste parti nel nostro stato è l'array di pets
. produce
d nextState
è un albero di stato immutabile che contiene le modifiche che abbiamo apportato e le parti che non abbiamo modificato.
Forti di questa semplice ma utile conoscenza, diamo un'occhiata a come i produce
possono aiutarci a semplificare i nostri riduttori React.
Riduttori di scrittura con Immer
Supponiamo di avere l'oggetto stato definito di seguito
const initState = { pets: ['dog', 'cat'], packages: [ { name: 'react', installed: true }, { name: 'redux', installed: true }, ], };
E volevamo aggiungere un nuovo oggetto e, in un passaggio successivo, impostare la sua chiave installed
su true
const newPackage = { name: 'immer', installed: false };
Se dovessimo farlo nel solito modo con la sintassi dell'oggetto JavaScript e della diffusione dell'array, il nostro riduttore di stato potrebbe apparire come di seguito.
const updateReducer = (state = initState, action) => { switch (action.type) { case 'ADD_PACKAGE': return { ...state, packages: [...state.packages, action.package], }; case 'UPDATE_INSTALLED': return { ...state, packages: state.packages.map(pack => pack.name === action.name ? { ...pack, installed: action.installed } : pack ), }; default: return state; } };
Possiamo vedere che questo è inutilmente prolisso e soggetto a errori per questo oggetto di stato relativamente semplice. Dobbiamo anche toccare ogni parte dello stato, il che non è necessario. Vediamo come possiamo semplificarlo con Immer.
const updateReducerWithProduce = (state = initState, action) => produce(state, draft => { switch (action.type) { case 'ADD_PACKAGE': draft.packages.push(action.package); break; case 'UPDATE_INSTALLED': { const package = draft.packages.filter(p => p.name === action.name)[0]; if (package) package.installed = action.installed; break; } default: break; } });
E con poche righe di codice, abbiamo notevolmente semplificato il nostro riduttore. Inoltre, se cadiamo nel caso predefinito, Immer restituisce semplicemente lo stato di bozza senza che dobbiamo fare nulla. Nota come c'è meno codice standard e l'eliminazione della diffusione dello stato. Con Immer, ci occupiamo solo della parte dello stato che vogliamo aggiornare. Se non riusciamo a trovare un tale elemento, come nell'azione `UPDATE_INSTALLED`, andiamo semplicemente avanti senza toccare nient'altro. La funzione "produrre" si presta anche al curry. Passare un callback come primo argomento per "produrre" è inteso per essere utilizzato per il curry. La firma del "prodotto" al curry è //curried produce signature produce(callback) => (state) => nextState
Vediamo come possiamo aggiornare il nostro stato precedente con un prodotto al curry. I nostri prodotti al curry sarebbero così: const curriedProduce = produce((draft, action) => { switch (action.type) { case 'ADD_PACKAGE': draft.packages.push(action.package); break; case 'SET_INSTALLED': { const package = draft.packages.filter(p => p.name === action.name)[0]; if (package) package.installed = action.installed; break; } default: break; } });
La funzione Curried Produce accetta una funzione come primo argomento e restituisce un Curried Produce che solo ora richiede uno stato da cui produrre lo stato successivo. Il primo argomento della funzione è lo stato bozza (che sarà derivato dallo stato da passare quando si chiama questo prodotto al curry). Quindi segue ogni numero di argomenti che desideriamo passare alla funzione.
Tutto ciò che dobbiamo fare ora per utilizzare questa funzione è passare nello stato da cui vogliamo produrre lo stato successivo e l'oggetto azione in questo modo.
// add a new package to the starting state const nextState = curriedProduce(initState, { type: 'ADD_PACKAGE', package: newPackage, }); // update an item in the recently produced state const nextState2 = curriedProduce(nextState, { type: 'SET_INSTALLED', name: 'immer', installed: true, });
Si noti che in un'applicazione React quando si utilizza l'hook useReducer
, non è necessario passare lo stato in modo esplicito come ho fatto sopra perché se ne occupa.
Ti starai chiedendo, Immer starebbe ottenendo un hook
, come tutto in React in questi giorni? Bene, sei in compagnia di buone notizie. Immer ha due hook per lavorare con state: gli useImmer
e useImmerReducer
. Vediamo come funzionano.
Utilizzo useImmer
e useImmerReducer
La migliore descrizione useImmer
viene dallo stesso README use-immer.
useImmer(initialState)
è molto simile auseState
. La funzione restituisce una tupla, il primo valore della tupla è lo stato corrente, il secondo è la funzione di aggiornamento, che accetta una funzione di produttore immer, in cui ladraft
può essere mutata liberamente, fino a quando il produttore non finisce e verranno apportate le modifiche immutabile e diventa lo stato successivo.
Per utilizzare questi ganci, è necessario installarli separatamente, oltre alla libreria principale Immer.
yarn add immer use-immer
In termini di codice, l'hook useImmer
appare come di seguito
import React from "react"; import { useImmer } from "use-immer"; const initState = {} const [ data, updateData ] = useImmer(initState)
Ed è così semplice. Si potrebbe dire che è useState di React ma con un po' di steroidi. Utilizzare la funzione di aggiornamento è molto semplice. Riceve lo stato di bozza e puoi modificarlo quanto vuoi come di seguito.
// make changes to data updateData(draft => { // modify the draft as much as you want. })
Il creatore di Immer ha fornito un esempio di codici e scatole con cui puoi giocare per vedere come funziona.
useImmerReducer
è altrettanto semplice da usare se hai usato l'hook useReducer
di React. Ha una firma simile. Vediamo come appare in termini di codice.
import React from "react"; import { useImmerReducer } from "use-immer"; const initState = {} const reducer = (draft, action) => { switch(action.type) { default: break; } } const [data, dataDispatch] = useImmerReducer(reducer, initState);
Possiamo vedere che il riduttore riceve una draft
stato che possiamo modificare quanto vogliamo. C'è anche un esempio di codici e box qui con cui puoi sperimentare.
Ed è così semplice usare i ganci Immer. Ma nel caso ti stia ancora chiedendo perché dovresti usare Immer nel tuo progetto, ecco un riepilogo di alcuni dei motivi più importanti che ho trovato per usare Immer.
Perché dovresti usare Immer
Se hai scritto la logica di gestione dello stato per un certo periodo di tempo, apprezzerai rapidamente la semplicità offerta da Immer. Ma questo non è l'unico vantaggio offerto da Immer.
Quando usi Immer, finisci per scrivere meno codice standard, come abbiamo visto con riduttori relativamente semplici. Questo rende anche gli aggiornamenti profondi relativamente facili.
Con librerie come Immutable.js, devi imparare una nuova API per sfruttare i vantaggi dell'immutabilità. Ma con Immer si ottiene lo stesso risultato con normali Objects
JavaScript, Arrays
, Sets
e Maps
. Non c'è niente di nuovo da imparare.
Immer fornisce anche la condivisione strutturale per impostazione predefinita. Ciò significa semplicemente che quando si apportano modifiche a un oggetto di stato, Immer condivide automaticamente le parti invariate dello stato tra il nuovo stato e lo stato precedente.
Con Immer, ottieni anche il congelamento automatico degli oggetti, il che significa che non puoi apportare modifiche allo stato produced
. Ad esempio, quando ho iniziato a utilizzare Immer, ho provato ad applicare il metodo di sort
su una matrice di oggetti restituiti dalla funzione di produzione di Immer. Ha generato un errore dicendomi che non posso apportare modifiche all'array. Ho dovuto applicare il metodo della fetta dell'array prima di applicare sort
. Ancora una volta, il nextState
prodotto è un albero di stato immutabile.
Immer è anche fortemente tipizzato e molto piccolo a soli 3 KB quando compresso con gzip.
Conclusione
Quando si tratta di gestire gli aggiornamenti di stato, l'utilizzo di Immer è un gioco da ragazzi per me. È una libreria molto leggera che ti consente di continuare a utilizzare tutte le cose che hai imparato su JavaScript senza cercare di imparare qualcosa di completamente nuovo. Ti incoraggio a installarlo nel tuo progetto e iniziare a usarlo subito. Puoi aggiungere utilizzarlo in progetti esistenti e aggiornare in modo incrementale i tuoi riduttori.
Ti incoraggio anche a leggere il post introduttivo sul blog di Immer di Michael Weststrate. La parte che trovo particolarmente interessante è "Come funziona Immer?" sezione che spiega come Immer sfrutta le funzionalità del linguaggio come proxy e concetti come copy-on-write.
Ti incoraggio anche a dare un'occhiata a questo post del blog: Immutability in JavaScript: A Contratian View in cui l'autore, Steven de Salas, presenta i suoi pensieri sui meriti di perseguire l'immutabilità.
Spero che con le cose che hai imparato in questo post tu possa iniziare subito a usare Immer.
Risorse correlate
-
use-immer
, GitHub - Immer, GitHub
-
function
, documenti web MDN, Mozilla -
proxy
, documenti web MDN, Mozilla - Oggetto (informatica), Wikipedia
- "Immutabilità in JS", Orji Chidi Matthew, GitHub
- "Tipi e valori di dati ECMAScript", Ecma International
- Raccolte immutabili per JavaScript, Immutable.js , GitHub
- "Il caso dell'immutabilità", Immutable.js, GitHub