Zespół Angulara pracuje nad nowym reaktywnym prymitywem o nazwie Signals. Integracja z frameworkiem idzie bardzo głęboko, ponieważ przynosi nową strategię wykrywania zmian, nowy cykl życia komponentów i wiele więcej.
Wydaje się, że społeczność Angulara mówi tylko o jednym – Signals. Nowo opublikowane RFC (Request for Comments) jest naprawdę ogromne i dlatego postanowiliśmy go trochę podsumować. Jeśli chcesz dogłębnie zrozumieć Signals i poznać wszystkie decyzje projektowe z nimi związane – oryginalne RFC wciąż pozostaje najlepszym miejscem żeby to zrobić. Jeśli jednak nie masz czasu przebijać się przez 5 rozległych dokumentów, w tym artykule znajdziesz wszystkie najważniejsze informacje, a jego przeczytanie nie zajmie Ci więcej niż 10 minut.
Angular Signals są już dostępne jako eksperymentalna funkcjonalność w Angular 16 RC (kilku funkcjonalności opisanych w tym artykule wciąż jednak brakuje). Nie wiemy kiedy Signalns staną się stabilną funkcjonalnością. Zalecałbym uzbrojenie się w cierpliwość, ponieważ w RFC jest jeszcze kilka gorących punktów dyskusyjnych.
Dlaczego potrzebujemy Angular Signals?
Zasadniczo istnieją dwa główne powody: Po pierwsze, zone.js
jest największą bolączką programistów korzystających na codzień z Angulara. Po drugie, RxJS nie jest najlepszą abstrakcją do zarządzania stanem. Teraz przyjrzymy się obu powodom w szczegółach.
zone.js
to biblioteka robiąca monkey-patching* większości asynchronicznych metod przeglądarki, w celu powiadomiania Angulara o możliwych zmianach w aplikacji. Kiedy dowolne asynchroniczne API przeglądarki zostaje wywołane Angular uruchamia proces Change Detection, aby wykryć, czy coś zmieniło się w aplikacji. Jak można sobie wyobrazić, to zachowanie prowadzi do wielu nadmiarowych uruchomień procesu Change Detection. W rezultacie mechanizm ten jest trudny zarówno do optymalizacji jak i debugowania. ChangeDetectionStrategy.OnPush
czyni sytuację nieco lepszą, ale główne problemy pozostają takie same. Aplikacje pozbawione zone.js
były niespełnionym marzeniem deweloperów Angulara od lat. Projekty takie jak RxAngular odzwierciedlają podejście społeczności do rozwiązania problemu.
RxJS sprawdza się świetnie, gdy potrzebujemy modelować złożone zdarzenia dziejące się na przestrzeni czasu, takie jak limitowanie zapytań do backendu czy agregowanie kliknięć myszką. Jeśli chodzi o zarządzanie stanem, RxJS nie sprawdza się już tak dobrze. Zacznijmy od tego, że strumień nie musi przechowywać wartości i jest to wpisane w architekturę strumieni. Nawet jeśli używamy BehaviurSubject
i shareReplay(1)
wszędzie gdzie to możliwe, nasz kod szybko zalany zostje nullami i niebezpiecznym rzutowaniem typów. Strumienie naturalnie grawitują w kierunku zimnych strumieni, co prowadzi do wielu zbędnych kalkulacji i zaskakujących efektów ubocznych. RxJS wprowadza również wiele narzutu dla początkujących – na przykład czym jest Subject
i BehaviourSubject
, czym są zimne i gorące strumienie, dlaczego istnieje wiele operatorów map i kiedy dokładnie wykonywany jest napisany kod.
Signalns mają na celu rozwiązanie większości problemów wymienionych w powyższych akapitach. Warto jednak pamiętać, że ani zone.js
, ani RxJS nigdzie się w najbliższym czasie nie wybierają. Fakt, że pojawi się alternatywny sposób na rozwiązaywanie problemów nie oznacza, że stary automatycznie staje się deprecated.
*moneky-patching – technika używana do dynamicznego aktualizowania zachowania kodu w czasie rzeczywistym. monkey-patching jest sposobem na rozszerzenie lub modyfikację kodu runtime języków dynamicznych bez zmiany oryginalnego kodu źródłowego.
Zainstaluj teraz i czytaj tylko dobre teksty!
Angular Signals API
Cytując oryginalne RFC: Sygnał jest opakowaniem wokół wartości, które jest w stanie powiadomić zainteresowanych konsumentów, gdy ta wartość się zmienia. Sygnały mogą być tworzone za pomocą factory method signal()
przyjmującą wartość początkową jako parametr. Sygnały mogą być komponowane za pomocą metody computed
(). Możliwe jest również wywoływanie efektów ubocznych, gdy wartość sygnału się zmienia, rejestrując odpowiedni callback w metodzie effect()
. W dowolnym momencie możemy odczytać wartość sygnału wywołując go jak funkcję.
const counterA = signal(2);
const counterB = signal(5);
const counterSum = computed(() => counterA() + counterB());
effect(() => console.log(`Counters sum is ${counterSum()}`);
Warto zauważyć, że zarówno computed
(), jak i effect()
nie wymagają podania tablicy zależności. Funkcje te automatycznie śledzą wywołania innych sygnałów, a następnie subskrybują się na zmiany. Możemy nawet umieścić w nich instrukcje warunkowe i wszystko będzie działać jak należy.
const counterA = signal(2);
const counterB = signal(5);
const flag = signal(true);
// As long as flag is set to true, changes to counterB will not trigger below computed
const counterSum = computed(() => flag() ? counterA() : counterB());
Sygnały mogą być aktualizowane za pomocą metod set
(), update
() i mutate
().
const user = signal({id: 1, name: 'Tomek'});
user.set({id: 2, name: 'Iza'});
user.update(user => ({...user, name: 'Izabella'});
user.mutate(user => user.name = 'Izabela');
Dwie ostatnie to funkcje pomocnicze, ponieważ można je przepisać za pomocą set()
w następujący sposób:
// user.update(user => ({...user, name: 'Izabella'});
const currentUser = user();
user.set({...currentUser, name: 'Izabella'});
//user.mutate(user => user.name = 'Izabela');
const currentUser = user();
currentUser.name = 'Izabela';
user.set(currentUser);
Kiedykolwiek wartość sygnału się zmieni, wszystkie zależne sygnału zostaną oznaczone jako brudne, a wszyskie potrzebne kalkulacje zostaną wykonane przy następnej próbie odczytu. Jeśli nikt nie jest zainteresowany odczytem sygnałów, nie ma sensu marnować czasu procesora. Używając skomplikowanego żargonu informatycznego – wszystkie sygnały są leniwie.
Jak sygnały wykrywają, czy wartość rzeczywiście się zmieniła? Dla wartości prymitywnych używany jest operator ===
. Dla wartości nieprymitywnych sprawdzanie równości jest pomijane i sygnał zawsze zakłada, że wartości się zmieniły. Dzięki takiemu zachowaniu możemy uniknąć kosztownej operacji porównywania lub kopiowania obiektów.
const counterA = signal(10);
effect(() => console.log(counterA()));
// After setting counter to 10 nothing will display in console
counterA.set(10);
// After setting counter to 15, it will display in console
counterA.set(15);
// Assume we have a large array full of complicated objects.
// There is no efficient way to compare 2 arrays like this.
const largeTableOfObjects = signal([/*...*/]);
// Although array reference have not changed
// all signal dependencies will be re-evaluated.
// With referencial equality below code wound not trigger any computations.
largeTableOfObjects.mutate(objects => objects.push({}));
Oczywiście w prawdziwym życiu będzie wiele sytuacji, w których porównanie dwóch obiektów, bez względu na to, jak skomplikowanych, będzie bardziej wydajne niż ponowne obliczenie wszystkich zależności sygnału. Aby rozwiązać ten problem, możemy przekazać funkcję equal
do metod signal
() i computed()
.
import { isEqual } from 'lodash'
// isEqual might be CPU intensive for large deeply nested objects.
// For our simple case it works just fine.
const user = signal(
{id: 1, name: 'Tomek', age: 27},
{equal: (a, b) => isEqual(a, b)}
);
// We define computed signal that performs some heavy computations
const userDerived = computed(() => heavyComputations(user()))
// Currently none of the below will trigger heavyComputations()
// With default equality behaviour all 3 will trigger heavyComputations()
user.set({id: 1, name: 'Tomek', age: 27});
user.mutate(value => value.age = 27);
user.update(value => ({...value, age: 27}));
Wcześniej założyliśmy, że sygnały możemy modyfikować za pomocą metod set
(), update()
i mutate()
. Nie jest to do końca prawda, ponieważ sygnały udostępniają dwa różne interfejsy: Signal
i WritableSignal
. Pierwszy z nich jest tylko do odczytu, a drugi zawiera wszystkie niezbędne metody mutacji. Nie ma wbudowanego sposobu na przekształcenie Signal
w WritableSignal
. Tak zaprojektowane API daje możliwość wyraźnego stwierdzenia, czy chcesz emitować wartości, czy chcesz, aby Twój klient to robił.
const writableSignal: WritableSignal<Int> = signal(0);
const readonlySignal: Signal<Int> = signal(0);
const sum: Signal<Int> = computed(
// You can read from both signals
() => readonlySignal() + writableSignal()
);
// This works perfectly fine.
writableSignal.set(10);
// TypeScript compiler will throw en error here.
readonlySignal.set(10);
// TypeScript compiler will throw en error here.
// Even casting to any can't help you.
sum.set(10);
Mówiąc o przejrzystym przepływie danych sygnały uniemożliwiają modyfikowanie innych sygnałów z poziomu metod computed
() i effect()
. Jeśl spróbujesz zmodyfikować sygnał z ich poziomu, to rzucony zostanie błąd. Po raz kolejny jest to bardzo dobry projekt API, ponieważ w takich przypadkach należy użyć metody computed
.
const countA = signal(0);
const countB = signal(0);
const sumError = signal(countA() + countB());
// This will thorw an error!
effect(() => sumError.set(countA() + countB()));
// This is the correct way to define sum of signals
const sumCorrect = computed(() => countA() + countB());
Integracja Angular Signals z Angularem
Aby zacząć używać sygnałów w swoich komponentach, wszystko, co musisz zrobić, to dodać signals: true
do dekoratora @Component
. Ta flaga modyfikuje strategię wykrywania zmian dla komponentu i od tej pory będzie on renderowany tylko wtedy, gdy zmieni się jeden z sygnałów używanych w komponencie.
@Component({
signals: true,
selector: 'simple-counter',
template: `
<p>Count: {{ count() }}</p>
<p>Double Count: {{ doubleCount() }}</p>
<button (click)="increment()">Increment count</button>`,
})
export class SimpleCounter {
count = signal(0);
doubleCount = computed(() => 2 * count());
increment() {
this.count.update(c => c + 1);
}
}
Jeśli komponent jest oparty na sygnałach, to nie będzie on wykorzystywał do wykrywania zmian zone.js
! Przynajmniej dopóki nie zaczniemy używać starych komponentów w jego obrębie. W takich przypadkach Angular automatycznie podzieli aplikację na strefy i będzie do nich aplikował odpowiednią strategię wykrywania zmian. Nasz komponent nadal będzie renderowany tylko wtedy, gdy zmieni się jeden z wewnętrznych sygnałów, podczas gdy komponent dziecko będzie używał starego dobrego wykrywania zmian opartego na zone.js
.
@Component({
signals: true,
selector: 'simple-counter',
template: `
<p>Count {{ count() }}</p>
<zone-component />
<button (click)="increment()">Increment count</button>`,
})
export class SimpleCounter {
count = signal(0);
increment() {
this.count.update(c => c + 1);
}
}
Jednym z ważnych aspektów nowej strategii wykrywania zmian jest fakt, że nie można już imperatywnie modyfikować właściwości komponentów i oczekiwać ponownego renderowania interfejsu. Nadal możemy używać właściwości klas w swoim szablonie, ale będą one odświeżane tylko wtedy, gdy zmieni się jeden z sygnałów.
@Component({
signals: true,
selector: 'simple-counter',
template: `
<p>Signal Count {{ count() }}</p>
<p>Imperative Count {{ count() }}</p>
<!-- Clicking this button won't have any effect in the UI -->
<button (click)="incrementImperative()">Increment Imperative Count</button>
<!-- Clicking this button will update both counts in the UI -->
<button (click)="incrementSignal()">Increment Signal Count</button>`
})
export class SimpleCounter {
count = signal(0);
countImperative = 0;
incrementImperative(): void {
countImperative = countImperative + 1;
}
incrementSignal(): void {
this.count.update(c => c + 1);
}
}
W komponentach opartych o sygnały zmienił się również sposób definiowania zmiennych wejściowych i wyjściowych. Zamiast dekorowania @Inptut()
i @Output()
użyć należy funkcji input()
i output()
. Warto zwróć uwagę na typ zwracany przez funkcję input()
. Wyraźny podział na sygnały tylko do odczytu i do zapisu zmusza nas do zaprojektowania przejrzystego, jednokierunkowego przepływu danych.
@Component({
signals: true,
selector: 'simple-counter',
template: `
<p>Count {{ count() }}</p>
<button (click)="onClick()">Increment Count</button>`
})
export class SimpleCounter {
count: Signal<number> = input<number>(0);
buttonClick: EventEmitter<number> = output<void>();
onClick(): void {
this.buttonClick.emit();
}
}
Ostatnią ważną różnicą pomiędzy zwykłymi komponentami, a komponentami opartymi na sygnałac jest nowy cykl życia komponentu. Ponieważ wykrywanie zmian działa zupełnie inaczej, stare metody cyklu życia nie mają zbytnio sensu. W nowym podejściu zamiast implementować interfejsy, rejestrujemy callbacki za pomocą magicznych funkcji.
@Component({
signals: true,
selector: 'user-profile',
template: `
<h2>Hello {{name()}}!</h2>`,
})
export class LifecycleComponent {
name = signal('Tomek');
constructor() {
afterInit(() => {
// All inputs have their initial values.
});
afterRender(() => {
// After the DOM of *all* components has been fully rendered.
});
afterNextRender(() => {
// Same as afterRender, but only runs once.
});
afterRenderEffect(() => {
// Same as afterRender in terms of timing,
// but runs whenever the signals which it reads have changed.
console.log(`DOM was updated due to '${this.name()}'`);
});
beforeDestroy(() => {
// This component instance is about to be destroyed.
});
}
}
Podejście magicznych funkcji daje nam kilka niesamowitych możliwości, takich jak rejestrowanie callbacków w odpowiedzi na kliknięciu przycisku.
@Component({
signals: true,
selector: 'simple-counter',
template: `
<p>Count {{ count() }}</p>
<button (click)="increment()">Increment count</button>
<button (click)="addCallbacks()">Add callbacks</button>`,
})
export class SimpleCounter {
count = signal(0); // WritableSignal<number>
increment() {
this.count.update(c => c + 1);
}
addCallbacks() {
afterRenderEffect(() => {
// I need to get notified the next time name changes!
console.log(`Count changed: '${this.count()}'`);
});
beforeDestroy(() => {
// Because I have initialized some stuff in my function
// Now I have to do some additional cleanup
unsubscribe();
});
}
private unsubscribe() {
/*...*/
}
}
Zainstaluj teraz i czytaj tylko dobre teksty!
Angular Signals i współpraca z RxJS
Ponieważ Signals i RxJS będą długo współistnieć w wielu aplikacjach, bardzo ważne jest posiadanie dobrych narzędzi do przełączania się pomiędzy tymi abstrakcjami. Takie narzędzia będą również mocno wykorzystywane przez sam zespół Angulara, ponieważ zespół planują oni dostarczać większość API zarówno dla Signals jak i RxJS API.
fromObservable()
to funkcja konwerująca Observable
na Signal
. Drugim argumentem przekazywanym do funkcji jest wartość przechowywana wewnątrz sygnału do momentu wyemitowania wartości przez strumień. Jeśli nie podamy wartości domyślnej i będziemy próbowali uzyskać dostęp do sygnału przed emisją pierwszego wydarzenia, zostanie wyrzucony błąd. Warto również zauważyć, że fromObservable()
natychmiast zasubskrybuje się na wartość, aby uniknąć efektów ubocznych wywołanych przez odczytanie sygnału.
const mySignal = fromObservable(myObservable$);
fromSignal
konwertuje strumień na sygnał. Konwersja w tą stronę jest znacznie prostsza, ponieważ strumienie są znacznie bardziej elastyczną abstrakcją.
const myObsrevable$ = fromObservable(mySignal);