Controllo di primo livello con Redux State Management: un tutorial su ClojureScript
Pubblicato: 2022-03-11Bentornato per la seconda entusiasmante puntata di Unearthing ClojureScript! In questo post, tratterò il prossimo grande passo per diventare seri con ClojureScript: la gestione dello stato, in questo caso, usando React.
Con il software front-end, la gestione dello stato è un grosso problema. Pronto all'uso, ci sono un paio di modi per gestire lo stato in React:
- Mantenere lo stato al livello più alto e passarlo (o gestori per un particolare pezzo di stato) ai componenti figlio.
- Gettare la purezza fuori dalla finestra e avere variabili globali o qualche forma lovecraftiana di iniezione di dipendenza.
In generale, nessuno di questi è fantastico. Mantenere lo stato al livello più alto è abbastanza semplice, ma poi c'è una grande quantità di sovraccarico nel trasmettere lo stato dell'applicazione a ogni componente che ne ha bisogno.
In confronto, avere variabili globali (o altre versioni ingenue di stato) può causare problemi di simultaneità difficili da tracciare, portando i componenti a non aggiornarsi quando previsto, o viceversa.
Quindi, come può essere affrontato? Per quelli di voi che hanno familiarità con React, potreste aver provato Redux, un contenitore di stato per app JavaScript. Potresti averlo scoperto di tua spontanea volontà, cercando coraggiosamente un sistema gestibile per mantenere lo stato. Oppure potresti esserti appena imbattuto durante la lettura di JavaScript e altri strumenti web.
Indipendentemente da come le persone finiscono per guardare Redux, nella mia esperienza generalmente finiscono con due pensieri:
- "Sento di doverlo usare perché tutti dicono che devo usarlo."
- "Non capisco davvero perché questo sia meglio."
In generale, Redux fornisce un'astrazione che consente alla gestione dello stato di adattarsi alla natura reattiva di React. Scaricando tutta la statefulness su un sistema come Redux, si preserva la purezza di React. Così finirai con molti meno mal di testa e generalmente qualcosa su cui è molto più facile ragionare.
Per chi è nuovo a Clojure
Anche se questo potrebbe non aiutarti a imparare ClojureScript completamente da zero, qui ricapitolerò almeno alcuni concetti di stato di base in Clojure [Script]. Sentiti libero di saltare queste parti se sei già un Clojurian esperto!
Richiama una delle nozioni di base di Clojure che si applica anche a ClojureScript: per impostazione predefinita, i dati sono immutabili. Questo è ottimo per sviluppare e avere garanzie che ciò che crei al timestep N sia sempre lo stesso al timestep > N. ClojureScript ci fornisce anche un modo conveniente per avere uno stato mutabile se ne abbiamo bisogno, tramite il concetto di atom
.
Un atom
in ClojureScript è molto simile ad un AtomicReference
in Java: fornisce un nuovo oggetto che ne blocca il contenuto con garanzie di concorrenza. Proprio come in Java, puoi posizionare tutto ciò che ti piace in questo oggetto: da quel momento in poi, quell'atomo sarà un riferimento atomico a ciò che desideri.
Una volta che hai il tuo atom
, puoi impostare atomicamente un nuovo valore usando il reset!
funzione (notare il !
nella funzione: nel linguaggio Clojure questo è spesso usato per indicare che un'operazione è con stato o impura).
Nota anche che, a differenza di Java, a Clojure non importa cosa metti nel tuo atom
. Potrebbe essere una stringa, un elenco o un oggetto. Digitazione dinamica, piccola!
(def my-mutable-map (atom {})) ; recall that {} means an empty map in Clojure (println @my-mutable-map) ; You 'dereference' an atom using @ ; -> this prints {} (reset! my-mutable-map {:hello "there"}) ; atomically set the atom (reset! my-mutable-map "hello, there!") ; don't forget Clojure is dynamic :)
Il reagente estende questo concetto di atomo con il proprio atom
. (Se non hai familiarità con Reagent, controlla il post prima di questo.) Questo si comporta in modo identico a ClojureScript atom
, tranne per il fatto che attiva anche eventi di rendering in Reagent, proprio come l'archivio di stato integrato di React.
Un esempio:
(ns example (:require [reagent.core :refer [atom]])) ; in this module, atom now refers ; to reagent's atom. (def my-atom (atom "world!")) (defn component [] [:div [:span "Hello, " @my-atom] [:input {:type "button" :value "Press Me!" :on-click #(reset! My-atom "there!")}]])
Questo mostrerà un singolo <div>
contenente un <span>
che dice "Hello, world!" e un pulsante, come ci si potrebbe aspettare. Premendo quel pulsante si muterà atomicamente my-atom
per contenere "there!"
. Ciò attiverà un ridisegno del componente, con il risultato che l'intervallo dice "Hello, there!" invece.
Questo sembra abbastanza semplice per la mutazione locale a livello di componente, ma cosa succede se abbiamo un'applicazione più complicata che ha più livelli di astrazione? O se abbiamo bisogno di condividere lo stato comune tra più sottocomponenti e i loro sottocomponenti?
Un esempio più complicato
Esploriamo questo con un esempio. Qui implementeremo una pagina di accesso grezza:
(ns unearthing-clojurescript.login (:require [reagent.core :as reagent :refer [atom]])) ;; -- STATE -- (def username (atom nil)) (def password (atom nil)) ;; -- VIEW -- (defn component [on-login] [:div [:b "Username"] [:input {:type "text" :value @username :on-change #(reset! username (-> % .-target .-value))}] [:b "Password"] [:input {:type "password" :value @password :on-change #(reset! password (-> % .-target .-value))}] [:input {:type "button" :value "Login!" :on-click #(on-login @username @password)}]])
Ospiteremo quindi questo componente di accesso all'interno del nostro app.cljs
principale, in questo modo:
(ns unearthing-clojurescript.app (:require [unearthing-clojurescript.login :as login])) ;; -- STATE (def token (atom nil)) ;; -- LOGIC -- (defn- do-login-io [username password] (let [t (complicated-io-login-operation username password)] (reset! token t))) ;; -- VIEW -- (defn component [] [:div [login/component do-login-io]])
Il flusso di lavoro previsto è quindi:
- Aspettiamo che l'utente inserisca il proprio nome utente e password e premi Invia.
- Questo attiverà la nostra funzione
do-login-io
nel componente genitore. - La funzione
do-login-io
esegue alcune operazioni di I/O (come l'accesso a un server e il recupero di un token).
Se questa operazione si sta bloccando, allora siamo già in un mucchio di guai, poiché la nostra applicazione è bloccata; in caso contrario, dobbiamo preoccuparci dell'async!
Inoltre, ora dobbiamo fornire questo token a tutti i nostri sottocomponenti che desiderano eseguire query sul nostro server. Il refactoring del codice è diventato molto più difficile!
Infine, il nostro componente ora non è più puramente reattivo : ora è complice nella gestione dello stato del resto dell'applicazione, attivando l'I/O e in generale essendo un po' fastidioso.
Esercitazione ClojureScript: immettere Redux
Redux è la bacchetta magica che realizza tutti i tuoi sogni basati sullo stato. Se implementato correttamente, fornisce un'astrazione di condivisione dello stato sicura, veloce e facile da usare.
Il funzionamento interno di Redux (e la teoria alla base) sono in qualche modo al di fuori dello scopo di questo articolo. Invece, mi immergerò in un esempio funzionante con ClojureScript, che si spera dovrebbe in qualche modo dimostrare di cosa è capace!
Nel nostro contesto, Redux è implementato da una delle tante librerie ClojureScript disponibili; questo chiamato re-frame. Fornisce un involucro Clojure-ificato attorno a Redux che (a mio parere) lo rende un vero piacere da usare.
Le basi
Redux solleva lo stato dell'applicazione, lasciando i componenti leggeri. Un componente reduxificato deve solo pensare a:
- Cosa sembra
- Quali dati consuma
- Quali eventi innesca
Il resto viene gestito dietro le quinte.
Per enfatizzare questo punto, riduciamo la nostra pagina di accesso sopra.
La banca dati
Per prima cosa: dobbiamo decidere che aspetto avrà il nostro modello di applicazione. Lo facciamo definendo la forma dei nostri dati, dati che saranno accessibili in tutta l'app.

Una buona regola pratica è che se i dati devono essere utilizzati su più componenti Redux o devono essere di lunga durata (come sarà il nostro token), dovrebbero essere archiviati nel database. Al contrario, se i dati sono locali per il componente (come i nostri campi nome utente e password), dovrebbero vivere come lo stato del componente locale e non essere archiviati nel database.
Creiamo il nostro database boilerplate e specifichiamo il nostro token:
(ns unearthing-clojurescript.state.db (:require [cljs.spec.alpha :as s] [re-frame.core :as re-frame])) (s/def ::token string?) (s/def ::db (s/keys :opt-un [::token])) (def default-db {:token nil})
Ci sono alcuni punti interessanti che vale la pena notare qui:
- Usiamo la libreria delle
spec
di Clojure per descrivere come dovrebbero apparire i nostri dati. Ciò è particolarmente appropriato in un linguaggio dinamico come Clojure[Script]. - Per questo esempio, stiamo solo tenendo traccia di un token globale che rappresenterà il nostro utente una volta effettuato l'accesso. Questo token è una semplice stringa.
- Tuttavia, prima che l'utente acceda, non avremo un token. Questo è rappresentato dalla parola chiave
:opt-un
, che sta per "opzionale, non qualificato". (In Clojure, una parola chiave normale sarebbe qualcosa come:cat
, mentre una parola chiave qualificata potrebbe essere qualcosa come:animal/cat
. La qualificazione avviene normalmente a livello di modulo, questo impedisce alle parole chiave in moduli diversi di colpirsi a vicenda.) - Infine, specifichiamo lo stato predefinito del nostro database, ovvero come viene inizializzato.
In qualsiasi momento, dovremmo essere certi che i dati nel nostro database corrispondano alle nostre specifiche qui.
Abbonamenti
Ora che abbiamo descritto il nostro modello di dati, dobbiamo riflettere su come la nostra vista mostra quei dati. Abbiamo già descritto come appare la nostra vista nel nostro componente Redux, ora dobbiamo semplicemente connettere la nostra vista al nostro database.
Con Redux, non accediamo direttamente al nostro database: ciò potrebbe causare problemi di ciclo di vita e concorrenza. Al contrario, registriamo la nostra relazione con un aspetto del database tramite abbonamenti .
Un abbonamento dice a re-frame (e Reagent) che dipendiamo da una parte del database e, se quella parte viene modificata, il nostro componente Redux dovrebbe essere riprodotto.
Gli abbonamenti sono molto semplici da definire:
(ns unearthing-clojurescript.state.subs (:require [re-frame.core :refer [reg-sub]])) (reg-sub :token ; <- the name of the subscription (fn [{:keys [token] :as db} _] ; first argument is the database, second argument is any token)) ; args passed to the subscribe function (not used here)
Qui registriamo un unico abbonamento, al token stesso. Un abbonamento è semplicemente il nome dell'abbonamento e la funzione che estrae quell'elemento dal database. Possiamo fare tutto ciò che vogliamo per quel valore e mutare la vista quanto vogliamo qui; tuttavia, in questo caso, estraiamo semplicemente il token dal database e lo restituiamo.
C'è molto, molto di più che puoi fare con gli abbonamenti, come la definizione di viste sulle sottosezioni del database per un ambito più ristretto sul re-rendering, ma per ora lo terremo semplice!
Eventi
Abbiamo il nostro database e abbiamo la nostra vista nel database. Ora dobbiamo attivare alcuni eventi! In questo esempio, abbiamo due tipi di eventi:
- Il puro evento ( senza effetti collaterali) della scrittura di un nuovo token nel database.
- L'evento I/O ( che ha un effetto collaterale) di uscire e richiedere il nostro token attraverso un'interazione con il client.
Inizieremo con quello facile. Re-frame fornisce anche una funzione esattamente per questo tipo di evento:
(ns unearthing-clojurescript.state.events (:require [re-frame.core :refer [reg-event-db reg-event-fx reg-fx] :as rf] [unearthing-clojurescript.state.db :refer [default-db]])) ; our start up event that initialises the database. ; we'll trigger this in our core.cljs (reg-event-db :initialise-db (fn [_ _] default-db)) ; a simple event that places a token in the database (reg-event-db :store-login (fn [db [_ token]] (assoc db :token token)))
Ancora una volta, qui è abbastanza semplice: abbiamo definito due eventi. Il primo è per l'inizializzazione del nostro database. (Vedi come ignora entrambi i suoi argomenti? Inizializziamo sempre il database con il nostro default-db
!) Il secondo è per memorizzare il nostro token una volta che l'abbiamo ottenuto.
Nota che nessuno di questi eventi ha effetti collaterali: nessuna chiamata esterna, nessun I/O! Questo è molto importante per preservare la santità del santo processo Redux. Non renderlo impuro per non augurare l'ira di Redux su di te.
Infine, abbiamo bisogno del nostro evento di accesso. Lo posizioniamo sotto gli altri:
(reg-event-fx :login (fn [{:keys [db]} [_ credentials]] {:request-token credentials})) (reg-fx :request-token (fn [{:keys [username password]}] (let [token (complicated-io-login-operation username password)] (rf/dispatch [:store-login token]))))
La funzione reg-event-fx
è in gran parte simile a reg-event-db
, sebbene vi siano alcune sottili differenze.
- Il primo argomento non è più solo il database stesso. Contiene una moltitudine di altre cose che puoi usare per gestire lo stato dell'applicazione.
- Il secondo argomento è molto simile a
reg-event-db
. - Invece di restituire semplicemente il nuovo
db
, restituiamo invece una mappa che rappresenta tutti gli effetti ("fx") che dovrebbero verificarsi per questo evento. In questo caso, chiamiamo semplicemente l'effetto:request-token
, che è definito di seguito. Uno degli altri effetti validi è:dispatch
, che chiama semplicemente un altro evento.
Una volta che il nostro effetto è stato inviato, viene chiamato il nostro effetto :request-token
, che esegue la nostra operazione di accesso I/O di lunga durata. Una volta terminato, invia felicemente il risultato nel ciclo degli eventi, completando così il ciclo!
Tutorial ClojureScript: il risultato finale
Così! Abbiamo definito la nostra astrazione di archiviazione. Che aspetto ha il componente ora?
(ns unearthing-clojurescript.login (:require [reagent.core :as reagent :refer [atom]] [re-frame.core :as rf])) ;; -- STATE -- (def username (atom nil)) (def password (atom nil)) ;; -- VIEW -- (defn component [] [:div [:b "Username"] [:input {:type "text" :value @username :on-change #(reset! username (-> % .-target .-value))}] [:b "Password"] [:input {:type "password" :value @password :on-change #(reset! password (-> % .-target .-value))}] [:input {:type "button" :value "Login!" :on-click #(rf/dispatch [:login {:username @username :password @password]})}]])
E il nostro componente dell'app:
(ns unearthing-clojurescript.app (:require [unearthing-clojurescript.login :as login])) ;; -- VIEW -- (defn component [] [:div [login/component]])
E infine, accedere al nostro token in qualche componente remoto è semplice come:
(let [token @(rf/subscribe [:token])] ; ... )
Mettere tutto insieme:
Nessun problema, nessun problema.
Il disaccoppiamento dei componenti con Redux/Re-frame significa una gestione dello stato pulito
Usando Redux (tramite re-frame), abbiamo disaccoppiato con successo i nostri componenti di visualizzazione dal pasticcio della gestione dello stato. Estendere la nostra astrazione statale ora è un gioco da ragazzi!
Redux in ClojureScript è davvero così facile: non hai scuse per non provarlo.
Se sei pronto per iniziare, ti consiglio di dare un'occhiata ai fantastici documenti di re-frame e al nostro semplice esempio funzionante. Non vedo l'ora di leggere i tuoi commenti su questo tutorial ClojureScript di seguito. Buona fortuna!