Strona Główna /Sygnały w Angular: głęboka analiza dla zapracowanych deweloperów
06 sie 2024
20 min

Sygnały w Angular: głęboka analiza dla zapracowanych deweloperów

Tworzenie złożonych interfejsów użytkownika jest trudnym zadaniem. W nowoczesnych aplikacjach internetowych stan interfejsu użytkownika rzadko składa się z prostych, samodzielnych wartości. Jest to raczej skomplikowany stan, który zależy od złożonej hierarchii innych wartości lub obliczanych stanów. Zarządzanie tym stanem wymaga wiele pracy: programiści muszą przechowywać, obliczać, inwalidować i synchronizować te wartości.

Z biegiem lat wprowadzono wiele frameworków i prymitywów do tworzenia stron internetowych, aby uprościć to zadanie. Głównym tematem większości z nich jest programowanie reaktywne, które oferuje infrastrukturę do zarządzania stanem aplikacji, pozwalając programistom skoncentrować się na logice biznesowej, a nie na powtarzalnych zadaniach związanych z zarządzaniem stanem.

Najnowszym dodatkiem są sygnały, “reaktywne” prymitywy, które reprezentują dynamicznie zmieniającą się wartość i mogą powiadamiać zainteresowanych konsumentów o zmianie wartości. Te z kolei mogą uruchamiać ponowne obliczenia lub różne efekty uboczne, np. tworzenie/niszczenie komponentów, uruchamianie żądań sieciowych, aktualizowanie DOM itp.

Możemy znaleźć różne implementacje sygnałów w różnych frameworkach. Obecnie podejmowane są nawet próby standaryzacji sygnałów:

… ten aspekt koncentruje się na dostosowaniu ekosystemu JavaScript. Kilku autorów frameworków współpracuje tutaj nad wspólnym modelem, który mógłby wspierać ich rdzeń reaktywności. Obecny szkic opiera się na wkładzie projektowym autorów/opiekunów Angular, Bubble, Ember, FAST, MobX, Preact, Qwik, RxJS, Solid, Starbeam, Svelte, Vue, Wiz i innych…

Implementacja sygnałów w Angularze bardzo przypomina implementację dostarczoną jako część wyżej wymienionych propozycji i mogę opowiedzieć o tym między nimi w tym artykule.

Sygnały vs prymitywy

Sygnał reprezentuje komórkę danych, która może zmieniać się w czasie. Sygnały mogą być “state” (wartość ustawiana ręcznie) lub “computed” (formuła oparta na innych sygnałach).

Sygnały ‘computed’ działają poprzez automatyczne śledzenie, w którym  inne sygnały są odczytywane podczas ich ewaluacji. Kiedy computed sygnał jest odczytywany, sprawdza, czy którakolwiek z jego wcześniej zarejestrowanych zależności uległa zmianie i jeśli tak – propaguje swoją wartość.

W przykładzie poniżej mamy licznik stanu sygnału i computed sygnał isEven. Ustawiamy wartość początkową licznika na 0, a następnie zmieniamy ją na 1. Można zauważyć, że obliczony sygnał isEven reaguje na zmiany, generując dwie różne wartości przed i po aktualizacji sygnału licznika:

import { computed, signal } from '@angular/core';

// state/writable signal
const counter = signal(0);

// computed signal
const isEven = computed(() => (counter() & 1) == 0);

counter() // 0
isEven() // true

counter.set(1)

counter() // 1
isEven() // false

Zauważ również, że w powyższym przykładzie sygnał isEven nie subskrybuje się do licznika źródłowego tego sygnału. Zamiast tego po prostu wywołuje sygnał źródłowy za pomocą funkcji counter() w funkcji computed(). Jest to wystarczające do połączenia tych dwóch sygnałów. W rezultacie za każdym razem, gdy źródło sygnału licznika zostanie zaktualizowane o nową wartość, sygnał pochodny również zostanie automatycznie zaktualizowany.

Oba state i computed sygnały można uznać za nadawców wartości. Nadawcy reprezentują sygnały, które produkują wartości i dostarczają informację o zmianie.

State signal zmienia (produkuje) swoją wartość kiedy ta została zaktualizowana przez API, w czasie gdy computed signal generuje nową wartość automatycznie w momencie zmiany wartości użytych w callbacku zależności.

Computed sygnały mogą być również odbiorcami, ponieważ mogą zależeć od nieokreślonej liczby nadawców. W innych reaktywnych implementacjach, np. Rx, odbiorcy są także znani jako sinks.

Gdy wartość sygnału producenta zostanie zmieniona, wartości zależnych konsumentów, np. computed sygnałów, nie są natychmiast aktualizowane. Gdy computed sygnał jest odczytywany, sprawdza on, czy którakolwiek z jego wcześniej zarejestrowanych zależności uległa zmianie i w razie potrzeby sprawdza i aktualizuje swoją wartość.

Dzięki temu, computed sygnały są lazy lub pull-based. Oznacza to że są przeliczane w momencie wywołania, nawet jeżeli stan zmienia się wcześniej. W przykładzie wyżej wartość computed sygnału jest przeliczana w momencie wywołania `isEven()`, mimo że aktualizacja używanej wewnątrz zależności `counter` dokonała się wcześniej, w momencie wywołania `counter.set()`.

Oprócz reguralnych writable i computed sygnałów, istnieje również koncepcja obserwatorów (efektów). W przeciwieństwie do pull-based ewaluacji sygnałów computed, zmiana sygnału producenta natychmiast powiadomi obserwatora, synchronicznie wywołując wywołanie zwrotne powiadomienia obserwatora, skutecznie “wypychając” powiadomienie. Frameworki wrapują obserwatorów w efekty, które są dostępne dla użytkowników. Efekty opóźniają powiadomienie kodu użytkownika poprzez planowanie.

W przeciwieństwie do Promises, wszystko w sygnałach działa synchronicznie

  • Ustawienie sygnału na nową wartość jest synchroniczne i jest to natychmiast odzwierciedlane podczas odczytywania dowolnego obliczonego sygnału, który zależy od niego później. Nie ma wbudowanego grupowania tej mutacji.
  • Odczyt obliczonych sygnałów jest synchroniczny – ich wartość jest zawsze dostępna.
  • Obserwatorzy są powiadamiani synchronicznie, ale efekty wrapujące tych obserwatorów mogą zdecydować się na grupowanie i opóźnianie powiadomień poprzez planowanie.

Szczegóły implementacji

Implementacja sygnałów definiuje szereg pojęć, które chcę wyjaśnić w tym artykule: kontekst reaktywny, wykres zależności i efekty (obserwatorzy). Zacznijmy od kontekstu reaktywnego.

Aby omówić kontekst reaktywny, pomyśl o stack frame (execution frame), która definiuje środowisko, w którym kod JavaScript jest ewaluowany i wykonywany. W szczególności definiuje ona, które obiekty (zmienne) są dostępne dla funkcji. Można powiedzieć, że dostępność tych obiektów definiuje kontekst. Na przykład funkcja uruchamiana w kontekście web worker nie ma dostępu do globalnego obiektu document.

Kontekst reaktywny definiuje aktywny obiekt konsumenta, który zależy od producentów i jest dostępny dla ich funkcji dostępu za każdym razem, gdy odczytywana jest ich wartość. Na przykład, mamy tutaj konsumenta isEvent, który zależy od producenta counter (konsumuje jego wartość). Zależność ta jest definiowana poprzez dostęp do wartości licznika wewnątrz obliczonego wywołania zwrotnego:

isEvent = computed(() => (counter() & 1) === 0)

Gdy computed callback zostanie uruchomiony, automatycznie wykona funkcję dostępu do sygnału licznika, aby uzyskać jego wartość. Można powiedzieć, że w tym przypadku sygnał licznika jest wykonywany w kontekście reaktywnym konsumenta isEvent. Tak więc producent jest wykonywany w kontekście reaktywnym, jeśli istnieje aktywny konsument, który zależy od wartości tego producenta.

Aby zaimplementować ten mechanizm kontekstu reaktywnego, za każdym razem, gdy uzyskuje się dostęp do wartości konsumenta, ale przed jej ponownym obliczeniem (przed uruchomieniem obliczonego wywołania zwrotnego), możemy ustawić tego konsumenta jako aktywnego konsumenta. Można to zrobić, po prostu przypisując obiekt konsumenta do zmiennej globalnej i utrzymując go tam podczas wykonywania callback. Ta globalna zmienna będzie dostępna dla wszystkich producentów ustawionych w kolejce podczas wykonywania obliczonego callbacku i zdefiniuje kontekst reaktywny dla wszystkich producentów, od których zależy ten konsument.

Dokładnie to robi Angular. Gdy obliczony callback zostanie wykonany, najpierw ustawi bieżący node jako aktywnego konsumenta w producerRecomputeValue:

function producerRecomputeValue(node: ComputedNode<unknown>): void {
 ...
 const prevConsumer = consumerBeforeComputation(node);
 let newValue: unknown;
 try {
   newValue = node.computation();
 } catch (err) {...} finally {...}

function consumerBeforeComputation(node: ReactiveNode | null) {
 node && (node.nextProducerIndex = 0);
 return setActiveConsumer(node);
}

Angular pobiera ją z producerUpdateValueVersion wewnątrz funkcji fabrycznej createComputed:

function createComputed<T>(computation: () => T): ComputedGetter<T> {
 ...
 const computed = () => {
   producerUpdateValueVersion(node);
   ...
 };
}

function producerUpdateValueVersion(node: ReactiveNode): void {
 ...
 node.producerRecomputeValue(node);
 ...
}

Ten callstack również wyraźnie pokazuje tę implementację:

Z tego powodu, podczas gdy wywoływany jest callback, każdy producent, który jest odpytywany w czasie aktywności tego konsumenta, będzie wiedział, że jest wykonywany w kontekście reaktywnym. Wszyscy producenci wykonywani w kontekście reaktywnym danego konsumenta są dodawani jako jego zależności. Tworzy to graf reaktywny.

Większość wcześniej istniejących funkcji w Angular jest wykonywana w kontekście niereaktywnym. Można to zaobserwować po prostu wyszukując użycie setActiveConsumer z wartością null:

Na przykład, przed uruchomieniem lifecycle hooks, Angular czyści kontekst reaktywny:

/**
* Executes a single lifecycle hook, making sure that:
* - it is called in the non-reactive context;
* - profiling data are registered.
*/
function callHookInternal(directive: any, hook: () => void) {
 profiler(ProfilerEvent.LifecycleHookStart, directive, hook);
 const prevConsumer = setActiveConsumer(null);
 try {
   hook.call(directive);
 } finally {
   setActiveConsumer(prevConsumer);
   profiler(ProfilerEvent.LifecycleHookEnd, directive, hook);
 }
}

Funkcje templatek Angulara (widoki komponentów) i efekty są uruchamiane w kontekstach reaktywnych.

Grafy reaktywne

Graf reaktywny jest budowany poprzez zależności między konsumentami i producentami. Implementacja kontekstu reaktywnego za pomocą accessors value umożliwia automatyczne i niejawne śledzenie zależności sygnałów. Użytkownicy nie muszą deklarować tablic zależności, ani też zestaw zależności danego kontekstu nie musi pozostawać statyczny w różnych wykonaniach.

Gdy producent jest wykonywany, dodaje się do zależności bieżącego aktywnego konsumenta (konsumenta definiującego bieżący kontekst reaktywny). Dzieje się to wewnątrz funkcji producerAccessed:

export function producerAccessed(node: ReactiveNode): void {
 ...
 // This producer is the `idx`th dependency of `activeConsumer`.
   const idx = activeConsumer.nextProducerIndex++;
   if (activeConsumer.producerNode[idx] !== node) {
     // We're a new dependency of the consumer (at `idx`).
     activeConsumer.producerNode[idx] = node;
     // If the active consumer is live, then add it as a live consumer. If not, then use 0 as a
     // placeholder value.
     activeConsumer.producerIndexOfThis[idx] = consumerIsLive(activeConsumer)
       ? producerAddLiveConsumer(node, activeConsumer, idx)
       : 0;
   }

Zarówno producenci, jak i konsumenci uczestniczą w grafie reaktywnym. Ten graf zależności jest dwukierunkowy, ale istnieją różnice w tym, które zależności są śledzone w każdym kierunku.

Producenci są śledzeni jako zależności konsumenta poprzez właściwość producerNode, tworząc krawędzie od konsumentów do producentów:

interface ConsumerNode extends ReactiveNode {
 producerNode: NonNullable<ReactiveNode['producerNode']>;
 producerIndexOfThis: NonNullable<ReactiveNode['producerIndexOfThis']>;
 producerLastReadVersion: NonNullable<ReactiveNode['producerLastReadVersion']>;

Niektórzy konsumenci są również śledzeni jako konsumenci “na żywo” i tworzą krawędzie w przeciwnym kierunku, od producenta do konsumenta. Te krawędzie są używane do propagowania powiadomień o zmianach, gdy wartość producenta jest aktualizowana:

interface ProducerNode extends ReactiveNode {
 liveConsumerNode: NonNullable<ReactiveNode['liveConsumerNode']>;
 liveConsumerIndexOfThis: NonNullable<ReactiveNode['liveConsumerIndexOfThis']>;
}

Konsumenci zawsze śledzą producentów, od których są zależni. Producenci śledzą tylko zależności od konsumentów, którzy są uważani za “aktywnych”. Konsument jest “aktywny”, gdy ma właściwość consumerIsAlwaysLive ustawioną na true lub jest producentem, od którego zależy aktywny konsument.

W Angular dwa typy węzłów są zdefiniowane jako konsumenci live:

  • watch nodes (używane w efektach)
  • reaktywne LView nodes (używane w wykrywaniu zmian)

Oto ich definicje:

const WATCH_NODE: Partial<WatchNode> = /* @__PURE__ */ (() => {
 return {
   ...REACTIVE_NODE,
   consumerIsAlwaysLive: true,
   consumerAllowSignalWrites: false,
   consumerMarkedDirty: (node: WatchNode) => {
     if (node.schedule !== null) {
       node.schedule(node.ref);
     }
   },
   hasRun: false,
   cleanupFn: NOOP_CLEANUP_FN,
 };
})();

const REACTIVE_LVIEW_CONSUMER_NODE: Omit<ReactiveLViewConsumer, 'lView'> = {
 ...REACTIVE_NODE,
 consumerIsAlwaysLive: true,
 consumerMarkedDirty: (node: ReactiveLViewConsumer) => {
   markAncestorsForTraversal(node.lView!);
 },
 consumerOnSignalRead(this: ReactiveLViewConsumer): void {
   this.lView![REACTIVE_TEMPLATE_CONSUMER] = this;
 },
};

W niektórych kontekstach computed sygnały mogą stać się “żywymi” konsumentami, na przykład, gdy są używane w wywołaniu zwrotnym efektu.

Poniższa konfiguracja kodu:

import { ChangeDetectorRef, Component, computed, effect, signal } from '@angular/core';
import { SIGNAL } from '@angular/core/primitives/signals';

@Component({
 standalone: true,
 selector: 'app-root',
 template: 'Angular Love',
 styles: []
})
export class AppComponent {
 constructor(private cdRef: ChangeDetectorRef) {
   const a = signal(0);

   const b = computed(() => a() + 'b');
   const c = computed(() => a() + 'c');
   const d = computed(() => b() + c() + 'd');

   const nodes = [a[SIGNAL], b[SIGNAL], c[SIGNAL], d[SIGNAL]] as any[];

   d();

   const A = 0, B = 1, C = 2, D = 3;

   const depBToA = nodes[B].producerNode[0] === nodes[A];
   const depCToA = nodes[C].producerNode[0] === nodes[A];
   const depDToB = nodes[D].producerNode[0] === nodes[B];
   const depDToC = nodes[D].producerNode[1] === nodes[C];

   console.log(depBToA, depCToA, depDToB, depDToC);

   const e = effect(() => b()) as any;

   // need to wait for change detection to notify the effect
   setTimeout(() => {
     // effect depends on B
     const depEToB = e.watcher[SIGNAL].producerNode[0] === nodes[B];

     // live consumers link from producer A to B,
     // and from B to E, because E (effect) is a live consumer
     const depLiveAToB = nodes[A].liveConsumerNode[0] === nodes[B];
     const depLiveBToE = nodes[B].liveConsumerNode[0] === e.watcher[SIGNAL];

     console.log(depLiveAToB, depLiveBToE, depEToB);
   });
 }
}

wygeneruje następujący wykres:

Implementacja kontekstu reaktywnego poprzez aktywnego konsumenta umożliwia dynamiczne śledzenie zależności. Gdy dany konsument jest ustawiony jako aktywny, oceniani producenci są definiowani dynamicznie poprzez sekwencję wywołań tych producentów. Lista zależności może być zmieniana dla ActiveConsumer za każdym razem, gdy producent jest dostępny w kontekście reaktywnym tego konsumenta.

Aby to zaimplementować, zależności konsumenta są śledzone w tablicy producerNode:

interface ConsumerNode extends ReactiveNode {
 producerNode: NonNullable<ReactiveNode['producerNode']>;
 producerIndexOfThis: NonNullable<ReactiveNode['producerIndexOfThis']>;
 producerLastReadVersion: NonNullable<ReactiveNode['producerLastReadVersion']>;

Gdy obliczenia dla konkretnego konsumenta są ponownie uruchamiane, wskaźnik (indeks) producerIndexOfThis do tej tablicy jest inicjowany do indeksu 0, a każda odczytana zależność jest porównywana z zależnością z poprzedniego przebiegu w bieżącej lokalizacji wskaźnika. Jeśli wystąpi niezgodność, oznacza to, że zależności zmieniły się od ostatniego uruchomienia, a stara zależność może zostać porzucona i zastąpiona nową. Na koniec uruchomienia, wszelkie pozostałe niedopasowane zależności mogą zostać porzucone.

Oznacza to, że jeśli zależność jest potrzebna tylko w jednej gałęzi, a poprzednie obliczenia uwzględniały drugą gałąź, to zmiana tej tymczasowo nieużywanej wartości nie spowoduje ponownego obliczenia obliczonego sygnału, nawet po jego wyciągnięciu. Powoduje to możliwość dostępu do różnych zestawów sygnałów w zależności od wykonania.

Na przykład ten obliczony sygnał dynamiczny odczytuje daneA lub daneB w zależności od wartości sygnału useA:

const dynamic = computed(() => useA() ? dataA() : dataB());

W dowolnym momencie będzie miał zestaw zależności [useA, dataA] lub [useA, dataB] i nigdy nie może zależeć od dataA i dataB w tym samym czasie.

Ten kod, podobny do tego przypadku testowego w Angular, wyraźnie to pokazuje:

import { computed, signal } from '@angular/core';
import { SIGNAL} from '@angular/core/primitives/signals';

const states = Array.from('abcdefgh').map((s) => signal(s));
const sources = signal(states);

const vComputed = computed(() => {
 let str = '';
 for (const state of sources()) str += state();
 return str;
});

const n = vComputed[SIGNAL] as any;
expectEqual(vComputed(), 'abcdefgh');
expectEqualArrayElements(n.producerNode.slice(1), states.map(s => s[SIGNAL]));

sources.set(states.slice(0, 5));
expectEqual(vComputed(), 'abcde');
expectEqualArrayElements(n.producerNode.slice(1), states.slice(0, 5).map(s => s[SIGNAL]));

sources.set(states.slice(3));
expectEqual(vComputed(), 'defgh');
expectEqualArrayElements(n.producerNode.slice(1), states.slice(3).map(s => s[SIGNAL]));

function expectEqual(v1, v2): any {
 if (v1 !== v2) throw new Error(`Expected ${v1} to equal ${v2}`);
}
function expectEqualArrayElements(v1, v2): any {
 if (v1.length !== v2.length) throw new Error(`Expected ${v1} to equal ${v2}`);
 for (let i = 0; i < v1.length; i++) {
   if (v1[i] !== v2[i]) throw new Error(`Expected ${v1} to equal ${v2}`);
 }
}

Jak widać, nie ma jednego wierzchołka początkowego dla grafu. Ponieważ każdy konsument przechowuje listę producentów zależności, którzy z kolei mogą mieć zależności, np. computed sygnał, więc można powiedzieć, że każdy konsument w momencie uzyskania dostępu jest wierzchołkiem głównym grafu.

Dwufazowe aktualizacje

Wcześniejsze push-based modele reaktywności napotykały problem nadmiarowych obliczeń: jeśli aktualizacja sygnału stanu powoduje niecierpliwe uruchomienie obliczonego sygnału, ostatecznie może to spowodować wypchnięcie aktualizacji do interfejsu użytkownika. Ale ten zapis do interfejsu użytkownika może być przedwczesny, jeśli przed następną klatką nastąpi kolejna zmiana w pochodzącym sygnale stanu.

Na przykład dla wykresu takiego jak ten, problem ten obejmuje nieumyślną ocenę A -> B -> D i C, a następnie ponowną ocenę D, ponieważ C się zmieniło. Ponowna ocena D dwa razy jest nieefektywna i może prowadzić do zauważalnych usterek dla użytkownika.

Jest to znane jako problem diamentu.

Czasami z powodu takich usterek użytkownikom końcowym wyświetlane były nawet niedokładne wartości pośrednie. Sygnały unikają tej dynamiki, ponieważ są lazy, a nie push-based: W momencie, gdy framework zaplanuje renderowanie interfejsu użytkownika, pobierze odpowiednie aktualizacje, unikając marnowania pracy zarówno w obliczeniach, jak i w zapisie do DOM.

Rozważmy ten przykład:

const a = signal(0);

const b = computed(() => a() + 'b');
const c = computed(() => a() + 'c');
const d = computed(() => b() + c() + 'd');

// run the computed callback to set up dependencies
d();

// update the signal at the top of the graph
setTimeout(() => a.set(1), 2000);

Po aktualizacji ‘a’ nie odbywa się żadna propagacja. Aktualizowane są tylko wartość i wersja węzła:

function signalSetFn(node, newValue) {
 ...
 if (!node.equal(node.value, newValue)) {
   node.value = newValue;
   signalValueChanged(node);
 }
}

function signalValueChanged(node) {
 node.version++;
 ...
}

Kiedy później uzyskujemy dostęp do wartości dla d(), implementacja sygnałów sonduje zależności w górę od ‘d’ poprzez consumerPollProducersForChange, aby określić, czy konieczne jest ponowne obliczenie.

W celu wydajnego przetwarzania wszystkie węzły reaktywne rejestrują wersję węzła zależności. Aby określić zmianę, wystarczy po prostu porównać zapisaną wersję węzła producenta z rzeczywistą wersją w węzła:

interface ConsumerNode extends ReactiveNode {
 ...
 producerLastReadVersion: NonNullable<ReactiveNode['producerLastReadVersion']>;
}

function consumerPollProducersForChange(node) {
 ...
 // Poll producers for change.
 for (let i = 0; i < node.producerNode.length; i++) {
   const producer = node.producerNode[i];
   const seenVersion = node.producerLastReadVersion[i];
   // First check the versions. A mismatch means that the producer's value is known to have
   // changed since the last time we read it.
   if (seenVersion !== producer.version) {
     return true;
   }

Jeśli się różnią, nastąpiła zmiana w producencie, a implementacja uruchomi ponowne obliczenie obliczonego callbacku za pośrednictwem producerRecomputeValue:

export function producerUpdateValueVersion(node: ReactiveNode): void {
 ...

 if (!node.producerMustRecompute(node) && !consumerPollProducersForChange(node)) {
   // None of our producers report a change since the last time they were read, so no
   // recomputation of our value is necessary, and we can consider ourselves clean.
   node.dirty = false;
   node.lastCleanEpoch = epoch;
   return;
 }

 node.producerRecomputeValue(node);

 // After recomputing the value, we're no longer dirty.
 node.dirty = false;
 node.lastCleanEpoch = epoch;
}

W ten sposób dotrze do węzła A, co w tym momencie spowoduje obliczenie gałęzi D->C->A. Ale ponieważ D zależy również od producenta B, zostanie on ponownie oceniony przed obliczeniem D. W ten sposób nie ma problemu podwójnego obliczenia dla D.

Czasami jednak może zaistnieć potrzeba niecierpliwego powiadamiania niektórych konsumentów. Jak można się domyślić, są to tak zwani “żywi” konsumenci. W takim przypadku powiadomienie o zmianie jest propagowane przez graf, gdy tylko wartość producenta zostanie zaktualizowana, powiadamiając żywych konsumentów, którzy zależą od producenta.

Niektórzy z tych konsumentów mogą być wartościami pochodnymi, a zatem także producentami, którzy unieważniają swoje buforowane wartości, a następnie kontynuują propagację powiadomienia o zmianie do własnych żywych konsumentów i tak dalej. Ostatecznie powiadomienie to dociera do efektów, które same planują ponowne wykonanie.

Co najważniejsze, podczas tej fazy nie są uruchamiane żadne efekty uboczne i nie jest wykonywane ponowne obliczanie wartości pośrednich lub pochodnych, a jedynie unieważnianie wartości buforowanych. Dzięki temu powiadomienie o zmianie może dotrzeć do wszystkich dotkniętych węzłów w grafie bez możliwości obserwowania stanów pośrednich lub usterek.

W razie potrzeby, po zakończeniu propagacji zmian (synchronicznie), po tym etapie może nastąpić lazy ewaluacja, o której mówiliśmy powyżej.

Aby zobaczyć tę fazę powiadomień w akcji, dodajmy do naszej konfiguracji żywego konsumenta, np. obserwatora. Gdy a jest aktualizowane, aktualizacja jest propagowana do zależnych żywych konsumentów:

import { computed, signal } from '@angular/core';
import { createWatch } from '@angular/core/primitives/signals';

const a = signal(0);
const b = computed(() => a() + 'b');
const c = computed(() => a() + 'c');
const d = computed(() => b() + c() + 'd');

setTimeout(() => a.set(1), 3000);

// watcher will setup a dependency on `d`
const watcher = createWatch(
 () => console.log(d()),
 () => setTimeout(watcher.run, 1000),
 false
);

watcher.notify();

Gdy tylko zaktualizujemy wartość dla a.set(1), możemy zobaczyć powiadomienie o żywych konsumentach w akcji:

Węzły b i c są żywymi konsumentami węzła a, dlatego podczas uruchamiania aktualizacji dla a, Angular przejdzie do node.liveConsumerNode i powiadomi te węzły o zmianie.

Ale jak wspomniano wcześniej, tak naprawdę nic się tutaj nie dzieje. Węzeł jest po prostu oznaczony jako dirty i propaguje powiadomienie do swoich żywych konsumentów za pośrednictwem producerNotifyConsumers:

function consumerMarkDirty(node) {
 node.dirty = true;
 producerNotifyConsumers(node);
 node.consumerMarkedDirty?.(node);
}

Wszystko to sprowadza się do watchera (efektu), który zależy od d. W przeciwieństwie do zwykłych węzłów reaktywnych, węzeł watch implementuje planowanie w swojej metodzie consumerMarkedDirty:

const WATCH_NODE: Partial<WatchNode> = (() => {
 return {
   ...REACTIVE_NODE,
   consumerIsAlwaysLive: true,
   consumerAllowSignalWrites: false,
   consumerMarkedDirty: (node: WatchNode) => {
     if (node.schedule !== null) {
       node.schedule(node.ref);
     }
   },
   hasRun: false,
   cleanupFn: NOOP_CLEANUP_FN,
 };
})();

I tutaj kończy się faza powiadamiania i przechodzenia przez graf.

Ten dwuetapowy proces jest czasami określany jako algorytm “push/pull”: “dirtiness” jest chętnie pushowany przez graf, gdy zmienia się sygnał źródłowy, ale ponowne obliczenia są wykonywane jako lazy, tylko wtedy, gdy wartości są pobierane przez odczyt ich sygnałów.

Change detection

Aby zintegrować powiadomienia oparte na sygnałach z procesem change detection, Angular opiera się na mechanizmie konsumentów na żywo. Szablony komponentów są kompilowane do wyrażeń szablonów (kod JS) i są wykonywane w reaktywnym kontekście widoku tego komponentu. W takich kontekstach wykonanie sygnału zwróci wartość, ale także zarejestruje sygnał jako zależność widoku komponentu.

Ponieważ wyrażenia szablonów są żywymi konsumentami, Angular utworzy łącze od producenta do węzła wyrażenia szablonu. Gdy tylko wartość producenta zostanie zaktualizowana, producent powiadomi węzeł szablonu natychmiast i synchronicznie. Po powiadomieniu Angular oznaczy komponent i wszystkich jego przodków do sprawdzenia.

Jak być może już wiesz z innych moich artykułów, szablon każdego komponentu jest wewnętrznie reprezentowany jako obiekt LView. Oto jak to wygląda dla komponentu:

@Component({...})
export class AppComponent {
 value = signal(0);
}

po skompilowaniu wygląda jak zwykła funkcja JS AppComponent_Template, która jest wykonywana podczas wykrywania zmian dla tego komponentu: 

this.ɵcmp = defineComponent({
 type: AppComponent,
 ...
 template: function AppComponent_Template(rf, ctx) {
   if (rf & 1) {
     ɵɵtext(0);
   }
   if (rf & 2) {
     ɵɵtextInterpolate1("", ctx.value(), "\n");
   }
 },
});

Gdy Angular dodał sygnały do change detection, opakował wszystkie widoki komponentów (funkcję szablonu) w węzeł ReactiveLViewConsumer:

export interface ReactiveLViewConsumer extends ReactiveNode {
 lView: LView | null;
}

Interfejs jest implementowany przez węzeł REACTIVE_LVIEW_CONSUMER_NODE:

const REACTIVE_LVIEW_CONSUMER_NODE: Omit<ReactiveLViewConsumer, 'lView'> = {
 ...REACTIVE_NODE,
 consumerIsAlwaysLive: true,
 consumerMarkedDirty: (node: ReactiveLViewConsumer) => {
   markAncestorsForTraversal(node.lView!);
 },
 consumerOnSignalRead(this: ReactiveLViewConsumer): void {
   this.lView![REACTIVE_TEMPLATE_CONSUMER] = this;
 },
};

Można myśleć o tym procesie w ten sposób, że każdy widok otrzymuje swój własny węzeł konsumenta ReactiveLViewConsumer, który definiuje kontekst reaktywny dla wszystkich sygnałów dostępnych wewnątrz funkcji szablonu.

W naszym przypadku za każdym razem, gdy funkcja szablonu zostanie uruchomiona w ramach wykrywania zmian, wykona ona producenta ctx.value() w kontekście węzła funkcji szablonu, który jest ActiveConsumer:

Spowoduje to dodanie węzła wyrażenia szablonu (konsumenta) jako żywej zależności do producenta value():

Ta zależność zapewnia, że gdy wartość licznika producenta ulegnie zmianie, natychmiast powiadomi on węzeł konsumenta (wyrażenie szablonu).

Żywi konsumenci implementują metodę consumerMarkDirty, która jest wywoływana synchronicznie przez producenta, gdy zmienia się jego wartość:

/**
* Propagate a dirty notification to live consumers of this producer.
*/
function producerNotifyConsumers(node: ReactiveNode): void {
 ...
 try {
   for (const consumer of node.liveConsumerNode) {
     if (!consumer.dirty) {
       consumerMarkDirty(consumer);
     }
   }
 } finally {
   inNotificationPhase = prev;
 }
}

function consumerMarkDirty(node: ReactiveNode): void {
 node.dirty = true;
 producerNotifyConsumers(node);
 node.consumerMarkedDirty?.(node);
}

Wewnątrz consumerMarkedDirty węzeł wyrażenia szablonu oznaczy przodków do odświeżenia za pomocą markAncestorsForTraversal w sposób podobny do tego, jak wcześniej robiła to funkcja markForCheck():

const REACTIVE_LVIEW_CONSUMER_NODE: Omit<ReactiveLViewConsumer, 'lView'> = {
 ...
 consumerMarkedDirty: (node: ReactiveLViewConsumer) => {
   markAncestorsForTraversal(node.lView!);
 },
};

function markAncestorsForTraversal(lView: LView) {
 let parent = getLViewParent(lView);
 while (parent !== null) {
   ...
   parent[FLAGS] |= LViewFlags.HasChildViewsToRefresh;
   parent = getLViewParent(parent);
 }
}

Ostatnie pytanie dotyczy tego, kiedy Angular ustawia bieżący węzeł konsumencki LView jako ActiveConsumer? Wszystko to dzieje się wewnątrz funkcji refreshView, którą możesz już znać z moich poprzednich artykułów.

Funkcja ta uruchamia wykrywanie zmian dla każdego LView i uruchamia typowe operacje wykrywania zmian: wykonywanie funkcji szablonu, wykonywanie haków, odświeżanie zapytań i ustawianie powiązań hosta. Zasadniczo cały fragment kodu do obsługi reaktywności został dodany, zanim Angular uruchomi wszystkie te operacje.

Oto jak to wygląda:

function refreshView<T>(tView, lView, templateFn, context) {
 ...

 // Start component reactive context
 enterView(lView);
 let returnConsumerToPool = true;
 let prevConsumer: ReactiveNode | null = null;
 let currentConsumer: ReactiveLViewConsumer | null = null;
 if (!isInCheckNoChangesPass) {
   if (viewShouldHaveReactiveConsumer(tView)) {
     currentConsumer = getOrBorrowReactiveLViewConsumer(lView);
     prevConsumer = consumerBeforeComputation(currentConsumer);
   } else {... }

   ...

   try {
     ...
     if (templateFn !== null) {
       executeTemplate(tView, lView, templateFn, RenderFlags.Update, context);
     }
 }

Ponieważ kod ten jest wykonywany przed uruchomieniem przez Angular funkcji szablonu komponentu w kodzie executeTemplate, gdy wykonywane są funkcje dostępu do sygnałów używanych w szablonie komponentu, istnieje już konfiguracja kontekstu reaktywnego.

Efekty i obserwatorzy

Efekt jest wyspecjalizowanym narzędziem, które ma na celu wykonywanie pobocznych operacji w oparciu o stan aplikacji. Efekty są żywymi konsumentami zdefiniowanymi z callabackiem, który jest wykonywane w kontekście reaktywnym. Zależności sygnałowe tej funkcji są przechwytywane, a efekt jest powiadamiany za każdym razem, gdy którakolwiek z jego zależności wytworzy nową wartość.

Efekty rzadko są potrzebne w większości kodu aplikacji, ale mogą być przydatne w określonych okolicznościach. Oto kilka przykładów użycia sugerowanych w dokumentacji Angular:

  • Rejestrowanie danych lub utrzymywanie ich w synchronizacji z window.localStorage
  • Dodawanie niestandardowych zachowań DOM, których nie można wyrazić za pomocą składni szablonu, np. wykonywanie niestandardowego renderowania elementu <canvas>.

Angular nie używa efektów w change detection do wyzwalania aktualizacji interfejsu użytkownika komponentu. Jak wyjaśniono w sekcji dotyczącej change detection, ta funkcja opiera się na mechanizmie żywych konsumentów.

Podczas gdy algorytm sygnału jest ustandaryzowany, szczegóły dotyczące zachowania efektów nie są zdefiniowane i będą się różnić w zależności od frameworka. Wynika to z subtelnego charakteru planowania efektów, który często integruje się z cyklami renderowania frameworka i innymi wysokopoziomowymi, specyficznymi dla frameworka stanami lub strategiami, do których JavaScript nie ma dostępu.

Propozycja sygnałów definiuje jednak zestaw prymitywów, a mianowicie watch API, które autorzy frameworków mogą wykorzystać do tworzenia własnych efektów. Interfejs Watcher służy do obserwowania funkcji reaktywnej i otrzymywania powiadomień, gdy zmieniają się zależności tej funkcji.

W Angularze efekt jest opakowaniem watchera. Najpierw zbadajmy, jak działają obserwatorzy i zobaczymy, jak są one używane do budowania prymitywnych efektów.

Najpierw zaimportujemy watcher z prymitywów Angulara i użyjemy go do zaimplementowania mechanizmu powiadomień:

import { createWatch } from '@angular/core/primitives/signals';

const counter = signal(0);

const watcher = createWatch(
 // run the user provided callback and set up tracking
 // this will be executed 2 times
 // 1st after `watcher.notify()` and 2nd time after `this.counter.set(1)`
 () => counter(),
 // this is called by the `notify` method
 // or by the consumer itself through through consumerMarkDirty method,
 // schedules the user provided callback to run in 1000ms
 () => setTimeout(watcher.run, 1000),
 false
);

// mark the watcher as dirty (stale) to force the user provided callback
// to run and set up tracking for the `counter` signal
// `notify` method will call `consumerMarkDirty` under the hood
watcher.notify();

// when the value changes, consumerMarkDirty is executed
// which schedules the user provided callback to run
setTimeout(() => this.counter.set(1), 3000);

Kiedy uruchamiamy watcher.notify(), Angular synchronicznie wywołuje metodę consumerMarkDirty na węźle watcher. Jednak zdefiniowany przez użytkownika callback powiadomienia nie jest wykonywany natychmiast po powiadomieniu. Zamiast tego jest on zaplanowany do uruchomienia przez watcher.run jakiś czas w przyszłości. Watcher po prostu wywoła tę operację planowania, gdy otrzyma powiadomienie “markDirty”.

Tutaj możesz zobaczyć w akcji:

Po uruchomieniu this.counter.set(1) ten sam łańcuch wywołań prowadzi do zaplanowania callbacku dostarczonego przez użytkownika.

Aby zbudować funkcję effect(), Angular opakowuje obserwatora wewnątrz klasy EffectHandle:

export function effect(effectFn,options): EffectRef {
 const handle = new EffectHandle();
 ...
 return handle;
}

class EffectHandle implements EffectRef, SchedulableEffect {
 unregisterOnDestroy: (() => void) | undefined;
 readonly watcher: Watch;

 constructor(...) {
   this.watcher = createWatch(
     (onCleanup) => this.runEffect(onCleanup),
     () => this.schedule(),
     allowSignalWrites,
   );
   this.unregisterOnDestroy = destroyRef?.onDestroy(() => this.destroy());
 }

Widać, że klasa EffectHandle jest miejscem, w którym konfigurowany jest obserwator. W naszym przykładzie powyżej, w którym wcześniej używaliśmy obserwatorów, użycie funkcji efektu znacznie uprości konfigurację:

import { Component, effect, signal } from '@angular/core';

@Component({...})
export class AppComponent {
 counter = null;

 constructor() {
   this.counter = signal(0);

   // this will be executed 2 times
   effect(() => this.counter());

   setTimeout(() => this.counter.set(1), 3000);
 }
}

Kiedy używamy funkcji efektu bezpośrednio, przekazujemy tylko jeden callback. Jest to callback zdefiniowany przez użytkownika, który ustawia zależności i jest zaplanowany do uruchomienia przez Angular, gdy zależności są aktualizowane.

Aktualnym harmonogramem używanym w efektach Angular jest ZoneAwareEffectScheduler, który uruchamia aktualizacje jako część kolejki mikrozadań po cyklu wykrywania zmian:

export class ZoneAwareEffectScheduler implements EffectScheduler {
 private queuedEffectCount = 0;
 private queues = new Map<Zone | null, Set<SchedulableEffect>>();
 private readonly pendingTasks = inject(PendingTasks);
 private taskId: number | null = null;

 scheduleEffect(handle: SchedulableEffect): void {
     this.enqueue(handle);
     if (this.taskId === null) {
       const taskId = (this.taskId = this.pendingTasks.add());
       queueMicrotask(() => {
         this.flush();
         this.pendingTasks.remove(taskId);
         this.taskId = null;
       });
     }
   }

Jest jedna interesująca rzecz, którą Angular musi zaimplementować, aby “zainicjować” efekt. Jak widzieliśmy w implementacji z watcher, musimy rozpocząć śledzenie poprzez pojedyncze ręczne wywołanie watcher.notify(). Angular również musi to zrobić i robi to w ramach pierwszego uruchomienia wykrywania zmian.

Oto jak to jest zaimplementowane.

Po wykonaniu funkcji efektu wewnątrz kontekstu wstrzykiwania komponentu, Angular doda wywołanie zwrotne powiadomienia do obiektu widoku LView[EFFECTS_TO_SCHEDULE] komponentu:

export function effect(
 effectFn: (onCleanup: EffectCleanupRegisterFn) => void,
 options?: CreateEffectOptions,
): EffectRef {
 ...
 const handle = new EffectHandle();

 // Effects need to be marked dirty manually to trigger their initial run. The timing of this
 // marking matters, because the effects may read signals that track component inputs, which are
 // only available after those components have had their first update pass.
 // ...
 const cdr = injector.get(ChangeDetectorRef, null, {optional: true}) as ViewRef<unknown> | null;
 if (!cdr || !(cdr._lView[FLAGS] & LViewFlags.FirstLViewPass)) {
   // This effect is either not running in a view injector, or the view has already
   // undergone its first change detection pass, which is necessary for any required inputs to be
   // set.
   handle.watcher.notify();
 } else {
   // Delay the initialization of the effect until the view is fully initialized.
   (cdr._lView[EFFECTS_TO_SCHEDULE] ??= []).push(handle.watcher.notify);
 }

 return handle;
}

Dodane w ten sposób funkcje powiadomień zostaną wykonane raz podczas pierwszego uruchomienia wykrywania zmian dla widoku tego komponentu wewnątrz funkcji refreshView:

export function refreshView<T>(tView,lView,templateFn,context) {
  ...

  // Schedule any effects that are waiting on the update pass of this view.
   if (lView[EFFECTS_TO_SCHEDULE]) {
     for (const notifyEffect of lView[EFFECTS_TO_SCHEDULE]) {
       notifyEffect();
     }

     // Once they've been run, we can drop the array.
     lView[EFFECTS_TO_SCHEDULE] = null;
   }
}

Wywołanie notifyEffect uruchomi callback powiadomienia consumerMarkDirty bazowego obserwatora, który z kolei zaplanuje efekt callback dostarczony przez użytkownika) do uruchomienia przy użyciu istniejącego harmonogramu (po wykryciu zmiany):

Podziel się artykułem

Zapisz się na nasz newsletter

Dołącz do community Angular.love i bądź na bieżąco z trendami.