Kontrola najwyższego poziomu z zarządzaniem stanem Redux: samouczek ClojureScript
Opublikowany: 2022-03-11Witamy ponownie w drugiej ekscytującej odsłonie Unearthing ClojureScript! W tym poście omówię kolejny duży krok w kierunku poważnego podejścia do ClojureScript: zarządzanie stanem — w tym przypadku za pomocą Reacta.
W przypadku oprogramowania typu front-end zarządzanie stanem to wielka sprawa. Gotowe do użycia, istnieje kilka sposobów radzenia sobie ze stanem w React:
- Utrzymywanie stanu na najwyższym poziomie i przekazywanie go (lub programów obsługi konkretnego elementu stanu) do komponentów podrzędnych.
- Wyrzucanie czystości przez okno i posiadanie zmiennych globalnych lub jakiejś Lovecraftowskiej formy wstrzykiwania zależności.
Ogólnie rzecz biorąc, żaden z nich nie jest świetny. Utrzymywanie stanu na najwyższym poziomie jest dość proste, ale przekazywanie stanu aplikacji do każdego komponentu, który tego potrzebuje, wiąże się z dużym narzutem.
Dla porównania, posiadanie zmiennych globalnych (lub innych naiwnych wersji stanu) może skutkować trudnymi do wyśledzenia problemami ze współbieżnością, prowadzącymi do tego, że składniki nie będą aktualizowane zgodnie z oczekiwaniami lub na odwrót.
Jak więc sobie z tym poradzić? Dla tych z Was, którzy znają Reacta, być może wypróbowaliście Redux, kontener stanu dla aplikacji JavaScript. Być może odkryłeś to z własnej woli, odważnie szukając łatwego do zarządzania systemu utrzymania państwa. A może właśnie natknąłeś się na to, czytając o JavaScript i innych narzędziach internetowych.
Niezależnie od tego, jak ludzie patrzą na Redux, z mojego doświadczenia wynika, że zazwyczaj kończą się dwiema myślami:
- „Czuję, że muszę tego użyć, ponieważ wszyscy mówią, że muszę tego użyć”.
- „Tak naprawdę nie do końca rozumiem, dlaczego tak jest lepiej”.
Ogólnie rzecz biorąc, Redux zapewnia abstrakcję, która pozwala zarządzaniu stanem dopasować się do reaktywnej natury Reacta. Przenosząc całą stateczność do systemu takiego jak Redux, zachowujesz czystość Reacta. W ten sposób będziesz mieć o wiele mniej bólów głowy i ogólnie rzecz biorąc, o wiele łatwiej jest zrozumieć.
Dla nowych w Clojure
Chociaż może to nie pomóc w nauce ClojureScript całkowicie od zera, tutaj przynajmniej podsumuję kilka podstawowych pojęć stanu w Clojure[Script]. Możesz pominąć te części, jeśli jesteś już doświadczonym Clojurianem!
Przypomnij sobie jedną z podstaw Clojure, która dotyczy również ClojureScript: Domyślnie dane są niezmienne. Jest to świetne do programowania i daje gwarancję, że to, co tworzysz w kroku czasowym N, jest nadal takie samo w kroku czasowym > N. ClojureScript zapewnia nam również wygodny sposób na zmienny stan, jeśli tego potrzebujemy, za pomocą koncepcji atom
.
atom
w ClojureScript jest bardzo podobny do AtomicReference
w Javie: zapewnia nowy obiekt, który blokuje swoją zawartość z gwarancjami współbieżności. Podobnie jak w Javie, możesz umieścić w tym obiekcie wszystko, co ci się podoba — odtąd ten atom będzie atomowym odniesieniem do czegokolwiek zechcesz.
Gdy już masz swój atom
, możesz atomowo ustawić w nim nową wartość, używając reset!
funkcja (zwróć uwagę na !
w funkcji — w języku Clojure jest to często używane do oznaczenia, że operacja jest stanowa lub nieczysta).
Zauważ też, że — w przeciwieństwie do Javy — Clojure nie dba o to, co wkładasz do swojego atom
. Może to być ciąg znaków, lista lub obiekt. Dynamiczne pisanie, kochanie!
(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 :)
Reagent rozszerza tę koncepcję atomu o własny atom
. (Jeśli nie znasz Reagenta, zapoznaj się z postem przed tym.) Działa to identycznie jak atom
ClojureScript , z wyjątkiem tego, że wyzwala również zdarzenia renderowania w Reagent, podobnie jak wbudowany magazyn stanów Reacta.
Przykład:
(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!")}]])
Spowoduje to wyświetlenie pojedynczego elementu <div>
zawierającego <span>
mówiące „Hello, world!” i przycisk, jak można się spodziewać. Naciśnięcie tego przycisku spowoduje atomową mutację my-atom
aby zawierał "there!"
. Spowoduje to ponowne narysowanie komponentu, w wyniku czego span powie „Cześć, tam!” zamiast.
Wydaje się to dość proste dla lokalnej mutacji na poziomie komponentów, ale co, jeśli mamy bardziej skomplikowaną aplikację, która ma wiele poziomów abstrakcji? Lub jeśli musimy dzielić wspólny stan między wieloma podkomponentami i ich podkomponentami?
Bardziej skomplikowany przykład
Zbadajmy to na przykładzie. Tutaj zaimplementujemy prymitywną stronę logowania:
(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)}]])
Następnie będziemy hostować ten komponent logowania w naszym głównym app.cljs
, na przykład:
(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]])
Oczekiwany przepływ pracy to zatem:
- Czekamy, aż użytkownik wprowadzi swoją nazwę użytkownika i hasło, a następnie kliknie Prześlij.
- To uruchomi naszą funkcję
do-login-io
w komponencie nadrzędnym. - Funkcja
do-login-io
wykonuje pewne operacje we/wy (takie jak logowanie na serwerze i pobieranie tokena).
Jeśli ta operacja blokuje, to już mamy kłopoty, ponieważ nasza aplikacja jest zamrożona — jeśli nie, musimy się martwić o asynchronię!
Dodatkowo teraz musimy udostępnić ten token wszystkim naszym podkomponentom, które chcą wykonywać zapytania do naszego serwera. Refaktoryzacja kodu stała się o wiele trudniejsza!
Wreszcie, nasz komponent nie jest już całkowicie reaktywny — jest teraz współuczestnikiem zarządzania stanem pozostałej części aplikacji, wyzwalania operacji we/wy i ogólnie jest trochę uciążliwy.
Samouczek ClojureScript: Wprowadź Redux
Redux to magiczna różdżka, która urzeczywistnia wszystkie Twoje stanowe marzenia. Prawidłowo zaimplementowany zapewnia abstrakcję współdzielenia stanu, która jest bezpieczna, szybka i łatwa w użyciu.
Wewnętrzne działanie Redux (i stojąca za nim teoria) są nieco poza zakresem tego artykułu. Zamiast tego zagłębię się w działający przykład z ClojureScript, który, miejmy nadzieję, powinien w jakiś sposób zademonstrować, do czego jest zdolny!
W naszym kontekście Redux jest zaimplementowany przez jedną z wielu dostępnych bibliotek ClojureScript; ten nazwano re-frame. Zapewnia opakowanie z Clojure wokół Redux, co (moim zdaniem) sprawia, że korzystanie z niego jest absolutną przyjemnością.
Podstawy
Redux podnosi stan aplikacji, pozostawiając lekkie komponenty. Komponent z reduksyfikacją musi tylko pomyśleć o:
- Jak to wygląda
- Jakie dane zużywa
- Jakie zdarzenia to wyzwala
Reszta jest obsługiwana za kulisami.
Aby podkreślić ten punkt, zredukujmy naszą stronę logowania powyżej.
Baza danych
Po pierwsze: musimy zdecydować, jak będzie wyglądał nasz model aplikacji. Robimy to, określając kształt naszych danych, które będą dostępne w całej aplikacji.

Dobrą zasadą jest to, że jeśli dane mają być używane w wielu komponentach Redux lub muszą być długowieczne (tak jak będzie nasz token), powinny być przechowywane w bazie danych. W przeciwieństwie do tego, jeśli dane są lokalne dla komponentu (takie jak pola naszej nazwy użytkownika i hasła), powinny one istnieć jako lokalny stan komponentu i nie powinny być przechowywane w bazie danych.
Stwórzmy nasz szablon bazy danych i określmy nasz 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})
Warto tutaj zwrócić uwagę na kilka interesujących punktów:
- Używamy biblioteki
spec
Clojure, aby opisać , jak powinny wyglądać nasze dane. Jest to szczególnie odpowiednie w dynamicznym języku, takim jak Clojure[Script]. - W tym przykładzie śledzimy tylko globalny token, który będzie reprezentował naszego użytkownika po zalogowaniu. Ten token jest prostym ciągiem.
- Jednak zanim użytkownik się zaloguje, nie będziemy mieli tokena. Jest to reprezentowane przez słowo kluczowe
:opt-un
, które oznacza „opcjonalny, niekwalifikowany”. (W Clojure zwykłe słowo kluczowe to:cat
, podczas gdy kwalifikowane słowo kluczowe może mieć postać:animal/cat
. Kwalifikacja zwykle odbywa się na poziomie modułu — dzięki temu słowa kluczowe w różnych modułach nie będą się wzajemnie zakłócać.) - Na koniec określamy domyślny stan naszej bazy danych, czyli sposób jej inicjalizacji.
W dowolnym momencie powinniśmy być pewni, że dane w naszej bazie danych są zgodne z naszą specyfikacją.
Subskrypcje
Teraz, gdy opisaliśmy nasz model danych, musimy zastanowić się, jak nasz widok pokazuje te dane. Opisaliśmy już, jak wygląda nasz widok w naszym komponencie Redux — teraz wystarczy połączyć nasz widok z naszą bazą danych.
Dzięki Redux nie uzyskujemy bezpośredniego dostępu do naszej bazy danych — może to spowodować problemy z cyklem życia i współbieżnością. Zamiast tego rejestrujemy naszą relację z aspektem bazy danych za pośrednictwem subskrypcji .
Subskrypcja mówi, że re-frame (i Reagent), że jesteśmy zależni od części bazy danych, a jeśli ta część zostanie zmieniona, to nasz komponent Redux powinien zostać ponownie wyrenderowany.
Subskrypcje są bardzo proste do zdefiniowania:
(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)
Tutaj rejestrujemy pojedynczą subskrypcję — do samego tokena. Subskrypcja to po prostu nazwa subskrypcji i funkcja, która wyodrębnia ten element z bazy danych. Możemy zrobić, co tylko zechcemy, z tą wartością i mutować pogląd tak bardzo, jak nam się tu podoba; jednak w tym przypadku po prostu wyodrębniamy token z bazy danych i zwracamy go.
Z subskrypcjami można zrobić znacznie więcej — na przykład definiowanie widoków podsekcji bazy danych w celu uzyskania węższego zakresu ponownego renderowania — ale na razie zachowamy prostotę!
Wydarzenia
Mamy naszą bazę danych i mamy wgląd do bazy danych. Teraz musimy uruchomić kilka wydarzeń! W tym przykładzie mamy dwa rodzaje wydarzeń:
- Czyste zdarzenie ( bez efektu ubocznego) polegające na zapisaniu nowego tokena do bazy danych.
- Zdarzenie I/O ( mające efekt uboczny) wyjścia i zażądania naszego tokena poprzez interakcję z klientem.
Zaczniemy od łatwego. Funkcja Re-frame zapewnia nawet funkcję dokładnie dla tego rodzaju wydarzenia:
(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)))
Ponownie, jest to całkiem proste — zdefiniowaliśmy dwa wydarzenia. Pierwszy służy do inicjalizacji naszej bazy danych. (Widzisz, jak ignoruje oba argumenty? Zawsze inicjujemy bazę danych za pomocą naszego default-db
!) Drugi służy do przechowywania naszego tokena, gdy już go otrzymamy.
Zauważ, że żadne z tych zdarzeń nie ma skutków ubocznych — żadnych połączeń zewnętrznych, żadnych operacji we/wy! Jest to bardzo ważne dla zachowania świętości świętego procesu Redux. Nie czyńcie go nieczystym, abyście nie życzyli wam gniewu Redux.
Na koniec potrzebujemy naszego zdarzenia logowania. Umieścimy go pod pozostałymi:
(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]))))
Funkcja reg-event-fx
jest w dużej mierze podobna do reg-event-db
, chociaż istnieją pewne subtelne różnice.
- Pierwszym argumentem nie jest już sama baza danych. Zawiera wiele innych rzeczy, których możesz użyć do zarządzania stanem aplikacji.
- Drugi argument jest podobny do
reg-event-db
. - Zamiast zwracać tylko nową
db
, zamiast tego zwracamy mapę, która reprezentuje wszystkie efekty („fx”), które powinny wystąpić w przypadku tego zdarzenia. W tym przypadku po prostu wywołujemy efekt:request-token
, który jest zdefiniowany poniżej. Jednym z innych prawidłowych efektów jest:dispatch
, który po prostu wywołuje inne zdarzenie.
Gdy nasz efekt zostanie wysłany, wywoływany jest nasz efekt :request-token
, który wykonuje naszą długotrwałą operację logowania I/O. Gdy to się skończy, szczęśliwie wysyła wynik z powrotem do pętli zdarzeń, kończąc w ten sposób cykl!
Samouczek ClojureScript: ostateczny wynik
Więc! Zdefiniowaliśmy naszą abstrakcję pamięci. Jak teraz wygląda składnik?
(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]})}]])
I nasz komponent aplikacji:
(ns unearthing-clojurescript.app (:require [unearthing-clojurescript.login :as login])) ;; -- VIEW -- (defn component [] [:div [login/component]])
I wreszcie, dostęp do naszego tokena w jakimś zdalnym komponencie jest tak prosty, jak:
(let [token @(rf/subscribe [:token])] ; ... )
Kładąc wszystko razem:
Bez zamieszania, bez bałaganu.
Oddzielenie komponentów z redux/re-frame oznacza zarządzanie czystym stanem
Używając Redux (poprzez re-frame), z powodzeniem oddzieliliśmy nasze komponenty widoku od bałaganu związanego z obsługą stanów. Rozszerzanie naszej abstrakcji stanów to teraz bułka z masłem!
Redux w ClojureScript jest naprawdę taki prosty — nie masz wymówki, by nie spróbować.
Jeśli jesteś gotowy na crack, polecam sprawdzić fantastyczne dokumenty dotyczące ponownego tworzenia ramek i nasz prosty przykład. Nie mogę się doczekać przeczytania waszych komentarzy na temat tego samouczka ClojureScript poniżej. Powodzenia!