Viac komponentové aplikácie

tvorba vlastných komponentov, organizácia komponentov v projekte, kompozícia komponentov, odovzdávanie údajov medzi komponentami, properties

Záznam z prednášky

Introduction

  • (slide) je to neuveriteľné, ale stále pracujeme na baterke.

    • Naša baterka svieti pomocou blesku kamery na telefóne. A pri jej zapínaní a vypínaní prehráme aj zvuk.

    • Dokonca vieme aj v prípade, že zariadenie baterkou nedisponuje, zobraziť správu a aplikáciu vypnúť. Dokonca to robíme už tesne po spustení aplikácie.

    • Pred prístupom k baterke vieme dokonca overiť aj to, či máme povolený prístup ku kamere na OS Android.

    • Máme ju dokonca pripravenú pre globálny trh, pretože sme vyriešili aj problematiku lokalizácie aplikácie do cudzieho jazyka.

  • Dnes sa pozrieme na to, ako z baterky urobíme viackomponentovú aplikáciu.

Multi Component Application

  • (slide) Vráťme sa teraz naspäť k definícii komponentu. V predchádzajúcich prednáškach sme komponenty v rámci React-u definovali ako

    malé izolované časti na tvorbu používateľských rozhraní

  • Používateľské rozhranie našej aplikácie sa aktuálne skladá z dvoch takýchto častí

    • z obrázka
    • z tlačidla
  • Miesto toho, aby sme sa na ne pozerali ako na dva samostatné komponenty, sú súčasťou jedného komponentu, v ktorom sa tento obrázok spolu s tlačidlom nachádzajú. Upravme teda projekt tak, že ho rozbijeme na tri komponenty:

    • obrázok ako komponent ImageSwitch,
    • tlačidlo ako komponent ButtonSwitch, a
    • samotnú aplikáciu ako komponent App.
  • V štruktúre projektu v priečinku src/ preto vytvoríme ďalší priečinok s názvom components/, do ktorého tieto komponenty umiestnime - každý do samostatného súboru.

  • Zatiaľ vytvoríme len kostru týchto komponentov extrahovaním len potrebného kódu pre každého jedného z nich. Následne z nich postupne opätovne vytvoríme celú aplikáciu.

  • Komponent ImageSwitch bude uložený v súbore ImageSwitch.js a bude vyzerať nasledovne:

    import React from "react";
    import { Image, Pressable } from "react-native";
    
    export function ImageSwitch() {
        const image = require("../assets/bulb_off.png");
    
        return (
            <Pressable onPress={null}>
                <Image source={image} />
            </Pressable>
        );
    }

    Komponent ButtonSwitch bude uložený v súbore ButtonSwitch.js a bude vyzerať nasledovne:

    import { useTranslation } from "react-i18next";
    import React from "react";
    import { Button } from "react-native";
    
    export function ButtonSwitch() {
        const { t } = useTranslation();
    
        return (
            <Button
                onPress={null}
                title={t("Turn On")}
            />
        );
    }

    Komponent App bude uložený v súbore App.js a bude vyzerať nasledovne:

    import React, { useState } from "react";
    import { StyleSheet, View } from "react-native";
    
    import { ButtonSwitch } from "./ButtonSwitch";
    import { ImageSwitch } from "./ImageSwitch";
    
    import "../i18n";
    
    export function App() {
        const [isOn, setIsOn] = useState(false);
    
        return (
            <View style={styles.container}>
                <ImageSwitch></ImageSwitch>
                <ButtonSwitch></ButtonSwitch>
            </View>
        );
    }
  • Po spustení sa síce aplikácia spustí a bude aj vyzerať ako predtým, ale tentokrát nebude fungovať. Ak totiž ťapneme na jeden alebo druhý komponent, nedôjde k žiadnej zmene v aplikácii - ani obrázok a ani tlačidlo sa nijak nezmenia. Rovnako tak sa nerozsvieti ani blesk.

  • Vyzerá to teda tak, že aplikácia prestala pracovať. Jej znefunkčnenie je spôsobené tým, že stav aplikácie, ktorý sa menil po ťapnutí či už na obrázok alebo tlačidlo, zostal izolovaný len v komponente App.

  • Ako teda zabezpečiť, aby bol stav zdieľaný naprieč všetkými komponentmi, z ktorých sa aplikácia skladá?

Share Data Between the Components

  • (slide) Problém, s ktorým sa stretávame, je bežný problém, s ktorým sa dá stretnúť v prípade viackomponentových aplikácií. Týmto problémom je zdieľanie údajov medzi komponentmi.

  • A nevyhli sme sa mu ani v našom prípade. A to aj napriek tomu, že aplikácia, ktorú vytvárame, má len tri komponenty. Stav v našej aplikácie hovorí o tom, či je baterka zasvietená alebo zhasnutá. A tento stav môžeme zmeniť kliknutím buď na obrázok so žiarovkou alebo na tlačidlo pod ním. A so zmenou stavu dôjde aj k zmene vzhľadu či už tlačidla a textu na ňom alebo obrázku reprezentujúcom stav baterky.

  • Komponent je malá izolovaná časť na tvorbu používateľských rozhraní. My sme vytvorili tri takéto izolované časti. Ako teda zabezpečíme to, že po kliknutí na tlačidlo sa o tom dozvie aj obrázok, keď aj jeden aj druhý komponent sú navzájom izolované? Čo znamená, že aj jeden aj druhý disponuje vlastným stavom komponentu?

  • (slides) Existuje viacero mechanizmov, ktoré je možné použiť na vyriešenie tohto problému. Každý je však špecifický a záleží od toho, medzi ktorými komponentmi v hierarchickej štruktúre komponentov chceme údaje zdieľať. Preto je potrebné rozlišovať aj to, ktorým smerom budú údaje medzi komponentmi zdieľané:

    • od komponentu rodiča ku potomkovi (z angl. parent to child component),
    • od komponentu potomka ku rodičovi (z angl. child to parent component),
    • medzi súrodencami (z angl. between siblings), alebo
    • medzi ľubovoľnými komponentmi (z angl. sharing data between not related components)
  • Na jednotlivé mechanizmy pre jednotlivé typy zdieľania údajov sa pozrieme bližšie.

Parent to Child Component

  • (slide) Toto je veľmi častý prípad zdieľania údajov medzi komponentmi. Každý (rodičovský) komponent sa totiž môže odkazovať na iný komponent (potomka) pri svojom renderovaní a v tomto momente im môže predať potrebné informácie.

    Parent to Child [@y23]

Properties

  • (slide) Predať údaje od rodiča svojmu potomkovi je možné zabezpečiť pomocou vlastností (z angl. properties alebo skrátene props). Toto predanie sa deje vo forme argumentu funkcie props, ktorá reprezentuje komponent (slide):

    function Hello(props){
        return (
            <Text>Hello {props.name} {props.surname}!</Text>
        );
    }
  • (slide) Rodičovský komponent odovzdá údaje do komponentu Hello pri jeho renderovaní napríklad takto:

    return (
        <View style={styles.container}>
          <Hello name="Sherlock" surname="Holmes"></Hello>
      </View>
    );
  • Vlastnosti props sú objektom, čo si môžeme jednoducho overiť tým, že ich vypíšeme do log-u:

    console.log(props);
  • Vlastnosti sa obecne používajú na prispôsobenie, resp. úpravu komponentu v dobe jeho vytvárania pomocou rozličných parametrov (z angl. properties). Väčšina komponentov rámca React Native obsahuje vlastnosti, pomocou ktorých je možné nastaviť ich vlastnosti v čase vytvárania. Napr. komponent Image obsahuje vlastnosť src, pomocou ktorej máme možnosť špecifikovať obrázok, ktorý sa má zobraziť.

  • (slide) Vlastnosti sú určené len na čítanie! Komponent nesmie modifikovať hodnoty vlastností! Vlastnosti sú nemenné (z angl. immutable)!

Pure Function

  • (slide) Takéto funkcie, ktoré nemenia svoje vstupy a vždy vrátia rovnaký výstup pri rovnakom vstupe, sa nazývajú pure” funkcie. Dokonca samotný React, aj napriek tomu, že je pomerne flexibilný, má jedno veľmi striktné pravidlo:

    All React components must act like pure functions with respect to their props.

Torch Update

  • V našom prípade sa jedná o vzťah rodiča, ktorým je komponent App a jeho potomkov, ktorými sú ImageSwitch a ButtonSwitch. Údaj, ktorý je potrebné, aby každý potomok dostal, je počiatočný stav komponentu:

    • aby komponent ImageSwitch vedel, aký obrázok má zobraziť, a
    • aby komponent ButtonSwitch vedel, aký popisok má na tlačidle zobraziť.
  • Upravíme teda renderovanie komponentu App, kde každému potomkovi pošleme stav komponentu pomocou vlastnosti isOn:

    return (
        <View style={styles.container}>
          <ImageSwitch isOn={isOn}></ImageSwitch>
          <ButtonSwitch isOn={isOn}></ButtonSwitch>
      </View>
    );
  • Na základe tejto vlastnosti upravíme komponent ButtonSwitch, kde na základe jej hodnoty aktualizujeme popisok tlačidla:

    export function ButtonSwitch(props) {
        const { t } = useTranslation();
    
        return (
            <Button
                onPress={null}
                title={props.isOn === true ? 
                    t("Turn Off") : t("Turn On")}
            />
        );
    }
  • Podobne na základe hodnoty tejto vlastnosti zvolíme správny obrázok v komponente ImageSwitch:

    export function ImageSwitch(props) {
        const { t } = useTranslation();
    
        const image = props.isOn
            ? require("../assets/bulb_on.png")
            : require("../assets/bulb_off.png");
    
        return (
            <Pressable onPress={null}>
                <Image source={image} />
            </Pressable>
        );
    }
  • Po spustení aplikácie síce nevidíme žiadnu mimoriadnu zmenu, ale môžeme si byť istý, že aktuálne sa jej vzhľad odvíja od vlastnosti isOn. Zmenou jej počiatočnej hodnoty sa o tom môžeme presvedčiť.

  • Problém odovzdávania údajov od rodiča smerom k potomkovi máme vyriešený. Ako však zabezpečiť to, aby potomok dokázal informovať o vzniknutej zmene svojho rodiča? V prípade aplikácie Torch to potrebujeme zabezpečiť vždy vtedy, keď dôjde ku stlačeniu tlačidla alebo ťapnutiu na obrázok.

Child to Parent Component

  • (slide) Pozrieme sa teda na druhý veľmi častý prípad odovzdávania údajov medzi komponentmi, keď potomok potrebuje informovať svojho rodiča.

    Child to Parent [@y23]
  • Najjednoduchší spôsob, ako môže potomok kontaktovať rodiča, je pomocou tzv. callback funkcie, ktorá sa nachádza u rodiča. Túto funkciu rodič odovzdá potomkovi ako vlastnosť (z angl. property). Potomok ju následne bude volať vždy vtedy, keď dôjde u neho k zmene stavu a bude o tom potrebovať informovať svojho rodiča.

Torch Update

  • V našom prípade vytvoríme v komponente App funkciu onStateChange(), ktorá po zavolaní zmení jeho stav na opačný:

    function onStateChange() {
        setIsOn(!isOn);
    }
  • Referenciu na túto funkciu následne odovzdáme vo forme vlastnosti onPress ostatným komponentom pri renderovaní komponentu App:

    return (
        <View style={styles.container}>
          <ImageSwitch 
              isOn={isOn} 
              onPress={onStateChange}>
          </ImageSwitch>
          <ButtonSwitch 
              isOn={isOn} 
              onPress={onStateChange}>
          </ButtonSwitch>
      </View>
    );
  • Následne upravíme renderovanie tlačidla, kde hodnotu vlastnosti props.onPress priradíme ku vlastnosti onPress komponentu tlačidla Button:

    return (
        <Button
          onPress={props.onPress}
          title={props.isOn === true ? 
              t("Turn Off") : t("Turn On")}
      />
    );
  • Podobne upravíme aj renderovanie obrázku, kde podobne nastavíme vlastnosť onPress komponentu Pressable:

    return (
        <Pressable onPress={props.onPress}>
          <Image source={image} />
        </Pressable>
    );
  • Ak spustíme aplikáciu teraz, “automagicky” bude všetko fungovať tak, ako má. Na začiatku bude rodič propagovať stav aplikácie všetkým svojim potomkom pomocou vlastností (props). Následne budú potomkovia v prípade vzniknutej udalosti propagovať aktuálny stav svojmu rodičovi pomocou callback funkcie.

  • Netreba však zabudnúť na to, že keď potomok iniciuje zmenu stavu, rodič ju následne distribuuje medzi všetkých svojich potomkov. To je dané vlastnosťami rámca React, kedy pri zmene stavu komponentu sa jeho nová hodnota automaticky propaguje všetkým ďalším potomkom, ktorí ho nejakým spôsobom zdieľajú.

  • Tým sme vlastne náš problém vyriešili - z jedno komponentovej aplikácie sme vytvorili viac komponentovú aplikáciu, v ktorej sme zabezpečili zdieľanie údajov medzi jednotlivými komponentmi. Aktuálne stačí len do komponentu App vložiť kontrolu prístupových práv ku fotoaparátu a kontrolu existencie fotoaparátu ako takého.

    export function App() {
        const [isOn, setIsOn] = useState(false);
        const { t } = useTranslation();
    
        useEffect(function () {
            Torch.switchState(isOn).catch(function (e) {
                Alert.alert(
                    t("Missing Flashlight"),
                    t(
                        "No camera available. Go and buy a " +
                        "device with some (or two) and come " +
                        "back later."
                    ),
                    [
                        {
                            text: t("Quit"),
                            onPress: function () {
                                RNExitApp.exitApp();
                            },
                        },
                    ]
                );
            });
        }, []);
    
        async function onStateChange() {
            let cameraAllowed = true;
    
            if (Platform.OS === "android") {
                const granted = await PermissionsAndroid.request(
                    PermissionsAndroid.PERMISSIONS.CAMERA,
                    {
                        title: t("Torch Needs Camera Permission"),
                        message: t(
                            "Torch app uses camera flashlight " + 
                            "as torch. To make Torch work, you " +
                            "need to allow Camera Permission."
                        ),
                        buttonNeutral: t("Ask Me Later"),
                        buttonNegative: t("Cancel"),
                        buttonPositive: t("OK"),
                    }
                );
    
                cameraAllowed = 
                    granted === PermissionsAndroid.RESULTS.GRANTED;
            }
    
            if (cameraAllowed) {
                await Torch.switchState(!isOn);
                setIsOn(!isOn);
            }
        }
    
        return (
            <View style={styles.container}>
                <ImageSwitch 
                    isOn={isOn} 
                    onPress={onStateChange}>
                </ImageSwitch>
                <ButtonSwitch 
                    isOn={isOn} 
                    onPress={onStateChange}>
                </ButtonSwitch>
            </View>
        );
    }

Sharing Data Between Siblings

  • (slide) Ďalší prípad zdieľania údajov je medzi súrodencami. Komponenty sú súrodenci, ak majú spoločného predka.

    Sibling to Sibling [@y23]
  • Toto je vlastne aj náš prípad, kedy komponenty ButtonSwitch a ImageSwitch majú spoločného predka App.

  • Odporúčanie je v tomto prípade na komunikáciu medzi súrodencami použiť kombináciu predchádzajúcich stratégií, ktoré zahŕňajú spoločného predka. A ako sme mohli vidieť, pomáha v tomto prípade aj samotný rámec, ktorý zmeny stavu automaticky propaguje dotknutým komponentom.

  • (slide) Posledný prípad zdieľania údajov je medzi ľubovoľnými dvoma komponentmi aplikácie.

    Any to Any [@y23]
  • Tento prípad už nie je taký triviálny, ako tomu bolo doteraz, pretože doteraz sme videli, že React údaje distribuuje prostredníctvom komponentov. Rýchlosť doručenia teda silne závisí od vzdialenosti medzi komunikujúcimi komponentmi.

  • (slides) React totiž nemá vlastné riešenie, ktoré zabezpečuje takúto nepriamu komunikáciu. React podporuje len jednosmernú komunikáciu (z angl. unidirectional data flow) v smere od rodiča ku potomkovi. Takže doručiť údaje medzi dvoma uzlami, ktoré nie sú rodičmi, je problém.

    The Problem [@y31]
  • (slide) Aj napriek tomu existuje niekoľko prístupov, ktoré je možné použiť na tento typ komunikácie medzi komponentmi:

    • globálne premenné,
    • použiť Context, alebo použiť
    • návrhový vzor pozorovateľ (z angl. observer)

Global Variables

  • Globálne premenné nie je dobré používať, ale pre rýchle prototypovanie sa občas môžu hodiť.

  • V tomto prípade môžete na globálne premenné použiť objekt window.

Context

  • Context funguje podobne ako vlastnosti (props), ale s tým rozdielom, že neumožňuje poslať údaje len jednému potomkovi, ale naraz celej vetve stromu (resp. podstromu). Údaje je možné poslať len smerom od rodiča k potomkom v danej vetve.

  • Context sa používa v prípadoch, keď je potrebné zdieľať údaje spoločné pre komponenty danej vetvy stromu. To môžu byť údaje ako napr. prihlásený používateľ, téma prostredia, zvolený jazyk a pod.

  • V samotnej dokumentácii autori upozorňujú na opatrné používanie Context-u, pretože jeho nadmerným resp. nesprávnym používaním sa znižuje znovupoužiteľnosť komponentov.

Observer Pattern

  • Pomocou tohto návrhového vzoru je možné kontaktovať jedným komponentom ľubovoľný počet iných komponentov. Dôležité je, aby sa tieto komponenty prihlásili, že chcú byť v prípade potreby (napr. vzniku udalosti) kontaktované, resp. notifikované. Tieto komponenty sa na odber obyčajne prihlásia v čase pripojenia (z angl. mounting) komponentu a odhlásia sa pri odpojení (z angl. unmounting), resp. odstránení komponentu. To je možné zabezpečiť pomocou hook-u useEffect().

  • (slide) Implementáciu je možné vytvoriť vlastnú, ale samozrejme existujú aj hotové knižnice, ako napr.:

    • EventEmitter - EventEmitter is an implementation of the Event-based architecture in JavaScript.
    • Flux - Application architecture for building user interfaces
    • MobX - MobX is a battle tested library that makes state management simple and scalable by transparently applying functional reactive programming (TFRP)
    • Redux - A Predictable State Container for JS Apps

Conclusion

  • Dnes sme úspešne rozbili náš komponent a vytvorili sme viac komponentovú aplikáciu. Ukázali sme si problém, ktorý takýto prístup tvorby komponentov so sebou prináša. Tým problémom je zdieľanie údajov medzi komponentmi. Ukázali sme si štyri prípady zdieľania údajov medzi komponentmi a mechanizmy, ktorými je možné toto zdieľanie zabezpečiť.

  • Tvorba vlastných komponentov má svoje výhody pri väčších aplikáciách a pri opakujúcich sa komponentoch. V našom prípade sa stále jedná o pomerne jednoduchú aplikáciu. Ak si však spomeniete na aplikáciu Dialer, ktorú ste vytvárali na cvičeniach, využitie komponentov v jej prípade je omnoho užitočnejšie.

  • Nabudúce sa pozrieme na Redux.