Jestem przekonana, że większość z Was, spotkała się już w swojej karierze z zasadą “don’t repeat yourself”. Nie ważne w jakim języku programujemy, dążymy do tego, by nasz kod był DRY. Z tego powodu korzystamy z różnego rodzaju abstrakcji czy wzorców, które pozwalają nam na uniknięcie kopiowana fragmentów kodu i konieczności późniejszego ich utrzymywania. Dziś chciałabym pokazać Wam 2 wzorce, które możecie zastosować w przypadku aplikacji bazujących na React’cie.

Wyobraźmy sobie klasyczną aplikację React’ową. Składa się ona z wielu komponentów, a jej rozszerzanie i rozbudowywanie zazwyczaj łączy się z tworzeniem kolejnych. Załóżmy, że naszym dzisiejszym zadaniem jest dodanie animującego się header’a. Chcielibyśmy, by początkowo był on przeźroczysty (opacity: 0) i w raz ze scrollowaniem strony, był coraz bardziej widoczny (aż do opacity: 1). Końcowym efektem jest to, co widzicie na gifie poniżej.

Pierwszym naszym rozwiązaniem jest umieszczenie kodu odpowiedzialnego za tę funkcjonalność w komponencie, który wyświetla header i listę.

DummyPage.jsDummyPage.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class DummyPage extends Component {
constructor() {
super();
this.state = {
scrollPos: 0,
};
this.scrollView = null;
this.handleScroll = this.handleScroll.bind(this);
this.calculateOpacity = this.calculateOpacity.bind(this);
}
calculateOpacity() {
if(!this.state.scrollPos) return 0;
if (this.state.scrollPos > 200) return 1;
return this.state.scrollPos / 200;
}
handleScroll() {
if(!this.scrollView) return;
const scrollPos = this.scrollView.scrollTop;
this.setState({ scrollPos });
}
render() {
return (
<div
className="container"
onScroll={this.handleScroll}
ref={ref => this.scrollView = ref}
>
<Header
opacity={this.calculateOpacity()}
text="Dummy page"
/>
<List />
</div>
);
}
}

Wszystko pięknie śmiga, więc jesteśmy zadowoleni. Jednak za kilka dni, okazuje się, że to rozwiązanie tak przypadło użytkownikom do gustu, że chcielibyśmy je zastosować w innych miejscach aplikacji. I co teraz? Wiemy dobrze, że kopiowanie kodu odpowiedzialnego za animowanie headera to nie jest dobry pomysł. Jak sprawić, byśmy mogli użyć tego zachowania w wielu miejscach aplikacji?

Higher Order Component

Zacznijmy od krótkiego wstępu i powiedzenia kilku słów wyjaśnienia czym są Higher Order Components . HOC (używając skróconej nazwy komponentu wyższego rzędu) jest funkcją, która bierze jako argument komponent i zwraca nowy komponent. W ten sposób jesteśmy w stanie oddzielić pewną logikę, zamknąć ją w tejże funkcji i przekazując do niej różnego rodzaju komponenty, w pewien sposób powiększać ich możliwości.

Zobaczmy jak wygląda to w przypadku naszej mini-aplikacji. Spróbujmy zaimplementować tę samą funkcjonalność, lecz oddzielić logikę odpowiedzialną za animowanie headera do naszego HOCa.

PageWithHoc.jsPageWithHoc.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import withAnimatedHeader from '../enhancers/withAnimatedHeader';

const PageWithHoc = ({ opacity }) => (
<div>
<Header
blue
opacity={opacity}
text="Page with HOC"
/>
<List/>
</div>
);

export default withAnimatedHeader(PageWithHoc);

Widzimy, że komponent zawierający header i listę znacząco się odchudził. Ale możemy też zauważyć, że nie jest on już eksportowany. Eksportujemy rezultat wywyołania funkcji withAnimatedHeader, do której przekazaliśmy nasz komponent. Czym jest ta funkcja? Dobrze myślicie, to jest właśnie Higher Order Component.

Teraz sprawdźmy jak wygląda funkcja withAnimatedHeader czyli komponent wyższego rzędu.

withAnimatedHeader.jswithAnimatedHeader.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const withAnimatedHeader = (WrappedComponent) => {
return class extends Component {
constructor() {
super();
this.state = {
scrollPos: 0,
};
this.scrollView = null;
this.handleScroll = this.handleScroll.bind(this);
this.calculateOpacity = this.calculateOpacity.bind(this);
}
calculateOpacity() {
if(!this.state.scrollPos) return 0;
if (this.state.scrollPos > 200) return 1;
return this.state.scrollPos / 200;
}
handleScroll() {
if(!this.scrollView) return;
const scrollPos = this.scrollView.scrollTop;
this.setState({ scrollPos });
}
render() {
return (
<div
className="container"
onScroll={this.handleScroll}
ref={ref => this.scrollView = ref}
>
<WrappedComponent {...this.props} opacity={this.calculateOpacity()} />
</div>
);
}
};
}

export default withAnimatedHeader;

Widzimy, że to właśnie tutaj jest zamknięta logika animacji. WithAnimatedHeader zwraca nowy komponent, który zdefiniowane ma metody calculateOpacity i handleScroll. Komponent ten, renderując się, tworzy kontener, który będzie obsługiwał scroll’a a następnie renderuje komponent, który przekazaliśmy jako argument do funkcji.

Więcej na temat Higher Order Components możesz znaleźć w dokumentacji React’a.

Function as children

Kolejnym wzorcem, który pozwala na oddzielenie i reużywanie części logiki jest wykorzystanie function as children. Z pewnością spotkaliście się z użyciem this.props.children jako sposobu na zwiększenie komponowalności waszego React’owego kodu. (Jeśli nie, tutaj jest kilka informacji na ten temat). Ale czy wiedzieliście, że jako children możemy przekazać funkcję?

Spójrzmy na przykład :)

PageWithRenderCallback.jsPageWithRenderCallback.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import ScrollViewWithAnimatedHeader from '../enhancers/ScrollViewWithAnimatedHeader';

const PageWithRenderCallback = ({ opacity }) => (
<ScrollViewWithAnimatedHeader>
{
(opacity) => (
<div>
<Header
green
opacity={opacity}
text="Page with render callback"
/>
<List/>
</div>
)
}
</ScrollViewWithAnimatedHeader>
);

export default PageWithRenderCallback;

Znów widzimy, że nasz komponent zawierający Header i Listę jest malutki, ale renderuje w środku komponent ScrollViewWithAnimatedHeader. Ten z kolei przyjmuje jako children funkcję. Ta funkcja zostanie w trakcie renderowania wywołana z argumentem opacity, który możemy przekazać headerowi.

Z czego składa się ScrollViewWithAnimatedHeader?

ScrollViewWithAnimatedHeader.jsScrollViewWithAnimatedHeader.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

class ScrollViewWithAnimatedHeader extends Component {
constructor() {
super();
this.state = {
scrollPos: 0,
};
/* ... */
}
calculateOpacity() {
/* ... */
return opacity;
}
handleScroll() {
/* ... */
}
render() {
return (
<div
className="container"
onScroll={this.handleScroll}
ref={ref => this.scrollView = ref}
>
{this.props.children(this.calculateOpacity())}
</div>
);
}
};

export default ScrollViewWithAnimatedHeader;

Jest to zwykły komponent, który dodatkowo w metodzie render wywołuje funkcję przekazaną jako this.props.children i przekazuje jej jako argument wyliczoną wartość opacity. To nie jest wcale takie trudne, prawda?

Różnice

Pokazałam Wam 2 wzorce, które możecie zastosować, by wydzielić logikę pewnej funkcjonalności i sprawić, by wasz kod w React był bardziej DRY. Teraz całkiem prawdopodobne, że pojawi się pytanie: który z nich wybrać?
Jeśli wziąć pod uwagę popularność rozwiązania to zdecydowanie wygrywają Higher Order Components. Są bardzo często używane w środowisku React’owym, a jako przykład można podać connect z react-redux, które pewnie wielu z was kojarzy. Function as children nie jest tak popularne, ale niektóre biblioteki też korzystają z tego wzorca (np. react-motion).
Jeśli jednak przyjrzymy się konkretom to lepiej wypada function as children. Nie jest to widoczne na pierwszy rzut oka, w momencie gdy mamy do czynienia z jedną funkcjonalnością. Lecz gdybyśmy chcieli komponować kilka takich funkcjonalności ze sobą, to rozwiązanie prezentuje swoją siłę. (Jeśli chcecie dowiedzieć się więcej na ten temat zapraszam do obejrzenia tego talka, który był też inspiracją do moich dzisiejszych poczynań).

Mam nadzieję, że dzisiejszy artykuł pomoże Wam lepiej organizować logikę w waszych aplikacjach.
Repozytorium z kodem, który stworzyłam w ramach tego przykładu jest tutaj.

Do następnego!
Ania