Potřebujeme Flux?

Flux je návrhový vzor spojený hlavně s knihovnou React. Je vždy potřeba s Reactem tento vzor používat? Existují i jiné přístupy, která nám práci s Reactem usnadní? Co když používám Angular? Na tyto otázky bychom měli najít odpověď v tomto článku.

Ti, kdo byli na PragueJS o Reactu nebo viděli záznam mé přednášky, možná postřehli, že jsem říkal, že Flux není v podstatě potřeba, když zvolíte jiný přístup k programování UI. Pojďme si to tedy trošku rozvést.

Co je to Flux?

Někteří z vás možná o Fluxu nikdy neslyšeli. Jedná se o návrhový vzor (lidi rádi tvrdí, že architektonický, ale to jsou všechny), který se aplikuje při návrhu jednosměrného toku dat v UI (opak oblíbeného obousměrného). Vychází z předpokladu, že výsledné UI je reprezentovatelné pomocí dat, které do něj tečou z úložišť (Store). Interakce uživatelů jsou reprezentovány pomocí akcí. Akce zpracovává Dispatcher, který následně notifikuje o změnách jednotlivá úložiště. Jeden obrázek je lepší než tisíc slov:

Flux

Na první pohled to dává smysl. Když se ale podíváme na konkrétní implementace, narazíme na některé nehezké věci. Např. komponenty znají svá úložiště, komponenty mají přístup k Dispatcheru. Další věcí je, že data – stav aplikace – jsou distribuovaná po několika úložištích aDispatcher musí často koordinovat změny na několika místech. To je v praxi celkem nepříjemné.

Flux - Stores

Některé problémy se dají obejít. Například, můžeme znalost úložiště extrahovat do komponenty typu Container, která bude komponentu do ní vloženou krmit daty z úložiště pomocí props, a tak dosáhneme znovupoužitelnosti a odstínění od znalosti konkrétního úložiště. Jak komponenta se znalostí úložišť vypadá, můžete vidět třeba v oficiální ukázce. Nevím jak vám, ale mně se tohle nelíbí. Vůbec.

„A jak jinak bys to chtěl udělat?“

Problém první: distribuovaný mutabilní stav

V odkazované ukázce je komponenta, která má vnitřní závislost na úložištích ThreadStore a MessageStore. Porušuje hned několik SOLID principů. Dále má komponenta jen svůj vnitřní stav, který je aktualizován při změně v úložištích. A pak si vemte, že v běžné aplikaci máte úložišť víc než dvě. Věci se začnou komplikovat.

Jedním z možných řešení je zříci se mutací. Když nebudeme potřebovat provádět lokální změny v datech, nebudeme ani potřebovat jednotlivá úložiště, bude nám stačit jedno jediné! No jo, ale když nebudu moci nic měnit, k čemu mi taková aplikace bude?

Mutace jsou úkazem lokálního programování. Každá data mají své místo a když dojde k jejich změně, tak je na jejich místě nahradíme. Tím ztrácíme celkem důležitý rozměr, a tím je čas. Nikdy už nebudeme vědět, jaká data byla historicky. K čemu by nám ale taková informace byla? Už jste někdy implementovali undo v aplikaci s distribuovaným mutabilním stavem? A povedlo se vám to dovést do zdárného konce? Respekt!

Immutabilní data mají tu vlastnost, že každá změna vytvoří data nová, změněná. Jistě znáte hned několik příkladů. Například, když k jedničce přičteme dvojku, tak dostaneme trojku a jednička s dvojkou mají pořád svou hodnotu. Nebo když si vezmeme podřetězec, také to původní řetězec nijak neovlivní. Immutabilitu můžeme využít ale i u komplexních datových struktur jako jsou listy, sety, vektory a mapy. A můžeme s nimi zacházet jako s hodnotami! To má hned několik výhod.

Jistě vás napadne, že to musí být ale hodně drahá sranda, vytvářet neustále nová data při každé změně. Jasně paměti i disky jsou celkem levný, ale stejně, plýtvat by se nemělo. Moderní implementace immutabilních datových struktur využívají sdílení. Když provedu nad daty nějakou změnu, většinou nezmění úplně zgruntu všechno, ale jen nějakou malou část. Nová data mohou, protože nemůže dojít ke změně, sdílet většinu dat s předchozí verzí. Prakticky se uloží jen diff. Znáte git, ne? Tak takhle přesně to funguje. :)

Takže, když eliminujeme mutace, můžeme se zbavit většiny úložišť a vytvořit jen jednoho správce celkového neměnného stavu aplikace. Někdo by mohl namítnout, “fůj, globální stav!” Ano, globální stav je fuj. Vaše databáze je taky globální stav a tu taky používáte dnes a denně. Protože  databáze nám umožňují dělat atomické změny, případně nám poskytují transakční přístup. Změna immutabilních struktur je také atomická a když všechno bude v jednom úložišti, tak transakčnost nepotřebujeme. Úložiště pak může snadno spravovat i historii. A máte undo skoro zadarmo!

import {Stack} from 'immutable';

class Application {

    constructor(initialData) {
        this.state = initialData;
        this.history = new Stack();
        this.future = new Stack();
    }

    get canUndo() {
        return this.history.some(() => true);
    }
    
    get canRedo() {
        return this.future.some(() => true);
    }

    updateData(data) {
        this.future = new Stack();
        this.history = this.history.push(this.state);
        this.state = data;
    }

    undo() {
        if (!this.canUndo) {
            return;
        }
        this.future = this.future.push(this.state);
        this.state = this.history.peek();
        this.history = this.history.pop();
    }

    redo() {
        if (!this.canRedo) {
            return;
        }
        this.history = this.history.push(this.state);
        this.state = this.future.peek();
        this.future = this.future.pop();
    }
}

Benefity nižší paměťové náročnosti získáme pouze, pokud metoda updateData bude dostávat data, která byla vytvořena změnou aktuálního stavu. Jak na to si ukážeme dále.

Když přejdeme na immutabilní stav, krom snadné implementace undo dostaneme i další výhody. React komponenty se rázem stanou mapovací funkcí, kterou aplikujeme na stav a dostaneme aktuální zobrazení. Představte si, jak se ladí chyby na dálku.

ZeserializujeteApplication (včetně jednotlivých kroků historie) a celé to pošlete někomu, kdo se vyzná. On si přehraje váš stav k sobě a vidí nejen co vidíte vy, ale i jak jste se tam dostali!

f(data0) → view0
f(data1) → view1

diff(view0, view1) → DOM changes

Další obrovskou výhodu představuje poslední řádek předchozího výpisu. Když používáme React, tak on počítá změny, ke kterým došlo od předchozího stavu, a na základě nich provede jen nutné změny v DOMu prohlížeče. Díky tomu je hodně efektivní a pekelně rychlý. Teď, když ale máme immutabilní stav, je výpočet rozdílů v datech otázkou porovnání referencí, případně hash kódu struktur. Díky tomu můžete snížit dobu vykreslování na polovinu někdy i na třetinu!

Problém druhý: natvrdo zadrátované posílání zpráv

Když se podíváme do oficiální ukázky, jak jsou implementovaná jednotlivá uložiště, tak narazíme opět na problém přímé závislosti, tentokrát na Dispatcheru, a ušlé příležitosti na polymorfní zpracování zpráv.

Již mnoho let úspěšně aplikuji vzory reaktivního programování, kde to jen dává smysl, a UI je jedno z míst, kde je reaktivní přístup k nezaplacení. Pravdou je, že Dispatcher je celkem jednoduchou implementací posílače zpráv. Což má své výhody i mnoho nevýhod. Osobně preferuji práci s reaktivními proudy. Když jsem zvažoval, jestli použít původní RxJS nebo BaconJs, natrefil jsem na malou a rychlou knihovnu Kefir.

Kefir se inspiruje Rx i Baconem, ale snaží se držet praktičnosti místo akademické čistoty. Například nevrací Disposable objekt, pro ukončení odběru zpráv. To nám ale v našem případě nevadí, k odběru zpráv se budeme hlásit většinou pouze v jednom objektu s životností jako stránka, na které je. Pravděpodobnost, že nám začne utíkat paměť neznámo kam, je velmi malá.

Takže teď uděláme z pohledu Fluxu prasárničku a spojíme Store a Dispatcher do jednoho objektu. Já si myslím, že ve skutečnosti je prasárnička tyhle věci od sebe oddělovat, protože to porušuje Single Responsibility Principle, ale opačným směrem. Jeden koncept rozkládá na menší subkoncepty a dělá z nich jakoby samostatné koncepty, i když ty tvrdé závislosti přímo křičí “my jedno jsme!”

import Kefir from 'kefir';
import {List, Stack} from 'immutable';
import {comp, filter, map} from 'transducers-js';

function register(stream, messageType, handler) {
    let xform = comp(
        filter(x => x.first() === messageType),
        map(x => x.rest()));
    stream.transduce(xform).onValue(handler);
}

class Application {

    constructor(initialData) {
        // ...
        this.stream = Kefir.emitter();
        this.on('update', x => this.updateData(x.first()));
    }

    // ... props canUndo, canRedo
    
    on(eventName, handler) {
        register(this.stream, eventName, handler);
    }

    emit() {
        this.stream.emit(new List(arguments));
    }

    // ... updateData, undo, redo
}

Třídu Application jsme rozšířili o možnost přijímání a posílání zpráv pomocí Kefírového emitteru a vystavení metod on – pro registraci k odběru jednotlivých zpráv – a emit, která zprávy posílá jako immutable List. Rovněž jsme přidali prvního příjemce zpráv typuupdate. K čemu nám bude dobrá, si ukážeme o něco později.

Obrovským benefitem reaktivních streamů je, že s nimi lze zacházet jako s kolekcemi. Jednotlivé zprávy mohu filtrovat a mapovat, redukovat a vůbec používat spoustu užitečných operátorů, které nám dovolí brutálním způsobem snížit komplexitu práce s asynchronním přístupem. Viz funkce register, která vybere zprávy požadovaného typu, vezme zbytek parametrů a nad tím pověsí handler, který tyto parametry bude přebírat a zpracovávat. Ale zpátky k vyšším cílům. :)

Aktuálně toho naše aplikace moc nedělá. Sice umí spravovat historii a komunikovat s okolím, ale to je trochu málo.
Naše data potřebujeme nějak zobrazit uživateli. K tomu využijeme React.

class Application {

    constructor(initialData, view) {
        // ...
        this.view = view;
    }
    
    // ... props & methods
    
    render() {
        this.view.setProps({
            data: this.state,
            messages: {
                emit: this.emit.bind(this),
                on: this.on.bind(this)
            },
            history: {
                canUndo: this.canUndo,
                undo: () => this.undo(),
                canRedo: this.canRedo,
                redo: () => this.redo()
            }
        });
    }
}

Nainjektujeme si React kořenovou komponentu a když zavoláme metodu render, tak jí předáme props, které obsahují kompletní stav k vyrenderování, možnost posílání zpráv a práci s historií. Všimněte si, že komponenty nepotřebují znát žádná úložiště ani Dispatcher, všechno dostanou nainjektované a potřebují znát maximálně dvě jednoduchá rozhraní.

Application

Jednoduchá implementace vzoru Inversion of Control a ani nepotřebujeme žádný kontejner!

Třídu Application máme už pomalu hotovou. Ještě nám zbývá jedna věc. Kdykoliv dojde ke změně dat, potřebujeme přerenderovat našeview. Přenášet tuhle zodpovědnost na uživatele by bylo krajně nezodpovědné, takže  využijeme reaktivního streamu a o změně stavu hezky informujeme zprávou state-changed. A k ní se i rovnou přihlásíme a vyvoláme přerenderování.

class Application {

    constructor(initialData, view) {
        // ...
        this.on('state-changed', () => this.render());
    }
    
    // ... 

    updateData(data) {
        // ...
        this.emit('state-changed', 'updateData', this.state);
    }

    undo() {
        // ...
        this.emit('state-changed', 'undo', this.state);
    }

    redo() {
        // ...
        this.emit('state-changed', 'redo', this.state);
    }
    
    // ...
}

Proč si posíláme i data, co změnu stavu vyvolalo a jaký ten stav je, když to v handleru neřešíme? I když je to porušení 4 pravidel jednoduchého návrhu, tak přidáním kouzelného řádku do konstruktoru získáme silný debugovací nástroj:

this.stream.map(x => x.toJS()).log('stream');

Nový problém: potřeba lokálních změn

Vyřešili jsme dva problémy, ale vytvořili jsme tím problém zcela nový. Pokud potřebujeme v komponentách udělat sebemenší změnu stavu, musí komponenta znát jeho strukturu, aby mohla změnu provést. Což, jak jistě uznáte, není vůbec elegantní ani jednoduché. Najednou přenášíme komplexitu, kterou za nás spravovala jednotlivá úložiště, přímo do komponent, které mají sloužit pouze k zobrazovaní dat. Ha!

Naštěstí jde o problém, který jde vyřešit velice snadno pomocí kurzorů! Kurzor je v podstatě pohled na některou z větví nebo listů celkového stavu. Kurzor si pamatuje cestu, kde se ve stromu nachází, a tváří se, jako by šlo jen o data, která potřebujeme v konkrétní komponentě. Kurzor můžete implementovat třeba jako to dělá estejs, já pro svou jednoduchost použiju contrib implementaci z Immutable.js:

import Cursor from 'immutable/contrib/cursor';
import {Component} from 'react';

class ApplicationView extends Component {

    cursorFor(keyPath) {
        let update = x => this.props.messages.emit('update', x);
        return Cursor.from(this.props.data, keyPath, update);
    }

    render() {
        return (
            <SubComponent data={this.cursorFor(['path', 'to', 'nested', 'data'])} />
        );
    }
}

Co je důležité, že v případě contrib implementace se do update předává celý strom, který obsahuje lokálně provedené změny. Ten pošleme ve zprávě update zpátky do Application a tím se nám celé kolečko uzavře. Problém vyřešen.

Co když používám AngularJS?

“To je všechno hezký, co tady ukazuješ, ale co když používám Angular?”

AngularJS  používá obousměrný data-binding, který je o hodně komplexnější, ale benefity, které nám přináší immutabilní datové struktury, můžeme čerpat i v Angularu. Dirty checking je jedno z nejbolestivějších míst a immutabilní data nám ho pomůžou vyléčit stejně jako u Reactu. Zkuste knihovnu angular-immutable, hodně věcí vám usnadní a bude to znát i na výkonu. Co se týče posílání zpráv pomocí reaktivních streamů, můžete použít třeba Rx.angular.

Závěr

Asi jsem to explicitně nezmínil, ale všechnu tu zbytečnou komplexitu se nám podařilo vyhnat díky aplikaci několika málo návrhových principů a technik. SOLID a 4 pravidla jednoduchého návrhu už jsem zmiňoval. A ty techniky? Věřte nebo ne, jde o funkcionální programování bez akademických strašáků. Ruku v ruce s objekty. A ačkoliv se v článku nevyskytuje ani jeden test, veškerá funkcionalita v článku popsaná byla vytvořena pomocí TDD. Nevěříte?

test-results

Zdrojové kódy z ukázek jsem zkompletoval a najdete je v odkazovaném gistu.

Extrémní programátor webových aplikací v TopMonks. Host a facilitátor českých Code Retreatů.

Věděli jste, že nám můžete zasílat zprávičky? (Jen pro přihlášené.)

Komentáře: 40

Přehled komentářů

steida Vynikající článek Aleši, bravo.
Aleš Roubíček Re: Vynikající článek Aleši, bravo.
steida Re: Vynikající článek Aleši, bravo.
Aleš Roubíček Re: Vynikající článek Aleši, bravo.
AltmanO Re: Vynikající článek Aleši, bravo.
Aleš Roubíček Re: Vynikající článek Aleši, bravo.
AltmanO Re: Vynikající článek Aleši, bravo.
steida oprava
AltmanO Om.js ?
Aleš Roubíček Re: Om.js ?
tobik Pár poznámek
Aleš Roubíček Re: Pár poznámek
tobik Re: Pár poznámek
Aleš Roubíček Re: Pár poznámek
tobik Re: Pár poznámek
jiri.vrany Re: Pár poznámek
steida
tobik Re:
tacoberu Re:
Aleš Roubíček Re:
tacoberu Re:
steida
tobik Re:
Aleš Roubíček Re:
tobik Re:
Jenda Re:
Jiří Knesl (autor) Re:
Jiří Knesl Re:
Skalimil Vuk ES 6
Aleš Roubíček Re: ES 6
Skalimil Vuk Re: ES 6
Aleš Roubíček Re: ES 6
Skalimil Vuk Re: ES 6
Skalimil Vuk Re: ES 6
Aleš Roubíček Re: ES 6
Kolemjdouci
Aleš Roubíček Re:
petr kolik streamu pouzivat v aplikaci?
Aleš Roubíček Re: kolik streamu pouzivat v aplikaci?
Petr Akce v akci
Zdroj: https://www.zdrojak.cz/?p=14532