Dłoń wyjmująca plik płyt winylowych z pudełka.

Wyszukiwarka kryptowalut (część 2): Obsługa zdarzeń

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 SearchBara 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 statesearchQuery.

Pozostało przypisać handler do handleChange.

<SearchBar handleChange={searchChangedHandler} />	

this is lost

Zobaczmy, czy wszystko działa.

Błąd wiazania this w handlerze searchChangedHandler

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.

  1. bind inline
  2. <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.

  3. Arrow inline
  4. <SearchBar handleChange ={() => this.searchChangedHandler())} />
    

    Rozwiązanie o takich samych wadach i zaletach co bind inline (jedynie prezentuje się nieco lepiej).

  5. bind w constructorze
  6. 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 constructora tylko w celu bindowania wiązań.

  7. Pole klasy
  8. 	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!

Podsumowanie

Co dalej w planach? Do końca serii zostały jeszcze trzy wpisy. Następny będzie o stylach w React. Czas najwyższy pozbyć się tego surowego wyglądu rodem z początków internetu.

Kod źródłowy projektu jest dostępny tutaj.

Podobał Ci się dzisiejszy artykuł? Udostępnij go w Twoich ulubionych mediach społecznościowych. Może znajdzie się ktoś, dla kogo również będzie wartościowy.

Bądź na bieżąco z serią Pierwszy projekt w ReactJS. Wystarczy polubić fanpage AlgoSmart na fejsie, obserwować mój profil na Twitterze i regularnie odwiedzać portal Polski Front-end.

Do zobaczenia za tydzień :).

Zdjęcie tytułowe autorstwa: unsplash-logoClem Onojeghuo

Marcin Czarkowski

Cześć! Ja nazywam się Marcin Czarkowski, a to jest AlgoSmart - blog, na którym dzielę się wiedzą o ReactJS, JavaScript oraz CSS. Od niedawna tworzę materiały na YouTube, warto rzucić okiem :).