V minulém díle série Redux modulárně jsme probrali, jaká byla východiska pro náš systém modulů. Řekli jsme si, že děláme potenciálně velké informační systémy s větším množstvím podobných obrazovek. Představili jsme náš technologický stack, který tvoří především React, Redux a Redux-Saga. Také jsme si krátce popsali, čemu rozumíme pod pojmem modulární design. Důležitá je u něj pro nás především znovupoužitelnost a logické oddělení.

V tomto článku si ukážeme, jak přesně takový modul vypadá.

Myšlenku modulů přiblížím postupně, jako proces přechodu od jednoduché aplikace k obrazovkovým a sdíleným modulům. My jsme je samozřejmě takhle nezaváděli. Když jsme moduly navrhovali, vycházeli jsme ze skvělého článku Jacka Hsu Strukturování reduxových aplikací a postupně jeho návrh, alespoň z našeho pohledu, zdokonalovali. Přesto se mi následující myšlenkový proces líbí, protože elegantně ukazuje, jak je možné o modulech uvažovat.

Obrazovkové moduly

Pokud máme úplně jednoduchou aplikaci, je přirozené rozdělit její části do souborů podle rolí. Reducer je v zásadě jen jeden, proto je v souboru reducer.js, sága taky (soubor saga.js). Generátory akcí jsou typicky jednoduché funkce (pokud používáme ságy, u thunků by to bylo jinak), proto má smysl je sdružit do souboru actions.js. U komponent dává smysl mít každou ve svém vlastním souboru. Takto máme každou vrstvu aplikace zvlášť.
actions.js
reducer.js
saga.js
Container.js
Component1.js
Component2.js

Naše aplikace se však typicky skládají z více obrazovek (v angličtině také route – viz react-router). Sice bychom stále mohli mít všechno na jednom místě, ale pokud těch obrazovek bude hodně, už tato struktura pravděpodobně nebude udržitelná.

Přitom bychom mohli jednotlivé obrazovky oddělit. Proč? Mnoho úkolů lze definovat pouze v rámci jedné obrazovky. Pokud jako vývojář zasahuju do kódu jedné obrazovky, je pro mě zásadní výhodou, když vím, že nemůžu ovlivnit nic dalšího. Navíc mám všechny soubory, do kterých zasahuju, na jednom místě. Tohle bych považoval za obecně dobrý princip při vytváření modulů: dávat dohromady části aplikace, které se často mění zároveň.

Ideálně bychom tedy chtěli, aby každá obrazovka měla své vlastní generátory akcí, ságu a reducer – část stavu. Můžeme předpokládat, že budou používat stejné komponenty, ale ty můžeme všechny extrahovat do složky components. Každá obrazovka by měla samozřejmě mít své vlastní kontejnery – komponenty napojené na reduxový stav. Každopádně bychom ale chtěli, aby obrazovky nesdílely data a funkce (princip DRY) a neovlivňovaly se.

Pokud by se nám podařilo obrazovky izolovat, struktura aplikace by vypadala takto:
screen1
actions.js
reducer.js
saga.js
Container1.js
Container2.js

screen2
actions.js
reducer.js
saga.js
Container1.js
Container2.js

components
Component1.js
Component2.js

Sdílené moduly a selektory

Předpoklad, že obrazovky nesdílí data a funkce samozřejmě nemusí a nebude platit. Velmi pravděpodobně dojde ke sdílení funkcí. Proto zavedeme sdílené moduly, které taky mají svůj stav, a tedy reducer a akce, a můžou mít svou ságu. Pokud obrazovkový model potřebuje použít sdílenou funkci, odešle (dispatch) akci sdíleného modulu a dotáže se na jeho stav. Na akci přitom může reagovat sága sdíleného modulu.

A zde narážíme na první problém. Přímý přístup do stavu sdíleného modulu porušuje princip logického oddělení. Pokud změním strukturu stavu ve sdíleném modulu, musím změnit dotazovací logiku ve všech obrazovkách, které ho používají.

Proto zavádíme selektory, které tuto dotazovací logiku pokrývají. Selektor je funkce, která ze stavu vrací nějakou konkrétní hodnotu. V okamžiku, kdy změním strukturu stavu, stačí mi pouze přepsat selektory, které se nacházejí v daném modulu.

I obrazovkové moduly můžou mít vlastní selektory. Struktura aplikace pak může vypadat takto:
screen1
actions.js
reducer.js
saga.js
Container1.js
Container2.js
selectors.js

screen2
actions.js
reducer.js
saga.js
Container1.js
Container2.js
selectors.js

shared1
actions.js
reducer.js
selectors.js

shared2
actions.js
reducer.js
saga.js
selectors.js

components
Component1.js
Component2.js

Jak to dát dohromady?

Řekli jsme si, že bychom chtěli mít aplikaci rozdělenou na obrazovkové a sdílené moduly. Každý modul přitom bude mít svůj vlastní stav (tedy akce, reducer a selektory) a může mít vlastní ságu.

Takto na papíře to vypadá pěkně. Soubory jsou pěkně rozdělené. Jak je ale teď propojit, abychom skutečně měli jednu aplikaci?

Začneme reducery. Redux nám poskytuje funkci combineReducers, která nám umožňuje spojit více reducerů do jednoho stromu. To může být náš hlavní reducer:


import {combineReducers} from 'redux-immutable'; // používáme immutable
import screen1Reducer from './screen1/reducer.js';
import screen2Reducer from './screen2/reducer.js';
import shared1Reducer from './shared1/reducer.js';
import shared2Reducer from './shared2/reducer.js';
 
export default combineReducers({
    screen1: screen1Reducer,
    screen2: screen2Reducer,
    shared1: shared1Reducer,
    shared2: shared2Reducer,
});

Každý modul má ve stavu svůj vlastní podstrom označený stringovým literálem. To znamená, že selektory v daném modulu se musí nejdříve dostat ke svému podstromu:


// shared1/selectors.js
const getModel = (state) => state.get('shared1'); // tohle je poměrně užitečný pattern
 
export const getId = (state) => getModel(state).get('id');

Samozřejmě, používat stringové literály je potenciálně nebezpečné, lepší je použít stringové konstanty. Každý modul má pak tedy svůj název, který by ho měl unikátně identifikovat. Ten umístíme do souboru constants.js.

Někdo by mohl namítnout, že pokud bychom chtěli moduly identifikovat skutečně unikátně, mohli bychom použít Symbol. To zní jako dobrý nápad. Je však ještě jedno místo, kde se unikátní identifikátor modulu může hodit. Můžeme jej zahrnout do typů akcí, čímž se vyhneme tomu, že náhodou zavedeme dvě stejnojmenné akce v různých modulech. Protože akce by měly být serializovatelné, nemůžeme použít Symbol.

Sdílený modul může vypadat například takto. Všimněte si, že typy akcí a generátory akcí jsou oba v souboru actions.js.


// constants.js
export const NAME = 'shared1';
 
// actions.js
import {NAME} from './constants.js';
 
export const SET_ID = `${NAME}/SET_ID`; // template literals for the win!
export const setId = (id) => ({
    type: SET_ID,
    id,
});
 
// reducer.js
import {SET_ID} from './actions.js';
 
export default (state = null, action) => (action.type === SET_ID) ? action.id : state;
 
//selectors.js
import {NAME} from './constants.js';
 
const getModel = (state) => state.get(NAME);
 
export const getId = getModel;

Výsledný reducer pak může vypadat takto:


// reducer.js
import {combineReducers} from 'redux-immutable'; // používáme immutable
import {NAME as SCREEN1} from './screen1/constants.js';
import screen1Reducer from './screen1/reducer.js';
import {NAME as SCREEN2} from './screen2/constants.js';
import screen2Reducer from './screen2/reducer.js';
import {NAME as SHARED1} from './shared1/constants.js';
import shared1Reducer from './shared1/reducer.js';
import {NAME as SHARED2} from './shared2/constants.js';
import shared2Reducer from './shared2/reducer.js';
 
export default combineReducers({
    [SCREEN1]: screen1Reducer,
    [SCREEN2]: screen2Reducer,
    [SHARED1]: shared1Reducer,
    [SHARED2]: shared2Reducer,
});

Obrazovkové moduly mají navíc kořenovou komponentu, kterou můžeme zahrnout v routeru. Všimněte si, že URL jednotlivých obrazovek jsou také definovány v konstantách.


// Router.js
import {Router, Route} from 'react-router-dom';
import {PATH as SCREEN1_PATH} from './screen1/constants.js';
import Screen1Container from './screen1/Container.js';
import {PATH as SCREEN2_PATH} from './screen1/constants.js';
import Screen2Container from './screen2/Container.js';
 
export default () => (
    <router>
        <route path={SCREEN1_PATH} component={screen1Container}></route>
        <route path={SCREEN2_PATH} component={screen2Container}></route>
    </router>
);

Teď už nám zbývají vyřešit jen ságy. Ságy sdílených modulů typicky běží na pozadí, můžeme je tedy naforkovat v hlavní sáze. Ságy obrazovkových modulů jsou složitější. Ideální by bylo, aby se sága obrazovkového modulu spustila pokaždé, když uživatel vstoupí na obrazovku a ukončila, když ji opustí. O tom, jak něco takového udělat, si povíme v některém z dalších článků.

Závěr

Aplikaci jsme si rozdělil do obrazovkových modulů, z nichž každý odpovídá jedné obrazovce, a na sdílené moduly pro společné funkce. Každý modul má vlastní podstrom stavu a může mít vlastní ságu. Zároveň jsme si zavedli konstantu NAME sloužící k identifikaci modulů. Příště si povíme něco o zapouzdření (enkapsulaci) a o tom, jak řešit importy mezi moduly.