Wow, nasze pierwsze obserwable :) Szkoda tylko, że na razie nie widać absolutnie żadnych zalet w stosunku do czystego JS. A skoro nie widać różnicy… i tak dalej. Dodajmy więc kolejne wymagania do naszego projektu: tylko co trzecie kliknięcie ma zmieniać wyświetlaną liczbę.
Rx.Observable
.fromEvent(button, 'click')
.bufferCount(3) // !
.subscribe((res) => {
output.textContent = Math.random().toString();
});
Jakby to wyglądało w czystym JS? Na pewno byłoby nieco dłuższe. Tutaj pojawia się cała moc Observabli: operatory. Jest ich mnóstwo i nie sposób wszystkie zapamiętać, jednak dają one przeogromne, właściwie nieskończone możliwości! W tym przypadku dzięki bufferCount
zbieramy (buforujemy) 3 zdarzenia i dopiero wtedy je emitujemy w postaci tablicy.
Ale w zasadzie to wymaganie 3 kliknięć łatwo też napisać w czystym JS. Zmieńmy je nieco: Niech to będą 3 kliknięcia, ale tylko w krótkim czasie 400ms. Czyli coś w stylu potrójnego kliknięcia:
const click$ = Rx.Observable.fromEvent(button, 'click');
click$
.bufferWhen(() => click$.delay(400)) // ! w ciągu 400 ms
.filter(events => events.length >= 3) // ! tylko 3 kliknięcia lub więcej
.subscribe((res) => {
output.textContent = Math.random().toString();
});
bufferWhen
zbiera wszystkie kliknięcia aż do momentu gdy przekazana funkcja coś wyemituje. Ta robi to dopiero po 400ms po kliknięciu. A więc razem te dwa operatory powodują, że po upływie 400ms od pierwszego kliknięcia, zostanie wyemitowania tablica ze wszystkimi kliknięciami w tym czasie. Następnie używamy filter
aby sprawdzić czy kliknięto 3 lub więcej razy. Czy teraz wydaje się to bardziej interesujące?
Tworzenie observabli
Muszę przy okazji wspomnieć, że sposobów na tworzenie observabli jest bardzo wiele. Jeden z nich to poznany już fromEvent
. Ale ponadto, między innymi, możemy automatycznie przekształcić dowolny Promise w Observable przy użyciu funkcji Rx.Observable.fromPromise(…)
, albo dowolny callback dzięki Rx.Observable.bindCallback(…)
lub Rx.Observable.bindNodeCallback(…)
. Dzięki temu praktycznie dowolne API dowolnej biblioteki możemy zaadaptować na Observable.
HTTP
Jeśli masz ulubioną bibliotekę do obsługi żądań http, jak choćby fetch
, możesz ją łatwo zaadaptować na Observable. Jednak możesz też skorzystać z metody Rx.Observable.ajax
i na potrzeby tego wpisu ja tak właśnie zrobię.
Okej, prosty przykład, pobieramy listę postów z API i ją wyświetlamy. Renderowanie nie jest tematem tego posta, więc tutaj je pominę, a samo pobieranie jest tak proste jak:
const postsApiUrl = `https://jsonplaceholder.typicode.com/posts`;
Rx.Observable
.ajax(postsApiUrl)
.subscribe(
res => console.log(res),
err => console.error(err)
);
Voilà! To jest aż tak proste! Dodałem tutaj też drugi argument do funkcji subscribe
, który służy do obsługi błędów. Okej, co teraz możemy z tym zrobić? Niech po każdym kliknięciu przycisku zostaną pobrane posty losowego użytkownika:
Rx.Observable
.fromEvent(button, "click")
.flatMap(getPosts)
.subscribe(
render,
err => console.error(err)
);
function getPosts() {
const userId = Math.round(Math.random() * 10);
return Rx.Observable.ajax(
`https://jsonplaceholder.typicode.com/posts?userId=${userId}`
);
}
Użyłem tutaj funkcji flatMap
(zwanej też mergeMap
), która dla każdego zdarzenia (kliknięcia) wywoła funkcję getPosts
i poczeka na jej rezultat.
Super! ;) Jednak występuje tutaj pewien problem: Wielokrotne kliknięcie na przycisk powoduje nieprzyjemny efekt wyrenderowania listy wielokrotnie. Do tego tak naprawdę nie mamy pewności, czy ostatnio pobrane dane zostaną wyrenderowane jako ostatnie… jeśli szybko klikniemy kilka razy, niemal jednocześnie zostanie wysłanych wiele żądań, a opóźnienia mogą sprawić, że żądanie wysłane wcześniej zwróci odpowiedź później… Jest to znany, częsty problem tzw. race conditions.
Rozwiązanie go przy pomocy czystego JS nie jest takie trywialne. Musielibyśmy przechowywać ostatnio wykonane żądanie, a od poprzednich się odsubskrybować. Do tego przydałoby się poprzednie żądania anulować… tu przydaje się kolejny operator z rxjs: switchMap
. Dzięki niemu nie tylko automatycznie zostanie wyrenderowany tylko ostatnio pobrany zestaw danych, ale także poprzednie żądania będą anulowane:
Observable z różnych źródeł
Skoro umiemy już tak dużo to może teraz rozbudujemy nieco naszą aplikację. Damy użytkownikowi możliwość wpisania ID usera od 1 do 10 (input
) oraz wybór zasobu, który ma zostać pobrany: posts, albums, todos (select
). Po zmianie dowolnego z tych pól żądanie powinno zostać wysłane automatycznie. Jest to praktycznie kopia 1:1 funkcji, którą ostatnio implementowałem w aplikacji dla klienta. Na początek definiujemy obserwable na podstawie zdarzeń input
i change
dla selecta i inputa:
const id$ = Rx.Observable
.fromEvent(input, "input")
.map(e => e.target.value);
const resource$ = Rx.Observable
.fromEvent(select, "change")
.map(e => e.target.value);
Od razu też mapujemy każde zdarzenie na wartość inputa/selecta. Następnie łączymy obie obserwable w taki sposób, aby po zmianie dowolnej z nich, zostały pobrane wartości obu. Używamy do tego combineLatest
:
Rx.Observable
.combineLatest(id$, resource$)
.switchMap(getPosts)
.subscribe(render);
Co istotne, funkcja combineLatest
nie wyemituje niczego dopóki obie observable (id
) nie wyemitują przynajmniej jednej wartości. Innymi słowy, nic się nie stanie dopóki nie wybierzemy wartości w obu polach.
Podsumowanie
W zasadzie o obserwablach nie powiedziałem jeszcze za dużo. Chciałem szybko przejść do przykładu i pokazać coś praktycznego. Czy mi się to udało?
Jako bonus zmieniam ostatni kod i nieco inaczej obsługuję pole input. Pytanie czy i dlaczego jest to lepsze?
const id$ = Rx.Observable
.fromEvent(input, "input")
.map(e => e.target.value)
.filter(id => id >= 1 && id <= 10)
.distinctUntilChanged()
.debounceTime(200);