Angular Signals – nowy sposób wykrywania zmian

Tomasz Zawiślak

Wprowadzenie

Począwszy od Angular v16 deweloperzy otrzymali naprawdę potężną i oczekiwaną funkcję – Signals. Obiecano, że Signals będą działać z bardziej szczegółową reaktywnością niż Observables i dadzą nam lepszą kontrolę nad wykrywaniem zmian, i tak właśnie jest. W rezultacie wzrasta produktywność.

Czym jest sygnał?

W kilku prostych słowach:

Sygnał to wartość plus powiadomienie o zmianie.

Przeanalizujmy różnicę między Signal a prostą wartością (tj. const lub let). Rozważmy następujący przykład:

let x = 10;

Zawsze możemy uzyskać dostęp do x (oczywiście w jego zakresie), ale zawsze musimy to zrobić „ręcznie”, aby uzyskać jego najnowszą wartość. Nie jesteśmy powiadamiani ani świadomi zmiany wartości x, więc prawdopodobnie nie możemy na to odpowiednio zareagować. Sprawdźmy poniższy przykład:

let x = 10;
let y = 5;
let z = x + y;
console.log('z = ', z); // z = 15

Jak na razie wszystko w porządku. Teraz dodajmy jeszcze jedną linię, aby zmodyfikować wartość y i sprawdźmy, co się stanie:

let x = 10;
let y = 5;
let z = x + y;
console.log('z = ', z); // z = 15

y = 7;
console.log('z = ', z); // z = 15

Jak widzimy, pomimo faktu, że zmieniliśmy wartość y, wartość z nie zmieniła się – dokładnie tak, jak oczekiwaliśmy. Aby ten kod robił to, co zamierzaliśmy, po zmianie wartości y musimy ponownie obliczyć wartość z, dodając następującą linię: z = x + y; i musimy skopiować / wkleić tę linię za każdym razem, gdy zmieniamy wartość y (lub x).

let x = 10;
let y = 5;
let z = x + y;
console.log('z = ', z); // z = 15

y = 7;
z = x + y;
console.log('z = ', z); // z = 17

Tak więc ostateczny kod powinien wyglądać jak ten powyżej. Nie jest on jednak aż tak wydajny. W tym miejscu do gry wkraczają Sygnały. Przeprowadźmy refaktoryzację naszego prostego przykładu przy użyciu Signals (na razie bez wyjaśniania składni):

let x = signal(10);
let y = signal(5);
let z = computed(() => x() + y());
console.log('z = ', z()); // z = 15

y.set(7);
console.log('z = ', z()); // z = 17

Powyższy kod będzie działał zgodnie z naszymi założeniami. Warto zauważyć, że ilość kodu nie jest znacząco większa (lub mniejsza) niż w podstawowym przykładzie. Dodatkowo należy zwrócić uwagę na sposób, w jaki możemy uzyskać dostęp do aktualnej wartości z – po prostu używamy gettera: z().

Jak więc możemy myśleć o tych wszystkich rzeczach, aby zrozumieć różnicę między wartością a sygnałem? Wyobraźmy sobie prostą wartość jako coś, co zapisaliśmy na kartce papieru i gdzieś zostawiliśmy. Zawsze możemy tam wrócić i sprawdzić tę wartość, ale to wszystko.

Jeśli chodzi o Sygnał, możemy myśleć o nim jako o wartości zapisanej na papierze i umieszczonej w specjalnym pudełku. Za każdym razem, gdy wartość Signal ulegnie zmianie, pudełko powiadomi nas o tym wydając głośny dźwięk, dzięki czemu: 1. wiemy, że wartość się zmieniła 2. natychmiast mamy nową wartość dzięki użyciu gettera

A co z wykorzystaniem Observables? Porównajmy ten sam fragment kodu, jeśli chcemy użyć tutaj RxJS:

let x$ = new BehaviorSubject<number>(10);
let y$ = new BehaviorSubject<number>(5);

let z$: Observable<number> = combineLatest([x$, y$])
    .pipe(
        map(([x, y]) => x + y)
    );

Nie tak dużo kodu, ale jeśli chcemy odczytać wartość z$, musimy ręcznie zasubskrybować ten Observable (a następnie zrezygnować z subskrypcji) lub użyć async pipe w szablonie. To dużo pracy i więcej rzeczy do zarządzania.

Jaki jest więc cel korzystania z Signals, skoro nadal możemy używać Observables? Chodzi o wykrywanie zmian. Jeśli konsument (kod lub szablon) odczyta sygnał, oznacza to, że jest zainteresowany jego obserwowaniem, więc został powiadomiony o zmianie sygnału (a widok jest zaplanowany do ponownego renderowania w razie potrzeby). Ale wykrywanie zmian dla komponentu zostanie zaplanowane wtedy i TYLKO wtedy, gdy Sygnał odczytany w szablonie powiadomi Angular, że się zmienił. Konsument nie otrzymuje nowej wartości natychmiast. Następnym razem, gdy nadejdzie jego kolej na wykonanie, konsument odczyta bieżącą wartość z Signal. Poprawia to wykrywanie zmian przez Angular i sprawia, że nasz kod jest bardziej reaktywny.

Jak zarządzać sygnałami?

Istnieją dwa rodzaje sygnałów: zapisywalne i obliczane (tylko do odczytu).

Sygnały zapisywalne

Korzystając z zapisywalnych sygnałów, możemy bezpośrednio modyfikować ich wartość. Aby utworzyć zapisywalny sygnał, musimy wywołać funkcję signal(), podając jej wartość początkową jako argument. Każdy sygnał musi zawsze mieć wartość.

const count = signal(0);

Sygnały zapisywalne mają typ WritableSignal. Typ wartości można wywnioskować, jeśli używamy typów prymitywnych. Możemy również dodać jawne typy podczas tworzenia sygnału i wartości. Rozszerzmy powyższą linię w następujący sposób:

const count: WritableSignal<number> = signal(0);

Sygnały mogą zawierać dowolne wartości, od prostych prymitywów po złożone struktury danych.

Z wyjątkiem wartości początkowej, możemy opcjonalnie zdefiniować sposób, w jaki chcemy uznać dwie wartości za równe. Domyślna funkcja używa standardowego operatora porównania ===, więc zdefiniowanie funkcji equal jest szczególnie przydatne w przypadku obiektów i tablic, które nigdy nie są równe, aby umożliwić nam przechowywanie wartości tych typów i powiadamianie użytkownika, gdy się zmienią.

function signal<T>(
  initialValue: T,
  options?: { equal?: (a: T, b: T) => boolean }
): WritableSignal<T>

Jeśli chcemy zmienić wartość, możemy użyć metody .set(), aby zmienić wartość bezpośrednio:

count.set(15);

lub użyć metody .update(), aby obliczyć nową wartość na podstawie poprzedniej:

count.update(value => value * 2);

Jeśli nasz Signal zawiera obiekt, możemy również zmutować wartość zamiast zastępować cały obiekt. Przykładowo:

export interface Car {
    name: string;
    price: number;
}

const selectedCar: WritableSignal<Car> = signal({
    name: 'Nice car',
    price: 100000
});

selectedCar.mutate(value => value.price = 80000);

Jest jeszcze jedna metoda, której możemy użyć na WritableSignal – .asReadonly(). Zwraca ona wersję Signal tylko do odczytu (typu Signal). Może być przydatna, gdy chcemy ujawnić tylko wartość i nie dać konsumentowi możliwości modyfikowania wartości Signal. Może być używana w taki sam sposób jak .asObservable() podczas pracy z RxJS.

Sygnały obliczone

Sygnał obliczany czerpie swoją wartość z innych sygnałów. Aby go zdefiniować, używamy funkcji computed():

const count = signal(0);
const doubleCount = computed(() => count() * 2);

Typ wartości można również wywnioskować tutaj lub możemy go silnie typować, obliczony Signal ma typ Signal:

const doubleCount: Signal<number> = computed(() => this.count() * 2);

Podobnie jak w przypadku zapisywalnego Signal, z wyjątkiem wartości początkowej, możemy podać funkcję equal jako opcję.

Sygnał doubleCount zależy od count. Kiedy aktualizujemy count, Angular wie, że doubleCount również musi zostać zaktualizowane.

Obliczone sygnały są tylko do odczytu, więc nie możemy bezpośrednio zmienić ich wartości:

doubleCount.set(2);

spowoduje błędy kompilacji.

Obliczone sygnały są leniwie obliczane i zapamiętywane. Pierwsze obliczenie wartości obliczonego sygnału jest uruchamiane przy pierwszym odczycie. Obliczona wartość jest następnie buforowana i każdy przyszły odczyt zwraca wartość buforowaną bez ponownego obliczania. Gdy wartość zapisywalnego Sygnału zmienia się, informuje on obliczany Sygnał, który od niego zależy, że jego buforowana wartość nie jest już ważna, ale wartość zostanie ponownie obliczona dopiero przy następnym odczycie obliczanego Sygnału.

Wykrywanie zmian

Jak wspomniałem w części wprowadzającej, Sygnały „będą działać z bardziej szczegółową reaktywnością” i mogą dać nam „dokładniejszą kontrolę nad wykrywaniem zmian”. Ale co to dokładnie oznacza? W obecnym (przed Signals) podejściu, podczas wykrywania zmian, całe drzewo komponentów przechodzi przez brudne sprawdzanie, ponieważ Angular nie wie, która część DOM zależy od której części modelu widoku. Możemy częściowo ograniczyć sprawdzanie drzewa DOM, używając strategii wykrywania zmian OnPush w niektórych częściach aplikacji. Korzystając z podejścia Signals, Angular wie dokładnie, co się zmieniło i tylko węzły DOM, które muszą zostać zaktualizowane, zostaną zaktualizowane.

Jak używać Signals w szablonie?

Jak wspomniano powyżej, aby odczytać wartość Signal, wystarczy użyć gettera:

const count = signal(0);
console.log('Count value: ', count());

W ten sam sposób możemy wyświetlić wartość Signal w szablonie:

<div>{{ count() }}</div>

Jak na razie dobrze, ale co z wywoływaniem funkcji w szablonie? Dobrą praktyką Angulara jest unikanie tej operacji. To prawda, ale tutaj możemy to zrobić bezpiecznie. Widok zostanie odświeżony tylko wtedy, gdy wartość Signal zostanie zmieniona, a nie przy każdym wykryciu zmiany, ponieważ getter Signal jest czystą funkcją – nie ma efektów ubocznych i zwraca tę samą wartość dla tego samego wejścia (a także używa memoizacji).

Efekty

Korzystanie z Sygnałów jest bardzo proste i „bezobsługowe”, ale czasami musimy zarządzać jakimś kodem, gdy jedna lub więcej wartości Sygnału ulegnie zmianie. W tym celu możemy utworzyć efekt za pomocą funkcji effect():

effect(() => {
    console.log('count value ', this.count());
});

Efekty zawsze uruchamiają się co najmniej raz. Powyższy console.log zostanie wyświetlony po utworzeniu efektu, a następnie przy każdej zmianie wartości count. Podczas korzystania z efektu z obliczoną wartością Signal, efekt używa tej samej memoizacji, więc log będzie widoczny tylko wtedy, gdy zostanie obliczona nowa wartość.

Efekt można utworzyć tylko w kontekście wstrzykiwania, więc najczęstszym i najbardziej praktycznym miejscem do tego jest wnętrze konstruktora komponentu / dyrektywy / usługi. Jeśli chcemy utworzyć efekt w innym miejscu, musimy podać kontekst wstrzykiwania, przypisując efekt do pola klasy:

private countChangeEffect = effect(() => {
    console.log('count value ', this.count());
});

lub jeśli wolimy zawinąć tworzenie efektu w metodzie klasy, musimy ręcznie podać injector:

constructor(private injector: Injector) {
}

initCountChangeEffect() {
    effect(() => {
        console.log('count value ', this.count());
    }, {injector: this.injector});
}

Ponieważ możemy zrobić wszystko, co chcemy wewnątrz funkcji zwrotnej efektu, musimy być bardzo ostrożni. Ogólnie rzecz biorąc, jeśli musimy użyć efektu, przemyślmy to dwa razy i zróbmy wszystko, aby go nie używać.

Co możemy zrobić wewnątrz funkcji zwrotnej efektu? Wiele nieładnych rzeczy, takich jak np. imperatywna zmiana stanu, ręczna modyfikacja DOM, a nawet uruchamianie kodu asynchronicznego (np. wywołania http). Chociaż jest to domyślnie wyłączone, możemy nawet zmienić wartość Signal po odblokowaniu takiej możliwości, po prostu dodając {allowSignalWrites: true} do opcji funkcji effect().

Kiedy używać efektów?

Zgodnie z oficjalną stroną Angular, istnieje kilka

sytuacje, w których effect może być dobrym rozwiązaniem:

  • Rejestrowanie wyświetlanych danych i ich zmian w celu analizy lub jako narzędzie do debugowania.
  • Utrzymywanie danych w synchronizacji z window.localStorage
  • Dodanie niestandardowego zachowania DOM, którego nie można wyrazić za pomocą składni szablonu
  • Wykonywanie niestandardowego renderowania do <canvas>, biblioteki wykresów lub innej biblioteki interfejsu użytkownika innej firmy.

Każdy efekt jest niszczony automatycznie po zniszczeniu otaczającego go kontekstu, więc nie musimy o tym myśleć. Tak czy inaczej, funkcja effect() zwraca EffectRef, więc możemy ręcznie zniszczyć efekt za pomocą metody .destroy(). Dodatkowo, możemy dodać {manualCleanup: true} do opcji funkcji effect(), ale uwaga! Dodając tę opcję, musimy zadbać o faktyczne zniszczenie efektu, ponieważ jeśli tego nie zrobimy, nie zostanie wyświetlony żaden błąd, a efekt będzie nadal żywy po zniszczeniu kontekstu. Rezultaty mogą być podobne do sytuacji, w której nie wypisaliśmy się z Observable.

Signals vs RxJS

Observables są powszechnie używane w aplikacjach Angular od wielu lat i nie możemy nagle się ich pozbyć. Co więcej, nie powinniśmy tego robić. Sygnały nie zastąpią observables (przynajmniej teraz), ale mogą przejąć niektóre ich zastosowania. Observables są świetnymi narzędziami do zarządzania asynchronicznym kodem i strumieniami danych, ale Signals jako koncepcja synchroniczna może być lepsza w niektórych przypadkach, takich jak zarządzanie stanem.

Wspólna praca

Aby utworzyć Signal z observable, używamy funkcji toSignal():

const count: Signal<number> = toSignal(count$);

Ta funkcja subskrybuje Observable przekazane jako argument i zmienia wartość zwracanego Signal za każdym razem, gdy pojawia się nowa wartość. Subskrypcja zostanie automatycznie anulowana, gdy tylko kontekst (tj. komponent), w którym została użyta, zostanie zniszczony.

Sygnał utworzony z Observable będzie przechowywany w stanie undefined, dopóki Observable nie wyemituje żadnej wartości. Aby uniknąć undefined (które może zatrzymać pracę naszej aplikacji) możemy podać initialValue jako opcję podczas tworzenia Signal:

const count: Signal<number> = toSignal(count$, {initialValue: 0});

Używając Observables możemy obsługiwać różne przypadki używając powiadomień nexterror i complete. Sygnały są zainteresowane tylko emitowanymi wartościami, więc używając funkcji toSignal() nie możemy obsłużyć błędów, gdy wystąpią one w Observable i pokażą błąd następnym razem, gdy spróbujemy odczytać wartość z Signal. Dlatego w tym przypadku musimy ręcznie obsługiwać błędy za pomocą bloków try/catch lub operatora catchError.

Aby utworzyć Observable z Signal, używamy funkcji toObservable():

const count$: Observable<number> = toObservable(count);

Warto zauważyć, że wszystkie nowe wartości Signal będą emitowane asynchronicznie do subskrybentów. Oznacza to, że jeśli synchronicznie zmienimy wartość Signal kilka razy, wyemitowana zostanie tylko ostatnia z nich:

const count$: Observable<number> = toObservable(count);
count$.subscribe(value => console.log(value));

count.set(0);
count.set(1);
count.set(2);

// output -> 2

Co dalej?

Angular v16 wprowadził podstawy API Signals. W najbliższej przyszłości (najprawdopodobniej w v17) otrzymamy więcej – komponenty oparte na sygnałach.

Krótko mówiąc: komponenty oparte na sygnałach nie używają zone.js i nie podlegają globalnemu wykrywaniu zmian. Są one renderowane indywidualnie zgodnie z podstawową zasadą:

Wykrywanie zmian nastąpi tylko wtedy, gdy Signal, którego wartość odczytaliśmy w szablonie, powiadomi Angular, że wartość została zmieniona.

Ostatecznie (i potencjalnie) możemy całkowicie pozbyć się zone.js w całej aplikacji, co znacznie zwiększy wydajność. Dlaczego potencjalnie? Ponieważ zanim to zrobimy, wszystkie nasze zależności nie mogą używać zone.js

Wnioski

Z naprawdę szerokiej perspektywy, wprowadzenie Signals wydaje się być dużym krokiem naprzód w kierunku zupełnie nowego podejścia do wykrywania zmian w Angularze – wyrzucenie zone.js wydaje się być bardzo kuszące, ale jest to pieśń przyszłości. Póki co, możemy zacząć korzystać z Signals, by uczynić nasz nowy kod bardziej wydajnym, a nasze obecne aplikacje bardziej nowoczesnymi i lekkimi.

Ten artykuł dotyka tylko podstaw koncepcji Signals, jest dużo więcej do odkrycia i dużo więcej do powiedzenia na ten temat. A zwłaszcza dużo kodu do napisania. Mam nadzieję, że moje krótkie wprowadzenie będzie dobrym punktem wyjścia.

Bibliografia

Poznaj mageek of j‑labs i daj się zadziwić, jak może wyglądać praca z j‑People!

Skontaktuj się z nami