Higher Order Components w ReactJS

Higher Order Components w ReactJS

Objaśnienie idei oraz wskazówki dotyczące używania Higher Order Components w aplikacjach JS zbudowanych w oparciu o ReactJS.

sobota, 24 marca 2018

Informatyka

Od kilku miesięcy mam przyjemność korzystać z ReactJS, niewielkiej biblioteki do tworzenia front-endu w stylu komponentowym, opracowanej przez Facebook. W tym artykule pragnę przedstawić technikę składania komponentów zwaną Higher Order Components (HOC). Gdy pierwszy raz się z nią zetknąłem, oswojenie się z nią zajęło mi trochę czasu, a moje główne pytanie brzmiało: po co. Z biegiem czasu doceniłem jej przydatność i zrozumiałem, czemu jest ona tak powszechnie stosowana. Artykuł przedstawia kilka przypadków użycia HOC-ów, ze szczególnym uwzględnieniem współpracy z biblioteką Redux.

Czym jest Higher Order Component?

Pierwsze, co należy wiedzieć o komponentach wyższego rzędu to to, że nie jest to żadne specjalne API oferowane przez ReactJS. Jest to wzorzec projektowy, technika programistyczna mówiąca, w jaki sposób wykorzystać ReactJS i język JavaScript, aby rozwiązać określony problem. Chcemy unikać tworzenia ogromnych, monolitycznych komponentów, które realizują zbyt wiele zadań jednocześnie. Zamiast tego, chcielibyśmy mieć zestaw małych komponentów robiących dobrze jedną rzecz, i mieć możliwość komponowania z nich bardziej złożonych zachowań.

A oto definicja komponentu wyższego rzędu: jest to funkcja, która jako argument przyjmuje jeden komponent i zwraca inny komponent.

Innymi słowy, taka funkcja dokonuje przekształcenia przekazanego w argumencie komponentu tak, aby dodać do niego jakąś użyteczną funkcjonalność. W praktyce wewnątrz tej funkcji dokonujemy opakowania naszego komponentu w jakiś inny komponent, który realizuje określone zadanie, i zwracamy uzyskany wynik. Znakomitym przykładem HOC-a jest funkcja connect() z Reduksa, która pozwala do komponentów wstrzykiwać stan aplikacji:

const MyComponent = props => (<div>{props.foo}</div>)

const mapStateToProps = state => ({
    foo: state.foo
})

const MyComponentContainer = connect(mapStateToProps)(MyComponent)

Tworzenie Higher Order Component

Tworzenie własnego HOC-a nie jest trudne i sprowadza się do napisania funkcji. Poniżej przedstawiam wzór, którym można posłużyć się na start:

const myHoc = settings => WrappedComponent => {
    return class DecoratingComponent extends React.Component {
        render() {
            return (<div className={'foo'}><WrappedComponent {...this.props} /></div>)
        }
    }
}

Użycie:

const EnhancedMyComponent = myHoc('foo')(MyComponent)

Po co podwójna funkcja?

Jest to pewna konwencja. Komponenty wyższego rzędu przeważnie pobierają też jakieś dodatkowe ustawienia, które zwyczajowo wrzucamy jako argumenty zewnętrznej funkcji. Wewnętrzna funkcja pobiera wyłącznie komponent do udekorowania. Nie ma technicznych przeciwwskazań, aby zrobić tutaj jedną, dwuargumentową funkcję. Zauważmy jednak, że dzięki takiej konstrukcji możemy łatwo stworzyć HOC-a, który jest jedynie specjalizacją innego HOC-a. Posłużę się tu przykładem funkcji connect():

const predefinedMapStateToProps = state => ({
   // ...
})

const specialConnect = mapDispatchToProps => connect(predefinedMapStateToProps, mapDispatchToProps)

// użycie
const EnhancedComponent1 = connect(predefinedMapStateToProps, myMapDispatchToProps(SomeComponent)
const EnhancedComponent2 = specialConnect(myMapDispatchToProps)(SomeComponent)

Po co zwracamy klasę?

Popatrzmy na definicję - wynikiem działania funkcji musi być komponent. Zatem możemy zwrócić albo klasę komponentu rozszerzającą React.Component, albo komponent funkcyjny w postaci funkcji. Ważne, że ma to być komponent.

Gdzie używamy dekorowanego komponentu?

Używamy go w metodzie render(). Musimy pamiętać o tym, że stosując HOC, programista wciąż chce przekazać jakieś propsy do WrappedComponent, jednak ponieważ myśmy go właśnie udekorowali, tak naprawdę przekaże je do naszego dekorującego komponentu. Nie wiemy, jakie propsy ma dekorowany komponent, więc najprościej jest spropagować wszystko, co dostaliśmy, w dół. Stąd zapis <WrappedComponent {...this.props} />.

Czemu klasa jest zagnieżdżona?

Technicznie nie ma przeciwwskazań, aby klasę umieścić poza funkcją. Zauważmy jednak, że wtedy z jej wnętrza nie będziemy mieli dostępu do dekorowanego komponentu, ani do innych argumentów naszego HOC-a i będziemy musieli spropagować je w dół poprzez propsy. Zagnieżdżając klasę, mamy bezpośredni dostęp do wszystkich argumentów naszej funkcji.

Przykład 1: dodawanie zachowań

Najprostszym przypadkiem użycia jest dodanie jakiegoś zachowania do komponentu. Przykładowo, możemy przykryć komponent kręciołkiem, jeśli trwa aktualnie ładowanie danych z serwera.

const mapStateToProps = state => ({
    loadingInProgress: getLoadingState(state)
})

export const withLoadingSpinner = WrappedComponent => {
     return connect(mapStateToProps)(class LoadingAwareComponent extends React.Component {
        render() {
            if (this.props.loadingInProgress) {
                return (<div className={'loading-spinner'}><WrappedComponent {...this.props} /></div>)
            }
            return (<WrappedComponent {...this.props} />)
        }
    })
}

Użycie:

export const EnhancedMyComponent = withLoadingSpinner(MyComponent)

Czy można wewnątrz HOC-a używać innych HOC-ów?

W powyższym przykładzie nasz HOC, oprócz dekoracji, wykorzystuje także innego HOC-a, funkcję connect() z Reduksa do zamontowania informacji o stanie ładowania danych. Jest to dozwolone pod warunkiem, że funkcja connect() nie będzie wywoływana wewnątrz metody render(). Wrócimy do tego zagadnienia w dalszej części artykułu.

Przykład 2: ładowanie danych z serwera

Komponenty wyższego rzędu można wykorzystać do implementacji asynchronicznego ładowania danych z serwera. Właściwy komponent powinien zostać wyświetlony dopiero wtedy, kiedy dane będą już dostępne w Reduksie i kiedy będzie je można przekazać "w dół" przy pomocy propsów. Prawidłowa implementacja wymaga tak naprawdę użycia dwóch HOC-ów:

const mapDispatchToProps = dispatch => ({
    loadData: url => dispatch(loadData(url))
})

const mapStateToProps = state => ({
    data: getLoadedData(state)
})

const DataAwareComponent = WrappedComponent => props => {
    if (props.data) {
        return (<WrappedComponent {...props} />)
    }
    return null
}

export const withData = url => WrappedComponent => {
    const ConnectedComponent = connect(mapStateToProps)(DataAwareComponent(WrappedComponent))
    return connect(null, mapDispatchToProps)(class DataLoader extends React.Component {
        componentDidMount() {
            this.props.loadData(url)
        }

        render() {
            return (<ConnectedComponent {...this.props} />)
        }
    }
}

Czemu dwa komponenty?

Wynika to ze sposobu, w jaki React analizuje czy przebudować od nowa dany fragment drzewa komponentów. Gdybyśmy użyli pojedynczego HOC-a, który zarówno inicjowałby ściąganie danych w componentDidMount(), jak i mapował już załadowane dane ze stanu Reduksa, rendering by się zapętlił: pojawienie się danych spowodowałoby unieważnienie tegoż komponentu i... ponowne wysłanie żądania do serwera z powodu stworzenia nowej instancji komponentu po zmianie propsów. Powyższa implementacja pozwala tego bardzo łatwo uniknąć. Nadrzędny komponent DataLoader wywołuje this.props.loadData(), inicjując ściąganie danych. Zagnieżdżony komponent DataAwareComponent mapuje dane ze stanu Reduksa na propsy dekorowanego WrappedComponent. Zauważmy, że teraz, gdy w Reduksie pojawią się w końcu dane, przebudowany zostanie tylko DataAwareComponent, natomiast DataLoader pozostanie bez zmian. Unikamy dzięki temu wysyłania do serwera kolejnych żądań.

Przykład 3: wyciąganie danych z kontekstu

ReactJS oferuje trzy sposoby przekazywania danych do komponentu: stan, propsy oraz kontekst. Ten ostatni jeszcze do niedawna był prywatnym API biblioteki i dopiero w późniejszych wersjach został podniesiony do rangi API publicznego. Kontekst to rodzaj "globalnego" zbiornika na dane, do którego może sięgnąć każdy komponent. Łatwo to pokazać na przykładzie funkcji connect() z Reduksa. Skąd wspomniana funkcja "zna" nasz Redux store, skoro nigdzie go nie przekazujemy? Otóż wyciąga go sobie ona właśnie z kontekstu. My również możemy skorzystać z kontekstu i umieszczać tam różne serwisy, które muszą być pod ręką. Możemy też stworzyć własnego HOC-a, który je stamtąd wyciągnie.

Oto HOC, który wyciągnie serwis myService z kontekstu i przekaże go w propsach do udekorowanego komponentu:

export const withMyService = WrappedComponent => {
    return class MyServiceComponent extends React.Component {
        static contextTypes = {
            myService: PropTypes.object.isRequired
        }

        constructor(props, context) {
            super(props)
            this.myService = context.myService
        }

        render() {
            return (<WrappedComponent {...this.props} myService={this.myService} />)
        }
    }
}

Nieco trudniejszym przypadkiem użycia jest wstrzyknięcie naszego serwisu do funkcji Reduksa mapStateToProps oraz mapDispatchToProps, czyli udekorowanie argumentów dla innego HOC-a. We wcześniejszej części artykułu wspomniałem o tym, że HOC-ów
(np. connect()) nie powinno się używać wewnątrz funkcji renderujących. Jest to pułapka, w którą sam początkowo wpadłem i słono za to zapłaciłem, ponieważ powoduje ona zmylenie algorytmu wykrywania różnic w drzewie DOM używanego w ReactJS. Efektem jest to, że React będzie za każdym razem tworzył od nowa cały udekorowany fragment drzewa komponentów. Do kontekstu mamy dostęp w konstruktorze klasy, zatem możemy właśnie tam wykonać odpowiednie transformacje:

export const connectMyService = connectArgs => WrappedComponent => {
    return class MyServiceComponent extends React.Component {
        static contextTypes = {
            myService: PropTypes.object.isRequired
        }

        constructor(props, context) {
            super(props)
            const enhancedConnectArgs = Array.from(connectArgs, 
                mapSthToProps => (mapSthToProps !== null ?  mapSthToProps(context.myService) : null)
            )
            this.connectedComponent = connect(...enhancedConnectArgs)(WrappedComponent)
        }

        render() {
            const ConnectedComponent = this.connectedComponent
            return (<ConnectedComponent {...this.props} />)
        }
    }
}

Przykład użycia:

const mapStateToProps = myService => state => ({
    data: myService.selectDataFromState(state)
})

const mapDispatchToProps = myService => dispatch => ({
    onFoo: () => dispatch(myService.doSomethingAction())
})

export const MyComponentContainer = connectMyService(mapStateToProps, mapDispatchToProps)(MyComponent)

Podsumowanie

Powyższe przykłady ukazują szeroki zakres zastosowań dla komponentów wyższego rzędu. Zauważmy, że dzięki nim możemy łatwo wydzielić powtarzalne zachowania do oddzielnych komponentów, które można wielokrotnie wykorzystywać, a także testować niezależnie. Innymi słowy, mamy tu do czynienia z praktycznym wdrożeniem Zasady Jednej Odpowiedzialności. Znana jest ona wprawdzie ze świata obiektowego, jednak sprawdza się również w innych paradygmatach. Dlatego tym bardziej rekomenduję używanie HOC-ów. A oto kilka przydatnych materiałów:

Tomasz Jędrzejewski

Programista Javy, lider techniczny. W wolnych chwilach podróżuje, realizując od kilku lat projekty długodystansowych wypraw pieszych.

Autor zdjęcia nagłówkowego: Creativity103, CC-BY-2.0

Komentarze (0)

Skomentuj

Od 3 do 40 znaków.

Wymagany, nie będzie publikowany.

Odpowiedz na pytanie.

Edycja Podgląd

Od 10 do 8000 znaków.

Wszystkie komentarze są moderowane i muszą być zatwierdzone przed publikacją.