Reselect je velmi užitečná knihovna na cachování selektorů, aby se při dotazování na stav nemusely pokaždé provádět složité operace. Normálně je poměrně jednoduché ji použít, byť to požaduje trochu přehodnotit uvažování o selektorech (tedy dobře si přečíst dokumentaci). Cachovaný selektor funguje tak, že zavolá vstupní selektory, uloží si jejich výsledky, provede nad nimi nějakou operaci, výsledek taky uloží a vrátí. Když se volá opakovaně, kontroluje výsledky vstupních selektorů a výstupní hodnotu přepočítá pouze pokud se změní výsledky vstupních selektorů. Nedávno jsme ale řešili problém, kdy jsme na základě vstupního selektoru potřebovali vygenerovat další vstupní selektory a až nad jejich výsledky provést operaci. Jak jsme to vyřešili? Čtěte dále. Varuju Vás, bude to technické.

Reselect a mapStateToProps

React-redux, propojení Reactu a Reduxu poskytuje komponentu vyššího řádu connect, která umožňuje dotazovat se na stav pomocí mapStateToProps, které ze stavu vrací mapu propsů. Při každé změně na stavu všechny napojené komponenty zavolají mapStateToProps a poté zkontroluje obdržené propsy oproti jejich minulým hodnotám. Pokud se alespoň jedna props změní, komponenta se překreslí. Protože se to děje při libovolné změně stavu, která vůbec nemusí souviset s danou komponentou, chceme, aby se hodnoty props měnily – a komponenta se překreslovala – jenom když je to nutné. Samozřejmě, překreslení komponenty znamená, že se překreslí jen ve virtuálním DOMu Reactu, který se ještě porovná s předchozím virtuálním DOMem, ovšem náročnost kontroly propsů je lineární, zatímco náročnost kontroly virtuálního DOMu je kubická. Zároveň chceme, aby mapStateToProps byly co nejrychlejší, protože se volají velmi často, ideálně by měly mít konstantní složitost, neměly by tam být složité výpočty.

Ideální tedy je, aby se složité výpočty prováděly v reduceru. Což nemusí být vhodné, protože takový reducer pak může být docela složitý, zvláště pokud reaguje na více akcí. Proto existuje reselect. Ten výsledky selektorů cachuje, a proto se složité výpočty provádějí jen jednou, když je třeba. Má funkci createSelector, která jako argumenty bere pole vstupních selektorů a výstupní funkci. Funguje trochu podobně jako mapStateToProps. Když mapStateToProps zavolá cachovaný selektor, zavolají se vstupní selektory, zkontroluje se, jestli jejich výsledky odpovídají výsledkům při předchozím volání, a pouze pokud se změnily, provede se nad nimi výstupní funkce, jinak se vrátí uložený výsledek z předchozího volání.

Existuje ještě jeden důvod, proč používat reselect. Občas je výsledkem selektorů neprimitivní hodnota, konkrétně objekt nebo pole (ideálně Immutable List nebo Map). Jejich porovnání není na bázi hodnot ale referencí. Pokud se tedy v selektoru pole vytváří, tedy nebere se přímo ze stavu, používají se na něm operace typu map nebo podobné, výsledkem je nová reference – a komponenta se překreslí. Proto používáme reselect i v takových případech. Kromě toho, operace nad celým polem jsou výpočetně náročné.

Normalizovaný stav

Reselect řeší většinu problémů s náročnými selektory. My jsme narazili na problém v kombinaci s normalizovaným stavem. V okamžiku, kdy do stavu ukládáme složitější strukturu, například pole elementů, řekněme pole položek v nákupním košíku, vždy se snažíme, abychom neměli zanořené struktury. Ve výsledku pak máme:

  1. Seznam identifikátorů.
  2. Mapu z identifikátorů do dat (tj. jednotlivých položek).

Z toho vychází dva selektory – jeden, který vrátí seznam identifikátorů, a druhý, který vrátí položku pro identifikátor.

Tímhle způsobem se velmi dobře staví komponenty, takže můžeme mít jednu napojenou komponentu pro seznam a jednu napojenou komponentu pro položky. Zároveň to výrazně zjednodušuje operace nad stavem – více viz dokumentace reduxu.

Dvojúrovňová cache

V okamžiku, kdy mám v normalizovaném stavu položky v nákupním košíku, a chci na všech provést nějakou operaci, například zjištění celkové ceny, narážíme na problém. Potřebujeme totiž vybrat ze stavu všechny identifikátory, pro každý z nich dostat selektorem data a potom na nich provést agregační operaci. Zároveň chceme použít reselect. Problém je, že nedokážeme jendoduše vystavět cachovaný selektor, který vrátí pole položek na základě toho, že zavolá pole selektorů. Když uděláme selektor, který vrátí pole, pokaždé vrátí novou instanci, kvůli čemu se nedá cachovat (dají se zkoušet různé kombinace, ale ve výsledku narazíme na ten samý problém).

Existuje samozřejmě druhá možnost, nepracovat se dvěma selektory, které máme, mít interní selektor, který vrátí celou mapu, a při výpočtu se dotazujeme na mapu. Problém je, že seznam identifikátorů může být filtrovaný. Ale tento přístup znamená, že výpočet se provede pokaždé, pokud dojde ke změně libovolné položky. Taky to není elegantní.

Existuje však řešení. JavaScript má jednu krásnou vlastnost, a to že funkce, a tedy i selektory, jsou objekty první kategorie. Můžeme tedy cachovat nejen primitivní hodnoty, ale i funkce. Dokážeme vytvořit následující konstrukci: První cachovaný selektor má jako vstupní selektor ten, který vrací seznam identifikátorů. Na základě toho vrací (a cachuje) nový cachovaný selektor, který má jako vstupní selektory pole selektorů, jeden pro každý identifikátor. Vypadá to následovně:


const createGetItems = createSelector(
    getItemIds,
    (ids) => createSelector(
        ids.map((id) => (state) => getItem(state, id)).toArray(),
        (...ids) => List(ids),
    ),
);
export const getItems = (state) => createGetItems(state)(state);

Dvojitá aplikace na stav je nutná. První dostaneme selektor, který se ukládá v cachi a v druhé ho aplikujeme na stav. Použití metod toArray a List je nutný kvůli tomu, že používáme immutable struktury.

Náš výsledný selektor může vypadat takto:


const createGetBasketCost = createSelector(
    getItemIds,
    (ids) => createSelector(
        ids.map((id) => (state) => getItem(state, id)).toArray(),
        (...ids) => ids.reduce((cost, item) => cost + item.get('number') * item.get('cost')),
    ),
);
export const getBasketCost = (state) => createGetBasketCost(state)(state);

Dokonce se můžeme pokusit vytvořit generátor takových selektorů:


const createAggregateSelector = (idsSelector, valueSelector, aggregateFunction) => {
    const aggregateSelector = createSelector(
        idsSelector,
        (ids) => createSelector(
            ids.map((id) => (state) => valueSelector(state, id)).toArray(),
            (...values) => aggregate(values),
        ),
    );
    return (state) => aggregateSelector(state)(state);
};

Závěrem

Tento přístup funguje. Otázkou je, jestli je dostatečně efektivní. Při každé změně stavu totiž dojde ke dvěma věcem:

  1. Zavolá se selektor pro seznam identifikátor (getItemIds).
  2. Pro každý identifikátor (potenciálně n) se zavolá selektor pro získání dat (getItem).

Dojde tedy k volání n selektorů. Zatímco v případě, že bychom selektor vybudovali na základě selektoru, který vrací mapu z identifikátorů do dat, provolají se při každé změně stavu pouze dva selektory. Takže tento přístup bude pravděpodobně rychlejší, byť to znamená, že výpočet se bude potenciálně provádět častěji. Posouzení, který přístup použít, nechám na Vás. Je to však další krásný příklad toho, jak elegantní konstrukce se dají vybudovat v JavaScriptu díky tomu, že funkce jsou objekty první kategorie.