Krasimir Tsonev

O autorze

Krasimir Tsonev jest koderem z ponad dziesięcioletnim doświadczeniem w tworzeniu stron internetowych. Autor dwóch książek o Node.js. Pracuje jako senior front-end developer dla …More aboutKrasimir↬

  • 20 min lektury
  • UI,Apps,JavaScript
  • Zapisany do czytania w trybie offline
  • Share on Twitter, LinkedIn
Rozwój UI stał się trudny w ciągu ostatnich kilku lat. To dlatego, że zepchnęliśmy zarządzanie stanem do przeglądarki. A zarządzanie stanem jest tym, co sprawia, że nasza praca jest wyzwaniem. Jeśli zrobimy to poprawnie, zobaczymy jak nasza aplikacja łatwo się skaluje bez żadnych błędów. W tym artykule zobaczymy, jak wykorzystać koncepcję maszyny stanowej do rozwiązywania problemów związanych z zarządzaniem stanem.

Jest już rok 2018, a niezliczone rzesze front-end developerów wciąż prowadzą walkę ze złożonością i nieruchawością. Miesiąc po miesiącu szukają świętego Graala: wolnej od błędów architektury aplikacji, która pomoże im dostarczać szybko i z wysoką jakością. Jestem jednym z tych programistów i znalazłem coś ciekawego, co może pomóc.

Zrobiliśmy spory krok naprzód dzięki narzędziom takim jak React i Redux. Jednak same w sobie nie są one wystarczające w aplikacjach na dużą skalę. Ten artykuł przybliży Ci pojęcie maszyn stanów w kontekście front-end developmentu. Prawdopodobnie zbudowałeś już kilka z nich nie zdając sobie z tego sprawy.

Wprowadzenie do maszyn stanów

Maszyna stanów jest matematycznym modelem obliczeń. Jest to abstrakcyjna koncepcja, dzięki której maszyna może mieć różne stany, ale w danym momencie spełnia tylko jeden z nich. Istnieją różne typy maszyn stanów. Najsłynniejszym z nich jest, jak sądzę, maszyna Turinga. Jest ona nieskończoną maszyną stanów, co oznacza, że może mieć niezliczoną liczbę stanów. Maszyna Turinga nie pasuje dobrze do dzisiejszego rozwoju UI, ponieważ w większości przypadków mamy skończoną liczbę stanów. Dlatego właśnie maszyny skończonych stanów, takie jak Mealy i Moore, mają więcej sensu.

Różnica między nimi polega na tym, że maszyna Moore’a zmienia swój stan tylko na podstawie poprzedniego stanu. Niestety, mamy wiele czynników zewnętrznych, takich jak interakcje użytkownika i procesy sieciowe, co oznacza, że maszyna Moore’a również nie jest dla nas wystarczająco dobra. To, czego szukamy, to maszyna Mealy’ego. Ma ona stan początkowy, a następnie przechodzi do nowych stanów w oparciu o dane wejściowe i swój stan bieżący.

Jednym z najprostszych sposobów na zilustrowanie działania maszyny stanowej jest spojrzenie na bramkę obrotową. Ma on skończoną liczbę stanów: zablokowany i odblokowany. Oto prosta grafika, która pokazuje nam te stany wraz z ich możliwymi wejściami i przejściami.

turnstile

Stan początkowy kołowrotu jest zablokowany. Bez względu na to ile razy go naciśniemy, pozostaje on w tym stanie zablokowany. Jeśli jednak wrzucimy do niego monetę, wówczas przejdzie on do stanu odblokowanego. Kolejna moneta w tym momencie nic by nie zrobiła; nadal byłby w stanie odblokowanym. Pchnięcie z drugiej strony zadziałałoby i moglibyśmy przejść. Ta akcja również przełącza maszynę do początkowego stanu zablokowania.

Gdybyśmy chcieli zaimplementować pojedynczą funkcję, która kontroluje kołowrót, prawdopodobnie skończylibyśmy z dwoma argumentami: aktualnym stanem i akcją. A jeśli używasz Reduxa, to prawdopodobnie brzmi to dla Ciebie znajomo. Jest to podobne do dobrze znanej funkcji reduktora, gdzie otrzymujemy aktualny stan, a na podstawie ładunku akcji decydujemy, jaki będzie następny stan. Reduktor jest przejściem w kontekście maszyn stanów. Tak naprawdę każda aplikacja, która posiada stan, który możemy w jakiś sposób zmienić, może być nazwana maszyną stanów. Chodzi tylko o to, że implementujemy wszystko ręcznie i tak w kółko.

Jak maszyna stanów jest lepsza?

W pracy używamy Reduxa i jestem z niego całkiem zadowolony. Jednak zacząłem dostrzegać wzorce, które mi się nie podobają. Mówiąc „nie lubię”, nie mam na myśli tego, że nie działają. Chodzi raczej o to, że dodają złożoności i zmuszają mnie do pisania większej ilości kodu. Musiałem podjąć się realizacji pobocznego projektu, w którym miałem pole do eksperymentowania, i postanowiłem przemyśleć nasze praktyki rozwoju Reacta i Reduxa. Zacząłem robić notatki na temat rzeczy, które mnie niepokoiły i zdałem sobie sprawę, że abstrakcja maszyny stanów naprawdę rozwiązałaby niektóre z tych problemów. Wskoczmy więc do środka i zobaczmy, jak zaimplementować maszynę stanów w JavaScript.

Zaatakujemy prosty problem. Chcemy pobrać dane z back-end API i wyświetlić je użytkownikowi. Pierwszym krokiem jest nauczenie się, jak myśleć w stanach, a nie w przejściach. Zanim przejdziemy do maszyn stanów, mój tok pracy przy budowaniu takiej funkcjonalności wyglądał mniej więcej tak:

  • Wyświetlamy przycisk pobierz dane.
  • Użytkownik klika przycisk pobierz dane.
  • Wywołujemy żądanie do back-endu.
  • Pobieramy dane i parsujemy je.
  • Pokazujemy je użytkownikowi.
  • Albo, jeśli wystąpił błąd, wyświetl komunikat o błędzie i pokaż przycisk fetch-data, abyśmy mogli uruchomić proces ponownie.
myślenie liniowe

Myślimy liniowo i w zasadzie staramy się ogarnąć wszystkie możliwe kierunki do końcowego wyniku. Jeden krok prowadzi do drugiego, i szybko zaczęlibyśmy rozgałęziać nasz kod. A co z problemami takimi jak podwójne kliknięcie przycisku przez użytkownika, kliknięcie przycisku przez użytkownika w oczekiwaniu na odpowiedź back-endu, czy też powodzenie żądania, ale uszkodzenie danych. W takich przypadkach, prawdopodobnie mielibyśmy różne flagi, które pokazałyby nam co się stało. Posiadanie flag oznacza więcej if klauzul i, w bardziej złożonych aplikacjach, więcej konfliktów.

myślenie liniowe

To dlatego, że myślimy w kategoriach przejść. Skupiamy się na tym, jak te przejścia zachodzą i w jakiej kolejności. Skupienie się zamiast tego na różnych stanach aplikacji byłoby o wiele prostsze. Ile mamy stanów i jakie są ich możliwe wejścia? Używając tego samego przykładu:

  • idle
    W tym stanie, wyświetlamy przycisk fetch-data, siedzimy i czekamy. Możliwe akcje to:
    • kliknięcie
      Gdy użytkownik kliknie przycisk, wysyłamy żądanie do back-endu, a następnie przechodzimy maszynę do stanu „pobierania”.
  • pobieranie
    Żądanie jest w locie, a my siedzimy i czekamy. Akcje są następujące:
    • sukces
      Dane docierają pomyślnie i nie są uszkodzone. Używamy tych danych w jakiś sposób i wracamy do stanu „idle”.
    • failure
      Jeśli wystąpił błąd podczas wykonywania żądania lub przetwarzania danych, przechodzimy do stanu „error”.
  • error
    Pokazujemy komunikat o błędzie i wyświetlamy przycisk fetch-data. Ten stan akceptuje jedną akcję:
    • retry
      Kiedy użytkownik kliknie przycisk retry, odpalamy żądanie ponownie i przechodzimy do stanu „fetching”.

Opisaliśmy mniej więcej te same procesy, ale ze stanami i wejściami.

maszyna stanowa

Upraszcza to logikę i czyni ją bardziej przewidywalną. Rozwiązuje również niektóre z problemów wymienionych powyżej. Zauważ, że gdy jesteśmy w stanie „fetching”, nie akceptujemy żadnych kliknięć. Tak więc, nawet jeśli użytkownik kliknie przycisk, nic się nie stanie, ponieważ maszyna nie jest skonfigurowana do reagowania na tę akcję, gdy znajduje się w tym stanie. Takie podejście automatycznie eliminuje nieprzewidywalne rozgałęzianie się logiki naszego kodu. Oznacza to, że będziemy mieli mniej kodu do ogarnięcia podczas testowania. Ponadto, niektóre rodzaje testów, takie jak testy integracyjne, mogą być zautomatyzowane. Pomyśl o tym, że mając naprawdę jasny pomysł na to, co robi nasza aplikacja, możemy stworzyć skrypt, który przejdzie przez zdefiniowane stany i przejścia i wygeneruje asercje. Te asercje mogłyby udowadniać, że osiągnęliśmy każdy możliwy stan lub przebyliśmy określoną drogę.

W rzeczywistości, spisanie wszystkich możliwych stanów jest łatwiejsze niż spisanie wszystkich możliwych przejść, ponieważ wiemy, których stanów potrzebujemy lub które mamy. Przy okazji, w większości przypadków, stany opisywałyby logikę biznesową naszej aplikacji, podczas gdy przejścia są bardzo często nieznane na początku. Błędy w naszym oprogramowaniu są wynikiem akcji wysłanych w złym stanie i/lub w złym czasie. Pozostawiają one naszą aplikację w stanie, o którym nie wiemy, a to łamie nasz program lub sprawia, że zachowuje się on niepoprawnie. Oczywiście, nie chcemy znaleźć się w takiej sytuacji. Maszyny stanów są dobrymi firewallami. Chronią nas przed osiągnięciem nieznanych stanów, ponieważ wyznaczamy granice tego, co i kiedy może się wydarzyć, nie mówiąc wprost jak. Koncepcja maszyny stanów bardzo dobrze łączy się z jednokierunkowym przepływem danych. Razem, redukują one złożoność kodu i wyjaśniają tajemnicę skąd pochodzi dany stan.

Tworzenie maszyny stanów w JavaScript

Dość gadania – zobaczmy trochę kodu. Użyjemy tego samego przykładu. Bazując na powyższej liście, zaczniemy od następujących rzeczy:

const machine = { 'idle': { click: function () { ... } }, 'fetching': { success: function () { ... }, failure: function () { ... } }, 'error': { 'retry': function () { ... } }}

Mamy stany jako obiekty i ich możliwe wejścia jako funkcje. Brakuje nam jednak stanu początkowego. Zmieńmy powyższy kod na taki:

const machine = { state: 'idle', transitions: { 'idle': { click: function() { ... } }, 'fetching': { success: function() { ... }, failure: function() { ... } }, 'error': { 'retry': function() { ... } } }}

Po zdefiniowaniu wszystkich stanów, które mają dla nas sens, jesteśmy gotowi do wysłania danych wejściowych i zmiany stanu. Zrobimy to za pomocą dwóch poniższych metod helpera:

const machine = { dispatch(actionName, ...payload) { const actions = this.transitions; const action = this.transitions; if (action) { action.apply(machine, ...payload); } }, changeStateTo(newState) { this.state = newState; }, ...}

Funkcja dispatch sprawdza, czy w przejściach bieżącego stanu istnieje akcja o podanej nazwie. Jeśli tak, to odpala ją z podanym payloadem. Wywołujemy również action handler z machine jako kontekstem, dzięki czemu możemy wysyłać inne akcje za pomocą this.dispatch(<action>) lub zmieniać stan za pomocą this.changeStateTo(<new state>).

Podążając za podróżą użytkownika w naszym przykładzie, pierwszą akcją, którą musimy wysłać jest click. Oto jak wygląda handler tej akcji:

transitions: { 'idle': { click: function () { this.changeStateTo('fetching'); service.getData().then( data => { try { this.dispatch('success', JSON.parse(data)); } catch (error) { this.dispatch('failure', error) } }, error => this.dispatch('failure', error) ); } }, ...}machine.dispatch('click');

Najpierw zmieniamy stan maszyny na fetching. Następnie uruchamiamy żądanie do back-endu. Załóżmy, że mamy usługę z metodą getData która zwraca obietnicę. Gdy zostanie ona rozwiązana, a parsowanie danych jest w porządku, wysyłamy success, jeśli nie failure.

Jak na razie wszystko w porządku. Następnie musimy zaimplementować success i failure akcje i wejścia w ramach stanu fetching:

transitions: { 'idle': { ... }, 'fetching': { success: function (data) { // render the data this.changeStateTo('idle'); }, failure: function (error) { this.changeStateTo('error'); } }, ...}

Zauważ, jak uwolniliśmy nasz mózg od konieczności myślenia o poprzednim procesie. Nie obchodzą nas kliknięcia użytkownika ani to, co dzieje się z żądaniem HTTP. Wiemy, że aplikacja jest w stanie fetching i oczekujemy tylko tych dwóch akcji. Jest to trochę jak pisanie nowej logiki w izolacji.

Ostatni bit to stan error. Dobrze by było, gdybyśmy udostępnili tę logikę retry, aby aplikacja mogła odzyskać stan po niepowodzeniu.

transitions: { 'error': { retry: function () { this.changeStateTo('idle'); this.dispatch('click'); } }}

W tym miejscu musimy zduplikować logikę, którą napisaliśmy w handlerze click. Aby tego uniknąć, powinniśmy albo zdefiniować handler jako funkcję dostępną dla obu akcji, albo najpierw przejść do stanu idle, a następnie ręcznie wyekspediować akcję click.

Pełny przykład działającej maszyny stanów można znaleźć w moim Codepen.

Managing State Machines With A Library

Wzorzec maszyny stanów skończonych działa niezależnie od tego, czy używamy Reacta, Vue czy Angulara. Jak widzieliśmy w poprzednim rozdziale, możemy łatwo zaimplementować maszynę stanów bez większych problemów. Czasami jednak jakaś biblioteka zapewnia większą elastyczność. Jednymi z dobrych są Machina.js oraz XState. W tym artykule omówimy jednak Stent, moją bibliotekę podobną do Reduxa, która zawiera w sobie koncepcję skończonych maszyn stanów.

Stent jest implementacją kontenera maszyn stanów. Podąża on za niektórymi pomysłami z projektów Redux i Redux-Saga, ale zapewnia, moim zdaniem, prostsze i pozbawione kotła procesy. Jest rozwijany przy użyciu readme-driven development, a ja dosłownie spędziłem tygodnie tylko na projektowaniu API. Ponieważ pisałem bibliotekę, miałem okazję naprawić problemy, które napotkałem podczas używania architektur Redux i Flux.

Tworzenie maszyn

W większości przypadków nasze aplikacje obejmują wiele domen. Nie możemy korzystać tylko z jednej maszyny. Dlatego Stent pozwala na tworzenie wielu maszyn:

import { Machine } from 'stent';const machineA = Machine.create('A', { state: ..., transitions: ...});const machineB = Machine.create('B', { state: ..., transitions: ...});

Później możemy uzyskać dostęp do tych maszyn za pomocą metody Machine.get:

const machineA = Machine.get('A');const machineB = Machine.get('B');

Connecting The Machines To The Rendering Logic

Rendering w moim przypadku odbywa się za pomocą Reacta, ale możemy użyć dowolnej innej biblioteki. Sprowadza się to do odpalenia callbacka, w którym wywołujemy renderowanie. Jedną z pierwszych funkcji, nad którą pracowałem była funkcja connect:

import { connect } from 'stent/lib/helpers';Machine.create('MachineA', ...);Machine.create('MachineB', ...);connect() .with('MachineA', 'MachineB') .map((MachineA, MachineB) => { ... rendering here });

Powiadamy, które maszyny są dla nas ważne i podajemy ich nazwy. Callback, który przekazujemy do map jest odpalany raz na początku, a później za każdym razem, gdy zmieni się stan którejś z maszyn. To właśnie w tym miejscu wywołujemy renderowanie. W tym momencie mamy bezpośredni dostęp do podłączonych maszyn, więc możemy pobrać aktualny stan i metody. Istnieją również mapOnce, aby uzyskać callback odpalony tylko raz, oraz mapSilent, aby pominąć to początkowe wykonanie.

Dla wygody, helper jest wyeksportowany specjalnie dla integracji React. Jest on naprawdę podobny do Redux’owego connect(mapStateToProps).

import React from 'react';import { connect } from 'stent/lib/react';class TodoList extends React.Component { render() { const { isIdle, todos } = this.props; ... }}// MachineA and MachineB are machines defined// using Machine.create functionexport default connect(TodoList) .with('MachineA', 'MachineB') .map((MachineA, MachineB) => { isIdle: MachineA.isIdle, todos: MachineB.state.todos });

Stent uruchamia nasze wywołanie zwrotne mapowania i oczekuje otrzymania obiektu – obiektu, który jest wysyłany jako props do naszego komponentu React.

Czym jest stan w kontekście Stenta?

Do tej pory, naszym stanem były proste ciągi znaków. Niestety, w prawdziwym świecie, musimy trzymać w stanie coś więcej niż ciąg znaków. Dlatego też stan Stenta jest tak naprawdę obiektem z właściwościami wewnątrz. Jedyną zarezerwowaną właściwością jest name. Wszystko inne to dane specyficzne dla danej aplikacji. Na przykład:

{ name: 'idle' }{ name: 'fetching', todos: }{ name: 'forward', speed: 120, gear: 4 }

Moje dotychczasowe doświadczenie ze Stentem pokazuje mi, że jeśli obiekt stanu stanie się większy, prawdopodobnie będziemy potrzebowali innej maszyny, która obsłuży te dodatkowe właściwości. Identyfikacja różnych stanów zajmuje trochę czasu, ale uważam, że jest to duży krok naprzód w pisaniu bardziej zarządzalnych aplikacji. Jest to trochę jak przewidywanie przyszłości i rysowanie ramek z możliwymi działaniami.

Praca z maszyną stanów

Podobnie jak w przykładzie na początku, musimy zdefiniować możliwe (skończone) stany naszej maszyny i opisać możliwe wejścia:

import { Machine } from 'stent';const machine = Machine.create('sprinter', { state: { name: 'idle' }, // initial state transitions: { 'idle': { 'run please': function () { return { name: 'running' }; } }, 'running': { 'stop now': function () { return { name: 'idle' }; } } }});

Mamy nasz stan początkowy, idle, który akceptuje akcję run. Gdy maszyna znajdzie się w stanie running, jesteśmy w stanie odpalić akcję stop, która przywraca nas do stanu idle.

Pewnie pamiętasz pomocników dispatch i changeStateTo z naszej wcześniejszej implementacji. Ta biblioteka dostarcza tej samej logiki, ale jest ona ukryta wewnętrznie i nie musimy się nad nią zastanawiać. Dla wygody, na podstawie właściwości transitions, Stent generuje następujące elementy:

  • metodyhelper sprawdzające czy maszyna jest w danym stanie – stan idle produkuje metodę isIdle(), natomiast dla running mamy isRunning();
  • metody pomocnicze do wysyłania akcji: runPlease() oraz stopNow().

Więc, w powyższym przykładzie, możemy użyć tego:

machine.isIdle(); // booleanmachine.isRunning(); // booleanmachine.runPlease(); // fires actionmachine.stopNow(); // fires action

Połączenie automatycznie wygenerowanych metod z funkcją użytkową connect, jesteśmy w stanie zamknąć koło. Interakcja użytkownika wyzwala wejście i działanie maszyny, która aktualizuje stan. Z powodu tej aktualizacji, funkcja mapująca przekazana do connect zostaje odpalona, a my zostajemy poinformowani o zmianie stanu. Następnie, wykonujemy reerender.

Input And Action Handlers

Prawdopodobnie najważniejszym elementem są action handlers. Jest to miejsce, w którym piszemy większość logiki aplikacji, ponieważ reagujemy na dane wejściowe i zmienione stany. Coś, co bardzo lubię w Reduxie jest tutaj również zintegrowane: niezmienność i prostota funkcji reduktora. Istota obsługi akcji w Stencie jest taka sama. Otrzymuje on aktualny stan i ładunek akcji, i musi zwrócić nowy stan. Jeśli handler nie zwraca nic (undefined), to stan maszyny pozostaje taki sam.

transitions: { 'fetching': { 'success': function (state, payload) { const todos = ; return { name: 'idle', todos }; } }}

Załóżmy, że musimy pobrać dane ze zdalnego serwera. Odpalamy żądanie i przechodzimy maszynę do stanu fetching. Gdy dane przyjdą z backendu, odpalamy akcję success, tak jak poniżej:

machine.success({ label: '...' });

Potem wracamy do stanu idle i zachowujemy pewne dane w postaci tablicy todos. Jest jeszcze kilka innych możliwych wartości, które można ustawić jako handlery akcji. Pierwszy i najprostszy przypadek to taki, w którym przekazujemy tylko ciąg znaków, który staje się nowym stanem.

transitions: { 'idle': { 'run': 'running' }}

To jest przejście z { name: 'idle' } do { name: 'running' } przy użyciu akcji run(). To podejście jest przydatne, gdy mamy synchroniczne przejścia stanów i nie mamy żadnych meta danych. Tak więc, jeśli trzymamy coś innego w stanie, ten typ przejścia go wypłucze. Podobnie, możemy przekazać obiekt stanu bezpośrednio:

transitions: { 'editing': { 'delete all todos': { name: 'idle', todos: } }}

Przechodzimy z editing do idle używając akcji deleteAllTodos.

Widzieliśmy już handler funkcji, a ostatnim wariantem handler’a akcji jest funkcja generatora. Jest ona inspirowana projektem Redux-Saga, a wygląda tak:

import { call } from 'stent/lib/helpers';Machine.create('app', { 'idle': { 'fetch data': function * (state, payload) { yield { name: 'fetching' } try { const data = yield call(requestToBackend, '/api/todos/', 'POST'); return { name: 'idle', data }; } catch (error) { return { name: 'error', error }; } } }});

Jeśli nie masz doświadczenia z generatorami, może to wyglądać nieco krypto. Ale generatory w JavaScript są potężnym narzędziem. Możemy wstrzymywać nasz handler akcji, zmieniać stan wiele razy i obsługiwać logikę async.

Zabawa z generatorami

Kiedy po raz pierwszy zostałem wprowadzony do Redux-Saga, myślałem, że jest to zbyt skomplikowany sposób na obsługę operacji async. W rzeczywistości, jest to całkiem sprytna implementacja wzorca projektowego command. Główną zaletą tego wzorca jest to, że oddziela on wywoływanie logiki od jej faktycznej implementacji.

Innymi słowy, mówimy co chcemy, ale nie jak to powinno się wydarzyć. Seria blogów Matt’a Hink’a pomogła mi zrozumieć jak sagi są implementowane, i gorąco polecam jej przeczytanie. Przyniosłem te same pomysły do Stenta, a na potrzeby tego artykułu powiemy, że poprzez yielding stuff, dajemy instrukcje na temat tego, czego chcemy, bez faktycznego wykonywania tego. Kiedy akcja jest wykonywana, otrzymujemy kontrolę z powrotem.

W chwili obecnej, kilka rzeczy może zostać wysłanych (yielded):

  • obiekt stanu (lub łańcuch znaków) służący do zmiany stanu maszyny;
  • wywołanie helpera call (przyjmuje on funkcję synchroniczną, czyli taką, która zwraca obietnicę lub inną funkcję generatora) – w zasadzie mówimy: „Uruchom to dla mnie, a jeśli jest to asynchroniczne, poczekaj. Kiedy skończysz, daj mi wynik.”;
  • wywołanie helpera wait (przyjmuje on łańcuch reprezentujący inną akcję); jeśli użyjemy tej funkcji, wstrzymamy handler i poczekamy na inną akcję do wysłania.

Tutaj znajduje się funkcja, która ilustruje te warianty:

const fireHTTPRequest = function () { return new Promise((resolve, reject) => { // ... });}...transitions: { 'idle': { 'fetch data': function * () { yield 'fetching'; // sets the state to { name: 'fetching' } yield { name: 'fetching' }; // same as above // wait for getTheData and checkForErrors actions // to be dispatched const = yield wait('get the data', 'check for errors'); // wait for the promise returned by fireHTTPRequest // to be resolved const result = yield call(fireHTTPRequest, '/api/data/users'); return { name: 'finish', users: result }; } }}

Jak widzimy, kod wygląda na synchroniczny, ale w rzeczywistości taki nie jest. To po prostu Stent wykonuje nudną część czekania na rozwiązaną obietnicę lub iterację po innym generatorze.

Jak Stent rozwiązuje moje problemy z Reduxem

Zbyt dużo kodu z płytkami

Architektura Reduxa (i Fluxa) opiera się na akcjach, które krążą w naszym systemie. Kiedy aplikacja się rozrasta, zwykle kończy się na tym, że mamy dużo stałych i twórców akcji. Te dwie rzeczy bardzo często znajdują się w różnych folderach, a śledzenie wykonania kodu czasami zajmuje dużo czasu. Ponadto, gdy dodajemy nową funkcjonalność, zawsze mamy do czynienia z całym zestawem akcji, co oznacza definiowanie kolejnych nazw akcji i kreatorów akcji.

W Stencie nie mamy nazw akcji, a biblioteka automatycznie tworzy za nas kreatory akcji:

const machine = Machine.create('todo-app', { state: { name: 'idle', todos: }, transitions: { 'idle': { 'add todo': function (state, todo) { ... } } }});machine.addTodo({ title: 'Fix that bug' });

Mamy machine.addTodo kreator akcji zdefiniowany bezpośrednio jako metoda maszyny. To podejście rozwiązało również inny problem, z którym się zmierzyłem: znalezienie reduktora, który odpowiada na konkretną akcję. Zazwyczaj w komponentach React widzimy nazwy kreatorów akcji, takie jak addTodo; jednak w reduktorach pracujemy z typem akcji, który jest stały. Czasami muszę przeskakiwać do kodu twórcy akcji tylko po to, aby zobaczyć dokładny typ. Tutaj nie mamy w ogóle typów.

Nieprzewidywalne zmiany stanu

Ogólnie rzecz biorąc, Redux dobrze radzi sobie z zarządzaniem stanem w niezmienny sposób. Problem nie leży w samym Reduxie, ale w tym, że programista może w każdej chwili wysłać dowolną akcję. Jeśli powiemy, że mamy akcję, która włącza światło, to czy jest w porządku odpalić ją dwa razy z rzędu? Jeśli nie, to jak mamy rozwiązać ten problem z Reduxem? Cóż, prawdopodobnie umieścilibyśmy jakiś kod w reducerze, który chroniłby logikę i sprawdzał czy światła są już włączone – może if klauzula, która sprawdza aktualny stan. Teraz pytanie brzmi, czy nie jest to poza zakresem reduktora? Czy reduktor powinien wiedzieć o takich przypadkach brzegowych?

To, czego brakuje mi w Reduxie, to sposób na zatrzymanie wysyłania akcji w oparciu o bieżący stan aplikacji bez zanieczyszczania reduktora logiką warunkową. I nie chcę też przenosić tej decyzji do warstwy widoku, gdzie odpalany jest kreator akcji. W Stencie dzieje się to automatycznie, ponieważ maszyna nie reaguje na akcje, które nie są zadeklarowane w bieżącym stanie. Na przykład:

const machine = Machine.create('app', { state: { name: 'idle' }, transitions: { 'idle': { 'run': 'running', 'jump': 'jumping' }, 'running': { 'stop': 'idle' } }});// this is finemachine.run();// This will do nothing because at this point// the machine is in a 'running' state and there is// only 'stop' action there.machine.jump();

To, że maszyna akceptuje tylko określone dane wejściowe w danym momencie, chroni nas przed dziwnymi błędami i sprawia, że nasze aplikacje są bardziej przewidywalne.

Stany, nie przejścia

Redux, podobnie jak Flux, zmusza nas do myślenia w kategoriach przejść. Model mentalny tworzenia aplikacji z Reduxem jest w dużej mierze napędzany przez akcje i to, jak te akcje przekształcają stan w naszych reduktorach. To nie jest złe, ale odkryłem, że bardziej sensowne jest myślenie w kategoriach stanów – w jakich stanach może znajdować się aplikacja i jak te stany reprezentują wymagania biznesowe.

Podsumowanie

Koncepcja maszyn stanów w programowaniu, szczególnie w rozwoju UI, była dla mnie otwierająca oczy. Zacząłem widzieć maszyny stanowe wszędzie i mam pewne pragnienie, aby zawsze przestawić się na ten paradygmat. Zdecydowanie widzę korzyści płynące z posiadania bardziej ściśle zdefiniowanych stanów i przejść pomiędzy nimi. Zawsze szukam sposobów na to, aby moje aplikacje były proste i czytelne. Wierzę, że maszyny stanów są krokiem w tym kierunku. Koncepcja jest prosta i jednocześnie potężna. Ma potencjał, aby wyeliminować wiele błędów.

Smashing Editorial(rb, ra, al, il)

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *