Przyszedł czas na zbudowanie najciekawszego komponentu w całym trackerze kryptowalut. Jest nim SearchBar. Całe działanie tego komponentu opiera się o obsługę zdarzeń. Z wpisu dowiesz się, jakie są różnice pomiędzy zdarzeniami w React i DOM API. Dużo uwagi poświęciłem wiązaniu this
, które może sprawić Ci niemiłą niespodziankę przy przekazywaniu referencji do funkcji w props
. Najlepsze zostawiłem na koniec, w ostatniej części wpisu implementujemy algorytm wyszukiwarki.
Jeżeli jeszcze nie czytałeś pierwszej części wpisu poświęconego wyszukiwarce kryptowalut, znajdziesz go tutaj.
Dzieje się
Zacznijmy od szkieletu nowego komponentu.
// SearchBar.js const SearchBar = () => ( <input > );
Za wyszukiwarkę będzie służył nam najzwyklejszy input. Czego brakuje w tym nadzwyczaj skromnym komponencie? Jedyne, czego nam brakuje, to tytułowej obsługi zdarzeń.
Obsługa zdarzeń w React bardzo przypomina obsługę zdarzeń elementów DOM.
Mimo tych podobieństw, są dwie zasadnicze różnice związane ze składnią:
- Nazwy eventów pisane są camelCasem, a nie lowercasem (onClick vs onclick).
- Przypisując handlera w JSX, przekazujemy referencję do funkcji, a nie stringa z jej nazwą.
Zgodnie z podstawowym założeniem Reacta, obsługa zdarzeń jest deklaratywna. Nie ma potrzeby brudzenia sobie rąk wywoływaniem addEventListener
na wyrenderowanych elementach DOM. Listenery w React przypisuje się do elementów za pomocą specjalnych props
ów. props
dają pełną dowolność co do typu wartości, stąd przekazujemy referencję do funkcji łańcucha.
Propsy listenerów, takie jak onClick
, są wrapperami na te znane z DOM API. Możemy ich używać na dowolnym elemencie. Pełną listę wspieranych zdarzeń znajdziesz tutaj.
W przypadku SearchBar
a przyda nam się onChange
, odpowiednik oninput
.
const SearchBar = ({ handleChange }) => ( <input onChange={handleChange} > );
Handler trafi do listenera za pośrednictwem props
. Będzie nazywał się handleClick
, zgodnie z popularną konwencją nazewniczą – handleEventName
.
Do czego będzie służył handler przekazywany do SearchBar
? Będzie informował komponent App
o tym, co użytkownik wprowadza do inputa.
Skąd mamy się dowiedzieć, co użytkownik wklepał do inputa w zupełnie innym komponencie?
Przy każdym wywołaniu zdarzenia, React przekazuje do callbacka obiekt event
. To kolejne podobieństwo, które na pewno kojarzy Ci się z DOM API.
Zróbmy szybką powtórkę dla zapominalskich. Obiekt event
jest źródłem informacji o zdarzeniu. Możemy dowiedzieć się, jaki element uruchomił listenera oraz uzyskać informacje o jego stanie.
Do obsługi zdarzenia zadeklarujemy metodę searchChangedHandler
.
// App.js searchChangedHandler(event) { this.setState({ searchQuery: event.target.value }); }
Zaczęliśmy od przypisania przechwyconej frazy do nowej właściwości state
– searchQuery
.
Pozostało przypisać handler do handleChange
.
<SearchBar handleChange={searchChangedHandler} />
this
is lost
Zobaczmy, czy wszystko działa.
Woops. Wpadliśmy w pułapkę, przed którą przestrzegałem na początku wpisu. W metodach JavaScript, this
nie jest domyślnie związane z klasą otaczającą. Bez jawnego wiązania, przy wywołaniu searchChangedHandler
przez onClick, this
jest undefined
.
Jeżeli nie pamiętasz, jak działa this
w JavaScript, to zajrzyj do tego wpisu z serii powtórkowej.
W React mamy cztery sposoby na zapewnienie trwałości wiązania this
przy przekazywaniu referencji do komponentów dzieci. Przeanalizujmy ich wady i zalety.
bind
inline- Arrow inline
bind
w constructorze- Pole klasy
<SearchBar handleChange ={this.searchChangedHandler.bind(this)} />
Jasny i zwięzły kod, ale niestety, może powodować problemy z wydajnością. bind
zwraca nowy egzemplarz funkcji przy każdym renderze, co wiąże się z zużyciem dodatkowcych zasobów i może wywoływać kolejne, niepotrzebne rerendery. W małych i średnich aplikacjach te problemy będą niezauważalne. Dopiero przy wyświetlaniu listy setek elementów z podpiętymi handlerami zaczną się prawdziwe kłopoty. Taki scenariusz został świetnie opisany w tym artykule.
Z uwagi na te słabości, bind
inline jest uznawany przez wielu programistów za antywzorzec. Wszystko zależy od kontekstu, ale istnieją bezpieczniejsze alternatywy.
<SearchBar handleChange ={() => this.searchChangedHandler())} />
Rozwiązanie o takich samych wadach i zaletach co bind
inline (jedynie prezentuje się nieco lepiej).
constructor(props) { super(props); this.searchChangedHandler = this.searchChangedHandler.bind(this); }
Technika rekomendowana w dokumentacji React. Nie ma takich problemów jak rozwiązanie 1 i 2, przez co cechuje ją najlepsza wydajność. Jednak bind w constructorze to często nadmiarowy kod w naszym komponencie. Wielokrotnie przyłapiesz się na deklarowaniu
constructor
a tylko w celu bindowania wiązań.
searchChangedHandler = event => { this.setState({ searchQuery: event.target.value }); }
Najlepsze z dostępnych rozwiązań. Pole klasy nie ma problemów z wydajnością rozwiązania nr 1 i 2, i nie wymaga od nas nadmiarowego kodu rozwiązania nr 3. W momencie pisania tego wpisu, jest to funkcjonalność ekspertymentalna, obecnie w stage 2. Mimo to, pola klas są domyślnie wspierana przez create-react-app, więc nic nie stoi na przeszkodzie, aby z nich korzystać.
W kontakcie
Błąd zniknął, ale mamy kolejny, łatwiejszy do przeoczenia problem. Atrybut value
inputa jest pusty. Najlepiej byłoby, gdyby value
odpowiadało frazie wpisanej do wyszukiwarki.
W React komponent-dziecko nie może modyfikować swoich propsów, więc SearchBar
nie poradzi sobie sam z aktualizacją value
. Musi poprosić rodzica App
o dostosowanie props do tego, co dzieje się z inputem. Na bieżąco przypisujemy jego zawartość do searchQuery
, więc wystarczy przekazać tę wartość z powrotem jako props
.
Stworzy się pętla przepływu danych. Dane wejściowe będą krążyły od inputa, do searchChangedHandler
w App
i z powrotem do inputa.
// App.js <SearchBar handleChange={this.searchChangedHandler} searchQuery={this.state.searchQuery} /> // SearchBar.js const SearchBar = ({ handleChange, searchQuery }) => ( <div> <input value={searchQuery} onChange={handleChange} /> </div> );
Let me see
Czas zrobić użytek z searchQuery
. Przefiltrujemy cryptos
pod kątem zgodności z tą wartością. Wynik tej operacji będzie przechowywany w state, w tablicy matchedCryptos
. Od tej pory CoinList
będzie renderować właśnie tę tablicę.
searchChangedHandler = event => { this.setState({ searchQuery: event.target.value }, () => { const cryptos = [ ...this.state.cryptos]; function isMatched(match) { return function(crypto) { return Object.values(crypto).some(val => String(val).includes(match)); } } const isMatchedWithSearchQuery = isMatch(this.state.searchQuery); const matchedCryptos = cryptos.filter(isMatchedWithSearchQuery); this.setState({matchedCryptos}); }); }
Houston, mamy kolejny problem. searchChangedHandler
ma za dużo obowiązków – aktualizuje searchQuery
oraz przetwarza cryptos
. Wprowadzenie nowej procedury setMatchedCryptos
załatwi sprawę.
searchChangedHandler = event => { this.setState({ searchQuery: e.target.value }, this.setMatchedCryptos); }; setMatchedCryptos() { const cryptos = [...this.state.cryptos]; function isMatched(phrase) { const regex = new RegExp(`\\b${phrase}.*\\b`, "i"); return function(crypto) { return Object.values(crypto).some(val => regex.test(val)); }; } const isMatchedWithSearchQuery = isMatched(this.state.searchQuery); const matchedCryptos = cryptos.filter(isMatchedWithSearchQuery); this.setState({ matchedCryptos }); }
Od razu lepiej. Teraz możemy na spokojnie omówić, w jaki sposób działa algorytm wyszukiwania.
isMatched
to funkcja wyższego rzędu. Jako parametr przyjmuje wyszukiwaną frazę, którą wykorzystujemy do inicjalizacji wyrażenia regularnego. Wykorzystamy je do przefiltrowania wartości obiektów przekazywanych do zwróconej funkcji anonimowej. Aby uzyskać bezpośredni dostęp do tablicy tych wartości, korzystamy z metody ES7 Object.values()
. Następnie wywołujemy na niej metodę some
, pozwalająca sprawdzić, czy którakolwiek z wartości obiektu crypto
pokrywa się z wyrażeniem regularnym.
Pozostaje jedynie przefiltrować cryptos
z wykorzystaniem isMatchedWithSearchQuery
– funkcji zwróconej przez isMatched
z domknięciem na searchQuery
.
W celu uniknięcia wyświetlenia komunikatu „Brak wyników dla wprowadzonej frazy.” przed wpisaniem do wyszukiwarki jakiegokolwiek znaku, musimy wywołać setMatchedCryptos w metodzie cyklu życia componentWillMount
. Dzięki temu zawartość matchedCryptos
będzie się pokrywała z cryptos
.
debounce
Pokusimy się jeszcze o otoczenie setMatchedCryptos
metodą debounce
z biblioteki lodash. debounce
pozwala na opóźnienie wywołania funkcji o czas od jej ostatniego wywołania.
Skąd taki pomysł? Nie ma potrzeby wykonywania setMatchedCrypto
po każdym stuknięciu w klawiaturę przez użytkownika. W końcu interesuje nas wynik wyszukiwania po wprowadzeniu całej frazy.
Zaczniemy od dodania pakietu lodash.debounce
do projektu.
// npm npm install --save lodash.debounce // yarn yarn add lodash.debounce
Następnie przekształcimy setMatchedCryptos
w pole klasy i przekażemy ciało funkcji jako pierwszy parametr debounce
. W drugim parametrze ustawimy opóźnienie na 250ms.
setMatchedCryptos = debounce(() => { const cryptos = [...this.state.cryptos]; function isMatched(phrase) { const regex = new RegExp(`\\b${phrase}.*\\b`, "i"); return function(crypto) { return Object.values(crypto).some(val => regex.test(val)); }; } const isMatchedWithSearchQuery = isMatched(this.state.searchQuery); const matchedCryptos = cryptos.filter(isMatchedWithSearchQuery); this.setState({ matchedCryptos }); }, 250);
Wyszukiwarka działa płynnie, a my ograniczyliśmy zużycie zasobów do minimum. Dobra robota!