Po co jest ten ref w Reakcie?

Michał Taszycki
Michał Taszycki

Hej! A może zbudujemy Winampa* w React.js?

*No... prawie Winampa.

Załóżmy, że chcemy zbudować prosty odtwarzacz muzyki w Reakcie.

Tworzymy zatem komponent AudioPlayer. Wewnątrz renderujemy element <audio>. Następnie za pomocą propsów możemy przekazać do niego:

  • url pliku dźwiękowego (soundUrl),
  • informacje o tym czy ma wyświetlać natywne kontrolki (showNativeControls) oraz
  • poinstruować czy ma zapętlać dźwięk (loop).
function AudioPlayer({ soundUrl, loop, showNativeControls }) {
  return (
    <div className="AudioPlayer">
      <h3>Loop is: {loop ? "ON" : "OFF"}</h3>
      <audio loop={loop} controls={showNativeControls}>
        <source src={soundUrl} />
      </audio>
    </div>
  );
}

Efekt końcowy wygląda tak:

No i gra muzyka. Parę linijek kodu i mamy działający odtwarzacz. Nie jest najpiękniejszy, do Winampa jeszcze daleka droga, ale przecież nie od razu Rzym zbudowano.

Problem: Natywne kontrolki

Natywne kontrolki nie każdemu muszą przypaść do gustu. A nawet jeśli przypadną, to bedą wyglądać inaczej w różnych przeglądarkach.

Na którymś etapie rozbudowywania odtwarzacza, chcielibyśmy mieć całkowitą kotrolę nad tym jakie przyciski będzie wyświetlać i co mają one robić.

Dodajmy zatem dwa przyciski do naszego oddtwarzacza: "Play" i "Pause", a następnie, zastanówmy się co dalej?

function AudioPlayer({ soundUrl, loop = true, showNativeControls = false }) {
  return (
    <div className="AudioPlayer">
      <h3>Loop is: {loop ? "ON" : "OFF"}</h3>
      <div className="Controls">
        <button onClick={() => console.log("Play not implemented")}>
          Play
        </button>
        <button onClick={() => console.log("Pause not implemented")}>
          Pause
        </button>
      </div>
      <audio loop={loop} controls={showNativeControls}>
        <source src={soundUrl} />
      </audio>
    </div>
  );
}

Mamy zatem działający odtwarzacz z dwoma przyciskami, które niestety jeszcze nic nie robią.

Teraz pytanie brzmi jak zrobić żeby po wciśnięciu Play, muzyka zaczęła grać a po wciśnięciu Pause zatrzymała się?

Cóż, wystarczy tylko dostać się do obiektu DOM w pamięci przeglądarki. Tego, który reprezentuje element <audio>, który wyrenderowaliśmy w komponencie i wywołać na nim metody play() oraz pause().

W czystym javascripcie zrobilibyśmy to jak za czasów jQuery. Czyli znaleźli ten obiekt za pomocą id i po kłopocie.

Tylko jak to zrobić w Reakcie?

Teoretycznie moglibyśmy zrobić tak samo...

Złe rozwiązanie nr 1.

<button onClick={() => document.getElementByTagName("audio")[0].play()}>
  Play
</button>

Powyższy kod zadziała, ale to... nie najlepsze rozwiązanie.

Po pierwsze, zakładamy że jest tylko jeden element audio na stronie. Po drugie szukamy po całym drzewie DOM naszego elementu przy każdym kliknięciu przycisku.

Może da się to jakoś usprawnić?

A co gdybyśmy wyszukali obiekt DOM elementu audio tylko raz? Zaraz po tym jak React wyrenderuje i zamontuje komponent w drzewie DOM.

Złe rozwiązanie nr 2.

Skorzystajmy zatem z useEffect i zapiszmy gdzieś referencję do znalezionego obiektu.

let audioRef;
useEffect(() => {
  audioRef = document.getElementByTagName("audio")[0];
}, []);

a potem

<button onClick={() => audioRef.play()}>Play</button>

Tylko, że to nie zadziała.

Pamiętajmy że jesteśmy w komponencie funkcyjnym. Jeśli zapiszemy referencję do obiektu w zmiennej lokalnej, to jej wartość zniknie przy kolejnym renderowaniu.

Musimy mieć jakieś miejsce do zapisywania rzeczy które będzie istnieć przez cały cykl życia komponentu.

Złe rozwiązanie nr 3.

Stwórzmy zmienną globalną! :D

useEffect(() => {
  window.audioRef = document.getElementByTagName("audio")[0];
}, []);

a potem

<button onClick={() => window.audioRef.play()}>Play</button>

To rozwiązanie zadziała, ale za takie coś dostaniemy po łapach od każdego, kto zobaczy nasz kod!

Tworzymy zmienną globalną, którą każdy może nadpisać. Na dodatek nie sprzątamy po sobie.

Po prostu tragedia. Nie róbmy tak więcej!

Gdybyśmy byli w komponencie klasowym, to życie byłoby prostsze. Stworzylibyśmy sobie zmienną instancji i zapamiętali tam naszą referencję.

No ale jesteśmy w komponencie funkcyjnym i potrzebujemy jakiegoś rozwiązania.

Gdyby tylko w Reakcie istniało coś co jest przeznaczone do przetrzymywania referencji i jest utrzymywane przez cały cykl życia komponentu...

Prawie dobre rozwiązanie

Na szczęście istnieje taki mechanizm. Jest to ref - czyli zmienna referencyjna.

Jest to obiekt tworzony przez hooka useRef. Zawiera on pole current, pod którym można zapisywać rzeczy, które mają być dostępne pomiędzy renderami przez cały cykl życia komponentu.

Pod tym względem jest to coś podobnego do stanu, ale zmiana wartości refa nie powoduje ponownego renderowania.

const audioRef = useRef();
useEffect(() => {
  audioRef.current = document.getElementByTagName("audio")[0];
});

A potem:

<button onClick={() => audioRef.current.play()}>Play</button>

I to już jest całkiem niezłe rozwiązanie. Przechowujemy referencję we właściwym miejscu. Nie zniknie nam pomiędzy renderami, a React usunie ją z pamięci po odmontowaniu komponentu.

Ale nadal niepotrzebnie odpalamy useEffect() i szukamy obiektu audio w całym drzewie DOM. A przecież React doskonale wie, który element został wyrenderowany przez nasz komponent. Na szczęście możemy skorzystać z tego faktu.

Rozwiązanie poprawne

React udostępnia specjalnego propsa o nazwie "ref". Gdy przypiszemy zmienną referencyjną do tego propsa w jakimś elemencie, to React zrobi to co robiliśmy ręcznie w useEffect. Czyli przypisze obiekt DOM tego elementu do pola current naszej zmiennej referencyjnej.

Najlepsze jest to, że nie musi wcale go szukać po całym drzewie DOM. Zamiast tego przypisze do refa świeżo stworzony obiekt, zaraz po pierwszym wyrenderowaniu komponentu.

Czyli musimy zrobić tylko trzy rzeczy:

  • Stworzyć zmienną referencyjną przy użyciu useRef()
const audioRef = useRef();
  • Przekazać zmienną do propsa ref elementu <audio>
<audio ref={audioRef} />
  • W handlerze onClick użyć obiektu DOM w zmiennej referencyjnej.
<button onClick={() => audioRef.current.play()}>Play</button>

Całość kodu będzie od treaz wyglądać tak:

function AudioPlayer({ soundUrl, loop = true, showNativeControls = false }) {
  const audioRef = useRef();
  return (
    <div className="AudioPlayer">
      <h3>Loop is: {loop ? "ON" : "OFF"}</h3>
      <div className="Controls">
        <button onClick={() => audioRef.current.play()}>Play</button>
        <button onClick={() => audioRef.current.pause()}>Pause</button>
      </div>
      <audio ref={audioRef} loop={loop} controls={showNativeControls}>
        <source src={soundUrl} />
      </audio>
    </div>
  );
}

A nasz odtwarzacz będzie działać nawet gdy nie wyświetlimy natywnych kontrolek.

Jak już pobawisz się odtwarzaczem, to wróć do kodu i zauważ, że nie jest ważne czy najpierw wyświetlamy przyciski, a potem element <audio>, czy odwrotnie. Kolejność nie ma tu znaczenia.

Musisz tylko pamiętać o tym, że ze zmiennych referencyjnych możesz korzystać tylko w handlerach zdarzeń i w hooku useEffect.

Tylko tam masz gwarancję, że pole current będzie zainicjalizowane.

Podsumowując

Zmiennych referencyjnych należy używać w dwóch przypadkach:

  1. Gdy potrzebujemy mieć dostęp do natywnych obiektów DOM
  2. Gdy chcemy coś zapisać w pamięci na dłużej, ale nie chcemy żeby komponent się przerenderował po zmianie.

I to wystarczy na początek.

Powodzenia w refowaniu.

P.S. Zapraszam Cię na bezpłatne szkolenie z React.js.

W 30 minut [sic!] nauczę Cię podstaw Reacta i pokażę Ci jak zrobić bardziej rozbudowany odtwarzacz muzyki.

== Zbuduj Spotify w React.js w 30 minut ==

Zbuduj Spotify w React.js w 30 minut