Wróć do strony głównej
Angular

Sygnały w Angularze 16

Implementacja sygnałów do wersji 16 Angulara to kolejna przełomowa zmiana wprowadzona w ostatnim czasie. Wpływa na wiele kluczowych aspektów takich jak przepływ danych, mechanizm detekcji zmian, cykl życia komponentów czy użycie reaktywnych wartości. Warto zatem bliżej przyjrzeć się nadchodzącym zmianom, ponieważ z pewnością będą miały wpływ na proces tworzenia aplikacji.

Czym są sygnały?

Sygnały to Reactive Primitives, oparte na wzorcu zwanym Observer Design Pattern. Według tego wzorca mamy do czynienia z Publisherem, który przechowuje jakąś wartość wraz z listą subskrybentów (Subscribers), którzy są nią zainteresowani, a w momencie zmiany tej wartości otrzymują o tym powiadomienie.

Mówiąc prościej sygnał “opakowuje” pewną wartość, dając możliwość reagowania na jej zmiany. Mechanizm ten jest dobrze znany w Angularze za przyczyną RxJS, który udostępnia nam kilka rodzajów Subjectów, a najlepszą analogią jest BehaviorSubject, który tak jak sygnał, ma wartość początkową.

Celem, który przyświecał nowej wersji, było udoskonalenie mechanizmu detekcji zmian. Dlaczego spośród wielu rozważanych opcji, takich jak usprawnienie Zone.js, wprowadzenie API w stylu “setState”, czy właśnie wykorzystanie RxJS, zdecydowano się akurat na sygnały? Oto kilka powodów:

  • Angular może śledzić, które sygnały są odczytywane w widoku, co daje informację które komponenty wymagają odświeżenia ze względu na zmianę stanu
  • możliwość synchronicznego odczytania wartości, która jest zawsze dostępna
  • odczytywanie wartości nie wywołuje dodatkowych efektów (side effects)
  • brak glitchy, które powodowałyby niespójność odczytywanego stanu
  • automatyczne i dynamiczne śledzenie zależności, a co za tym idzie brak jawnych subskrypcji, co zwalnia nas z konieczności zarządzania nimi, aby uniknąć wycieków pamięci
  • możliwość korzystania z nich poza komponentami, co świetnie współgra z Dependency Injection
  • możliwość pisania kodu w deklaratywny sposób

Jak z nich korzystać?

Skoro poznaliśmy już powody, dla których wprowadzono sygnały oraz mechanizm ich działania, czas przyjrzeć się bliżej API, które pozwala na korzystanie z nich.

W Angularze sygnał jest reprezentowany przez interfejs z

  • funkcją getter, której wywołanie zwraca aktualną wartość oraz
  • symbolem SIGNAL pozwalający na jego rozpoznanie przez framework

Odczytanie wartości jest rejestrowane i służy do budowy grafu zależności pomiędzy sygnałami.

Co do zasady sygnały służą tylko do odczytu – chcemy dostać aktualną wartość i śledzić jej zmiany. Angular jednak udostępnia nam interfejs, który pozwala na zmianę wartości za pomocą wbudowanych metod:

Metoda set służy do zmiany aktualnie przechowywanej wartości. Korzystając z analogii do BehaviorSubject, jest to tożsame z wywołaniem metody next

Metoda update służy do wprowadzenia nowej wartości utworzonej na podstawie tej aktualnie przechowywanej. Z kolei mutate bezpośrednio modyfikuje aktualną wartość. Jest przydatna w sytuacji, gdy chcemy zmodyfikować tablicę lub obiekt bez zmiany referencji (np. dodać element do tablicy korzystając z Array.prototype.push).

Wywołanie asReadonly zwraca nowy sygnał, który przechowuje tą samą wartość, ale nie pozwala na jej modyfikację. Wracając do analogii z Subjectem, byłoby to skorzystanie z metody asObservable.

Do utworzenia instancji takiego sygnału służy funkcja:

Funkcja equal pozwala zdefiniować użytkownikowi kiedy dwie wartości (z praktycznego punktu widzenia najbardziej przydatne dla dwóch obiektów) są równe. Domyślna funkcja korzysta z operatora porównania “===” z tym, że obiekty i tablice nigdy nie są równe, co pozwala przechowywanie wartości tych typów i powiadamianie o ich zmianie. 

Przykład użycia metod interfejsu WritableSignal:

Jednym z najczęściej wykorzystywanych operatorów RxJS jest map, który pozwala na stworzenie nowej wartości, zależnej od źródła. W przypadku sygnałów służy do tego funkcja computed:

Tak stworzony sygnał jest zależny od tych, których wartość odczytujemy wewnątrz funkcji computation, co oznacza, że jego wartość zmieni się tylko, gdy przynajmniej jedna z zależności zmieni swoją wartość. Funkcja computation nie może powodować side-effectów, czyli wewnątrz niej możemy dokonywać tylko operacji odczytu. Sprawdzenie czy nowa wartość jest taka sama jak poprzednia działa na tej samej zasadzie, co w przypadku funkcji signal. Podobnie możemy również przekazać funkcję equal, która nadpisze standardowy sposób porównania. Pozwala to na poprawienie wydajności poprzez ograniczenie liczby komputacji.

Sygnał stworzony za pomocą funkcji computed dynamicznie śledzi wartości sygnałów, których wartość została odczytana podczas jego ostatniej komputacji.

Sygnał greeting zawsze będzie śledził wartość showName, ale jeżeli wartość showName będzie równa false, nie będzie śledził zmian sygnału name. W takim przypadku zmiana wartości name nie spowoduje zmiany wartości greeting.

W wielu przypadkach przydatne jest wywołanie tzw. side-effect, czyli wywołanie kodu, który zmienia stan poza swoim lokalnym kontekstem, takich jak np. wysłanie żądania http czy synchronizacja dwóch niezależnych modeli danych. Odwołując się do RxJS mamy operator tap, a w przypadku sygnałów służy do tego funkcja effect:

Zarejestrowana w ten sposób funkcja effectFn odczytuje wartości sygnałów i jest wykonywana za każdym razem, kiedy któryś z nich zmieni swoją wartość. Jako jej argument można opcjonalnie zarejestrować funkcję “czyszczącą”, która jest wywoływana przed kolejnym wykonaniem effectFn i ma możliwość anulowania działania rozpoczętego przy poprzednim wywołaniu.

Moment wykonania efektu nie jest ściśle zdefiniowany i zależy od strategii przyjętej przez Angulara, jednak przy pracy z nimi możemy być pewni pewnych pryncypialnych zasad:

  • efekt zostanie wywołany przynajmniej raz
  • efekt zostanie wywołany po tym jak przynajmniej jeden z sygnałów, od którego jest zależny (czyli odczytuje jego wartość) ulegnie zmianie
  • efekt zostanie wywołany minimalną ilość razy, co oznacza, że jeżeli kilka sygnałów, od których efekt jest zależny zmieni swoją wartość w jednym momencie, kod zostanie wykonany tylko raz

Z racji tego że efekt reaguje na zmianę sygnału, od którego zależy, pozostaje zawsze aktywny i gotowy do reakcji na zmiany. Domyślnie jego anulowanie odbywa się automatycznie. W przypadku ustawienia opcji manualCleanup efekt pozostanie aktywny po zniszczeniu komponentu czy dyrektywy. Aby anulować go ręcznie możemy skorzystać z instancji EffectRef.

Zmiana wartości sygnału wewnątrz efektu jest jest rekomendowane i może prowadzić do niespodziewanych błędów, dlatego domyślnie jest traktowane jako błąd. Aby to zmienić należy ustawić pole allowSignalWrites w obiekcie options.

Sygnałowe komponenty

Uwaga: Sygnałowe komponenty zostały przedstawione i opisane w RFC dotyczącym sygnałów, jednak nie są jeszcze dostępne w obecnej wersji, a obecne API oraz działanie może jeszcze ulec zmianie.

Na wstępie należy zaznaczyć, że opisane w tym rozdziale funkcjonalności działają zarówno w komponentach jak i dyrektywach, których dla uproszczenia nie będę wymieniał w tekście.

Aby korzystać z sygnałów i powiązanych z nimi funkcjonalności w komponentach oraz korzystać z nowego mechanizmu detekcji zmian (o czym szerzej za chwilę) należy ustawić opcję signals w dekoratorze @Component:

Wartość false będzie oznaczała, że korzystamy z dotychczasowego podejścia oraz detekcji zmian opartej na Zone.js. Nie wyklucza to jednak korzystania z obu typów komponentów i ich współistnienia w obrębie jednej aplikacji.

Aby skorzystać z wartości przechowywanej przez sygnał w widoku należy wywołać funkcję getter zwracaną przez signal:

Dotychczas należało unikać wywoływania funkcji w templacie, ponieważ zwracana wartość była obliczana na nowo przy każdej detekcji zmian, co mogło prowadzić do problemów z wydajnością. W tym przypadku nie jest to dłużej problemem – widok zostanie odświeżony po wykryciu zmiany wartości sygnału.

Dotyczasowe dekorator @Input zastąpiono funkcją input, która zwraca Signal (czyli tylko do odczytu) przechowujący najnowszą zbindowaną wartość. Jako argumenty przyjmuje ona domyślną wartość oraz obiekt opcji. Jeżeli w komponencie stworzymy efekt, który korzysta z wartości inputu nie zostanie on wykonany, dopóki ta nie będzie dostępna.

Nowym rodzajem inputu, który jest dostępny w tego rodzaju komponentach jest model, który zwraca WritableSignal, a więc daje możliwość zmiany jego wartości, która propaguje z powrotem do rodzica, zmieniając wartość sygnału, którego referencja została do niego przekazana, tworząc swego rodzaju two-way binding:

Skoro mamy nowy input, zmianie uległ także output. Wprowadzenie sygnałów nie zmienia sposobu w jaki działają, jednak aby zachować spójność API w miejsce dekoratora @Output dostajemy funkcję output, która zwraca EventEmmiter:

Również dekoratory @ViewChild, @ViewChildren, @ContentChild, @ContentChildren tworzące query elementu(ów) z template zamieniają się na odpowiednie funkcje zwracające Signal:

Ze względu na nowy mechanizm detekcji zmian, zmiany dotykają również obszaru lifecycle hooków. Nie są to dłużej metody klasy komponentu, których implementacji wymaga od nas  interfejs a funkcje, które jako argument przyjmują callbacki wykonujące się w odpowiednim momencie. Ich aktywowanie polega na wywołaniu odpowiedniej funkcji wewnątrz konstruktora lub innej metody komponentu. Wprowadzono również trzy nowe hooki, które mają wykonywać kod po operacji renderowania widoku:

Przekazana funkcja zostanie wykonana po zakończeniu kolejnego cyklu detekcji zmian. Przydatna w przypadku ręcznego odczytywania z lub wpisywania do DOM.

Przekazana funkcja zostanie wykonana po każdej aktualizacji DOM podczas renderowania.

To specjalny rodzaj efektu, który wyzwolony (poprzez zmianę w sygnale z którego odczytuje wartość) zostaje wykonany w tym samym czasie co afterRender.

Z zestawu dotychczasowych hooków pozostawiono charakter dwóch:

  • ngOnInit zostaje zastąpione przez afterInit
  • ngOnDestroy przez beforeDestroy

Moment ich wykonania jest taki sam jak poprzedników, czyli odpowiednio: po utworzeniu komponentu i ustawieniu wszystkich inputów oraz przed zniszczeniem komponentu.

Pozostałe hooki nie mają sensu w nowym systemie detekcji zmian i ich działanie może zostać zastąpione poprzez wykorzystanie sygnałów:

  • ngOnChanges – służące do reagowania na zmianę inputów – ponieważ teraz sam input jest sygnałem można wykorzystać computed to utworzenia na jego podstawie nowego sygnału lub zarejestrować odpowiednie operacje wewnątrz effect.
  • ngDoCheck – reagujący na proces detekcji zmian – jego działanie można przenieść do effect.
  • ngAfterViewInit – pozwalający na wykonywanie działań po wyrenderowaniu template – to miejsce zajmuje teraz afterNextRender.
  • ngAfterContentInit, ngAfterViewChecked, ngAfterContentChecked – służące do obserwowania wyników queries z widoku – same queries zwracają teraz sygnał, który z natury jest reaktywny

Nowy mechanizm detekcji zmian

Przegląd zmian rozpocznijmy od przypomnienia jak system Change Detection działał do tej pory. Angular śledzi eventy w przeglądarce (takie jak DOM events, wysłanie żądania HTTP czy timery) za pomocą biblioteki Zone.js, która rozszerza i dodaje callback podczas działania (tzw. monkey-patching) do obiektów (window, document) lub proptotypów (HtmlButtonElement, Promise), które mogą prowadzić do zmian w modelu danych.

Kiedy takie zdarzenie wystąpi framework nie wie co konkretnie, i czy wogóle, się zmieniło. Pobiera on nowe dane i aktualizuje widok porównując je z dotychczasowymi, przechodząc przez całe drzewo komponentów, chociaż najczęściej jedynie niewielka część aplikacji wymaga odświeżenia.

Liczbę sprawdzanych komponentów można ograniczyć poprzez zastosowanie strategii OnPush – wtedy sprawdzone zostają komponenty spełniające przynajmniej jeden z warunków (wystąpienie eventu DOM, zmiana inputu lub jawne oznaczenie komponentu jako wymagającego sprawdzenia) oraz wszyscy jego potomkowie. Krótko mówiąc daje to informację kiedy sprawdzać zmiany, ale już nie gdzie.

Takie rozwiązanie zapewnia pewne korzyści, zwłaszcza w mniejszych aplikacjach:

  • możliwość bezpośredniego korzystania z JSowych struktur danych
  • przechowywanie stanu gdziekolwiek
  • możliwość prostej zmiany stanu, bez konieczności stosowania dodatkowego API

W praktyce ma jednak szereg wad, dostrzegalnych szczególnie w przypadku większych systemów:

  • inicjalizacja działania Zone.js zużywa czas i zasoby, które dodatkowo rosną wraz z powiększaniem się aplikacji
  • konieczność zamiany async/await na Promise, ponieważ dla słów kluczowych niemożliwy jest monkey-patching
  • standardowe API przeglądarki jest modyfikowane co może prowadzić do trudnych w diagnozie błędów
  • zaburzenie standardowego jednokierunkowego przepływu danych może prowadzić do wystąpienia znanego błędu ExpressionChangedAfterItHasBeenCheckedError
  • użycie zewnętrznych bibliotek lub skryptów, które korzystają z API przeglądarki, może prowadzić do dużej liczby niepotrzebnych cykli detekcji zmian
  • jest źródłem problemów z wydajnością aplikacji.

Wykorzystanie sygnałów do detekcji zmian zapewnia większą kontrolę oraz granularność, tym samym zapewniając większą wydajność i lepszy developer experience. Sygnałowe komponenty nie podlegają pod globalny system Change Detection, zamiast tego odświeżane są indywidualnie, według podstawowej zasady:

Detekcja zmian nastąpi tylko kiedy sygnał, którego wartość odczytujemy w template, powiadomi Angulara, że ta została zmieniona.

Granularność nowego mechanizmu polega na niezależnym sprawdzaniu każdego widoku, czyli cegiełki, z których zbudowany jest template – statycznego zestawu elementów HTML, dyrektyw czy komponentów – tworząc UI i dając możliwość warunkowego lub powtarzalnego wyświetlania jego części.

Następujący template składa się z jednego widoku:

Odświeżanie UI na poziomie widoków jest najbardziej wydajnym rozwiązaniem, ponieważ są to relatywnie niewielkie elementy z niezbyt wielką ilością bindingów, przez co koszt takiej operacji jest niewielki. Większe rozdrobnienie zużywałoby dodatkowe ilości pamięci i czasu na śledzenie wielu zależności. Z kolei większe i dynamiczne struktury naturalnie dzielą się na widoki, które mogą być aktualizowane niezależnie.

Dodatkową optymalizację zapewnia fakt, że inputy są teraz sygnałami – aktualizacja wartości inputu natępuje przed detekcją zmian, a nie w trakcie, a sama detekcja nie występuje, jeżeli input nie jest odczytywany w templacie. Sama zmiana bindowanej do inputa wartości nie powoduje również odświeżenia widoku rodzica.

Integracja z RxJS

Observable, dostępne za przyczyną RxJS, są obecnie szeroko wykorzystywane zarówno w samym Angularze jak i całym ekosystemie. Sygnały przejmą część z tych zastosowań, jednak ze względu na to, że za tymi konstruktami stoją dwie odmienne koncepcje, mogą znakomicie ze sobą współpracować. 

Sygnały są synchroniczne, przez co znakomicie nadają się do zarządzania stanem, a także reprezentowania wartości zmieniających się w czasie. Observable z kolei są z natury asynchroniczne i reprezentują strumień danych. RxJS udostępnia również wiele narzędzi, które pozwalają na świetne zarządzanie skomplikowanymi, asynchronicznymi operacjami.

Do zamiany Observable w sygnał służy funkcja toSignal:

Funkcja ta subskrybuje do przekazanego jako argument Observable i zmienia wartość zwracanego sygnału za każdym razem, gdy pojawi się nowa wartość. Subskrypcja następuje od razu, aby nie wywoływać niepotrzebnie kodu tworzącego Observable. Gdy zniszczony zostanie kontekst, w którym funkcja została użyta, np. komponent, nastąpi automatyczne odsubskrybowanie.

Dopóki Observable nie wyemituje żadnej wartości, domyślnie sygnał będzie przechowywał undefined, co nie zawsze jest najlepszym wyborem, dlatego obiekt opcji zawiera pole initialValue, którego wartość zainicjuje sygnał. 

Niektóre Observable emitują wartości synchronicznie (jak np. BehaviorSubject). W takim przypadku możemy ustawić opcję requireSync, co pozwala pozbyć się obsługi wartości początkowej. Jeżeli jednak opcja ta zostanie ustawiona, a przekazany Observabe będzie tworzony asynchronicznie, funkcja wyrzuci błąd.

Observable oferuje subskrybentowi trzy typy notyfikacji: next, error i complete. Ponieważ sygnał jest zainteresowany jedynie emitowanymi wartościami funkcja toSignal nie obsługuje błędów i wyrzuci go przy kolejnej próbie odczytania wartości z sygnału. Aby temu zapobiec należy ręcznie obsłużyć błąd za pomocą bloku try/catch lub operatora catchError.

Za konwersję w drugą stronę odpowiada funkcja toObservable:

Kiedy utworzony Observable zostanie zasubskrybowany utworzy ona efekt, wewnątrz którego będzie przekazywać kolejne wartości sygnału do subskrybentów. Wszystkie nowe wartości są emitowane w sposób asynchroniczny, co oznacza że kilkukrotna i synchroniczna zmiana wartości sygnału spowoduje wyemitowanie tylko ostatniej z nich:

Podsumowanie

Wiele zmian, ale także wiele nowych możliwości. Wprowadzenie sygnałów poprawia optymalizację i daje pole do implementacji dalszych zmian, które mogą poprawić developer experience, stanowi świetne uzupełnienie dla RxJS, definiuje nowy sposób zarządzania stanem oraz (w przyszłości) tworzenia komponentów. Mam nadzieję, że ten artykuł stanowił wartościowe wprowadzenie do tematu oraz podstawę do dalszego jego zgłębiania. Zapraszamy do podzielenia się Waszymi opiniami na temat tych nowości.

O autorze

Milosz Rutkowski

Angular Developer w House of Angular. Miłośnik tworzenia aplikacji webowych z zapałem do coraz lepszego poznawania Angulara. Fan czystego kodu, dobrych praktyk i świetnie skomponowanej architektury.

Zapisz się do naszego newslettera. Bądź na bieżąco z najnowszymi trendami, poradami, meetupami i stań się częścią społeczności Angulara w Polsce. Rynek pracy docenia członków społeczności.

Jeden komentarz

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *