Sygnały w Angularze: dogłębna analiza dla zajętych programistów

Budowanie złożonych interfejsów użytkownika to trudne zadanie. W nowoczesnych aplikacjach webowych stan UI rzadko składa się z prostych, samodzielnych wartości. Jest to raczej skomplikowany stan wyliczany, który zależy od złożonej hierarchii innych wartości lub stanów wyliczanych. Zarządzanie tym stanem wymaga wiele pracy: programiści muszą te wartości przechowywać, wyliczać, unieważniać i synchronizować.

Przez lata w web developmencie pojawiło się wiele frameworków i prymitywów upraszczających to zadanie. Motywem przewodnim większości z nich jest programowanie reaktywne, które daje infrastrukturę do zarządzania stanem aplikacji, pozwalając programistom skupić się na logice biznesowej, a nie na powtarzalnych zadaniach zarządzania stanem.

Najnowszym dodatkiem są sygnały — „reaktywny” prymityw, który reprezentuje dynamicznie zmieniającą się wartość i potrafi powiadomić zainteresowanych konsumentów o zmianie tej wartości. Ci z kolei mogą uruchomić przeliczenia albo różne efekty uboczne, np. tworzenie/niszczenie komponentów, żądania sieciowe, aktualizację DOM itd.

Różne implementacje sygnałów można znaleźć w różnych frameworkach. Trwają nawet prace nad standaryzacją sygnałów:

… te prace koncentrują się na ujednoliceniu ekosystemu JavaScriptu. Kilku autorów frameworków współpracuje tutaj nad wspólnym modelem, który mógłby stanowić rdzeń ich reaktywności. Obecny szkic opiera się na uwagach projektowych autorów/opiekunów Angulara, Bubble, Ember, FAST, MobX, Preact, Qwik, RxJS, Solid, Starbeam, Svelte, Vue, Wiz i innych…

Implementacja sygnałów w Angularze bardzo przypomina tę zaproponowaną w ramach propozycji, więc mogę w tym artykule odwoływać się do obu.

Sygnały jako prymitywy

Sygnał reprezentuje komórkę danych, która może się zmieniać w czasie. Sygnały mogą być albo „stanem” (po prostu wartością ustawianą ręcznie), albo „wyliczane” (pomyśl o formule opartej na innych sygnałach).

Sygnały wyliczane działają dzięki automatycznemu śledzeniu, które inne sygnały są czytane podczas ich obliczania. Gdy sygnał wyliczany jest czytany, sprawdza, czy któraś z wcześniej zapisanych zależności się zmieniła, i jeśli tak — przelicza się ponownie.

Na przykład mamy tutaj sygnał stanu counter i sygnał wyliczany isEven. Początkową wartość counter ustawiamy na 0, a później zmieniamy ją na 1. Widać, że sygnał wyliczany isEven reaguje na zmiany, dając dwie różne wartości przed i po aktualizacji sygnału counter:

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

// sygnał stanu / zapisywalny
const counter = signal(0);

// sygnał wyliczany
const isEven = computed(() => (counter() & 1) == 0);

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

counter.set(1)

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

Zauważ też, że w przykładzie powyżej sygnał isEven nie subskrybuje jawnie źródłowego sygnału counter. Zamiast tego po prostu wywołuje sygnał źródłowy przez counter() wewnątrz swojej funkcji wyliczającej. To wystarczy, by powiązać oba sygnały. W rezultacie ilekroć źródłowy sygnał counter zaktualizuje się nową wartością, sygnał pochodny również aktualizuje się automatycznie.

Zarówno sygnały stanu, jak i wyliczane uważa się za producentów (producers) wartości. Producenci to sygnały, które produkują wartości i mogą dostarczać powiadomienia o zmianie.

Sygnał stanu zmienia (produkuje) swoją wartość, gdy wartość zostaje zaktualizowana przez wywołanie API, natomiast sygnał wyliczany generuje nową wartość automatycznie, gdy zmieniają się zależności użyte w callbacku.

Sygnały wyliczane mogą być również konsumentami (consumers), ponieważ mogą zależeć od pewnej liczby producentów (konsumować ich wartości). W innych implementacjach reaktywnych, np. w Rx, konsumenci znani są także jako ujścia (sinks).

Gdy wartość sygnału-producenta się zmienia, wartości zależnych konsumentów, np. sygnałów wyliczanych, nie są aktualizowane natychmiast. Gdy sygnał wyliczany jest czytany, sprawdza, czy któraś z wcześniej zapisanych zależności się zmieniła, i w razie potrzeby przelicza się ponownie.

To czyni sygnały wyliczane leniwymi, czyli pull-based: są obliczane tylko przy dostępie do nich, nawet jeśli leżący pod spodem stan zmienił się wcześniej. W naszym przykładzie powyżej wartość sygnału wyliczanego jest obliczana tylko wtedy, gdy wywołamy isEven(), mimo że aktualizacja leżącej pod spodem zależności counter nastąpiła wcześniej, gdy wykonaliśmy counter.set().

Poza zwykłymi sygnałami zapisywalnymi i wyliczanymi istnieje też koncepcja obserwatorów (watchers, efektów). W przeciwieństwie do pull-based obliczania sygnałów wyliczanych, zmiana sygnału-producenta natychmiast powiadomi obserwatora, synchronicznie wywołując jego callback powiadomienia, czyli faktycznie „wypychając” (push) powiadomienie. Frameworki owijają obserwatorów w efekty, które udostępniane są użytkownikom. Efekty opóźniają powiadomienie kodu użytkownika przez planowanie (scheduling).

W przeciwieństwie do promise’ów wszystko w sygnałach działa synchronicznie:

  • Ustawienie sygnału na nową wartość jest synchroniczne i natychmiast odzwierciedla się przy czytaniu dowolnego sygnału wyliczanego, który od niego zależy. Nie ma wbudowanego grupowania (batchingu) tej mutacji.
  • Czytanie sygnałów wyliczanych jest synchroniczne — ich wartość jest zawsze dostępna.
  • Obserwatorzy są powiadamiani synchronicznie, ale efekty, które ich owijają, mogą grupować i opóźniać powiadomienie przez planowanie.

Szczegóły implementacji

Wewnętrznie implementacja sygnałów definiuje kilka koncepcji, które chcę w tym artykule wyjaśnić: kontekst reaktywny, graf zależności oraz efekty (obserwatorów). Zacznijmy od kontekstu reaktywnego.

Aby omówić kontekst reaktywny, pomyśl o ramce stosu (ramce wykonania), która definiuje środowisko, w obrębie którego kod JavaScript jest obliczany 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 uruchomiona w kontekście web workera nie ma dostępu do globalnego obiektu document.

Kontekst reaktywny definiuje aktywny obiekt-konsument, który zależy od producentów i jest dostępny dla ich funkcji dostępowej za każdym razem, gdy czytana jest ich wartość. Na przykład mamy tutaj konsumenta isEvent, który zależy od producenta counter (konsumuje jego wartość). Ta zależność jest zdefiniowana przez dostęp do wartości counter wewnątrz callbacku computed:

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

Gdy callback computed zostanie uruchomiony, automatycznie wykona funkcję dostępową sygnału counter, aby pobrać jego wartość. Można powiedzieć, że w takim przypadku sygnał counter jest wykonywany w kontekście reaktywnym konsumenta isEvent. Zatem 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 następuje dostęp do wartości konsumenta, ale przed jej przeliczeniem (przed uruchomieniem callbacku computed), możemy ustawić tego konsumenta jako aktywnego konsumenta. Da się to zrobić, po prostu przypisując ten obiekt-konsument do zmiennej globalnej i trzymając go tam w czasie wykonywania callbacku. Ta zmienna globalna będzie dostępna dla wszystkich producentów odpytywanych podczas wykonywania callbacku computed i zdefiniuje kontekst reaktywny dla wszystkich producentów, od których ten konsument zależy.

Dokładnie to robi Angular. Gdy wykonywany jest callback computed, najpierw ustawi bieżący węzeł 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 dochodzi tutaj z producerUpdateValueVersion wewnątrz funkcji fabrykującej createComputed:

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

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

Ten stos wywołań również wyraźnie pokazuje tę implementację:

stos wywołań pokazujący producerRecomputeValue i setActiveConsumer

Dzięki temu, podczas gdy callback computed jest wykonywany, każdy producent odpytywany w czasie, gdy ten konsument jest aktywny, będzie wiedział, że jest wykonywany w kontekście reaktywnym. Wszyscy producenci wykonani w kontekście reaktywnym danego konsumenta są dodawani jako zależności tego konsumenta. To właśnie tworzy graf reaktywny.

Większość wcześniej istniejącej funkcjonalności w Angularze wykonywana jest w kontekście niereaktywnym. Można to zaobserwować, po prostu wyszukując użycia setActiveConsumer z wartością null:

wyniki wyszukiwania setActiveConsumer(null)

Na przykład przed uruchomieniem hooków cyklu życia 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 szablonów Angulara (widoki komponentów) oraz efekty są uruchamiane w kontekstach reaktywnych.

Graf reaktywny

Graf reaktywny budowany jest przez zależności między konsumentami a producentami. Implementacja kontekstu reaktywnego poprzez funkcje dostępowe do wartości sprawia, że zależności sygnałów mogą być śledzone automatycznie i niejawnie. Użytkownicy nie muszą deklarować tablic zależności, a zbiór zależności danego kontekstu nie musi pozostawać statyczny między wykonaniami.

Gdy producent jest wykonywany, dodaje siebie 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;
    }

W grafie reaktywnym uczestniczą zarówno producenci, jak i konsumenci. Ten graf zależności jest dwukierunkowy, ale występują różnice w tym, które zależności są śledzone w każdym z kierunków.

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ą śledzeni także jako konsumenci „żywi” (live) i tworzą krawędzie w drugą stronę — od producenta do konsumenta. Te krawędzie służą do propagowania powiadomień o zmianie, gdy wartość producenta zostaje zaktualizowana:

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

Konsumenci zawsze śledzą producentów, od których zależą. Producenci śledzą zależności tylko od tych konsumentów, których uważa się za „żywych”. Konsument jest „żywy”, gdy ma właściwość consumerIsAlwaysLive ustawioną na true albo gdy sam jest producentem, od którego zależy żywy konsument.

W Angularze jako żywi konsumenci zdefiniowane są dwa typy węzłów:

  • węzły watch (używane w efektach)
  • reaktywne węzły LView (używane w change detection)

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 pewnych kontekstach sygnały computed mogą stać się konsumentami „żywymi”, na przykład gdy są używane w callbacku effect.

Poniższy kod:

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;

    // trzeba poczekać, aż change detection powiadomi efekt
    setTimeout(() => {
      // efekt zależy od B
      const depEToB = e.watcher[SIGNAL].producerNode[0] === nodes[B];

      // powiązania żywych konsumentów prowadzą od producenta A do B
      // oraz od B do E, bo E (efekt) jest żywym konsumentem
      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 graf:

graf zależności reaktywnych z węzłami A, B, C, D, E oraz powiązaniami producer i live consumer

Implementacja kontekstu reaktywnego przez aktywnego konsumenta umożliwia dynamiczne śledzenie zależności. Gdy dany konsument jest ustawiony jako aktywny, zbiór obliczanych producentów definiowany jest dynamicznie, poprzez kolejność wywołań tych producentów. Lista zależności może być przebudowywana dla ActiveConsumer za każdym razem, gdy w kontekście reaktywnym tego konsumenta następuje dostęp do producenta.

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

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

Gdy obliczenie dla danego konsumenta jest uruchamiane ponownie, wskaźnik (indeks) nextProducerIndex do tej tablicy jest inicjalizowany indeksem 0, a każde odczytanie zależności porównywane jest z zależnością z poprzedniego przebiegu na bieżącej pozycji wskaźnika. Jeśli występuje niezgodność, oznacza to, że zależności zmieniły się od ostatniego przebiegu i starą zależność można porzucić i zastąpić nową. Na końcu przebiegu wszystkie pozostałe niedopasowane zależności można porzucić.

Oznacza to, że jeśli masz zależność potrzebną tylko w jednej gałęzi, a poprzednie obliczenie poszło inną gałęzią, to zmiana tej tymczasowo nieużywanej wartości nie spowoduje ponownego przeliczenia sygnału wyliczanego, nawet gdy jego wartość zostanie pobrana. Skutkuje to możliwością, że od jednego wykonania do następnego używany będzie inny zbiór sygnałów.

Na przykład ten sygnał wyliczany dynamic czyta albo dataA, albo dataB, w zależności od wartości sygnału useA:

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

W dowolnym momencie będzie miał zbiór zależności albo [useA, dataA], albo [useA, dataB], i nigdy nie może zależeć od dataA i dataB jednocześnie.

Ten kod, podobny do tego przypadku testowego w Angularze, 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 widzisz, graf nie ma jednego jedynego wierzchołka startowego. Ponieważ każdy konsument trzyma listę producentów-zależności, którzy z kolei też mogą mieć zależności (np. sygnał wyliczany), można powiedzieć, że każdy konsument w momencie dostępu do niego jest wierzchołkiem-korzeniem grafu.

Dwufazowe aktualizacje

Wcześniejsze modele reaktywności typu push borykały się z problemem zbędnych obliczeń: jeśli aktualizacja sygnału stanu powoduje natychmiastowe uruchomienie sygnału wyliczanego, może to ostatecznie wypchnąć aktualizację do UI. Ale taki zapis do UI może być przedwczesny, jeśli przed następną klatką miała nastąpić kolejna zmiana źródłowego sygnału stanu.

Na przykład dla takiego grafu problem polega na tym, że niechcący obliczamy A -> B -> D oraz C, a potem ponownie obliczamy D, bo C się zmienił. Obliczanie D dwukrotnie jest nieefektywne i może prowadzić do zauważalnych dla użytkownika zakłóceń.

graf problemu diamentu: A -> B -> D oraz A -> C -> D

Jest to znane jako problem diamentu (diamond problem).

Czasem z powodu takich zakłóceń (glitches) użytkownikom końcowym pokazywano nawet niepoprawne wartości pośrednie. Sygnały unikają tej dynamiki, będąc pull-based (leniwe), a nie push-based: w momencie, gdy framework planuje renderowanie UI, pobierze odpowiednie aktualizacje, unikając zmarnowanej pracy zarówno w obliczeniach, jak i w zapisie do DOM.

Rozważ ten przykład:

const a = signal(0);

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

// uruchamiamy callback computed, aby ustanowić zależności
d();

// aktualizujemy sygnał na szczycie grafu
setTimeout(() => a.set(1), 2000);

Po zaktualizowaniu a nie zachodzi ż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++;
  ...
}

Gdy później sięgamy po wartość d(), implementacja sygnałów odpytuje zależności powyżej d przez consumerPollProducersForChange, aby ustalić, czy przeliczenie jest konieczne.

Dla efektywnego przetwarzania wszystkie węzły reaktywne zapisują wersję węzła-zależności. Aby ustalić zmianę, wystarczy po prostu porównać zapisaną wersję węzła-producenta z aktualną wersją na tym węźle:

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ą, to znaczy, że producent się zmienił, i implementacja uruchomi przeliczenie callbacku computed przez 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;
}

Co powtórzy proces dla zależności C. W ten sposób dotrze do węzła A, co w tym momencie skutkuje obliczeniem gałęzi D->C->A. Ale ponieważ D zależy też od producenta B, przeliczy również jego, przed obliczeniem D. W ten sposób nie ma problemu podwójnego obliczania D.

Czasem jednak może być potrzeba natychmiastowego powiadomienia pewnych konsumentów. Jak być może się domyślasz, są to właśnie konsumenci „żywi”. W tym przypadku powiadomienie o zmianie propagowane jest przez graf natychmiast po aktualizacji wartości producenta, powiadamiając żywych konsumentów, którzy od tego producenta zależą.

Niektórzy z tych konsumentów mogą być wartościami pochodnymi, a więc również producentami: unieważniają swoje zbuforowane wartości, a następnie kontynuują propagację powiadomienia o zmianie do własnych żywych konsumentów, i tak dalej. Ostatecznie to powiadomienie dochodzi do efektów, które planują siebie do ponownego wykonania.

Co kluczowe, podczas tej fazy nie są uruchamiane żadne efekty uboczne ani nie wykonuje się żadnego przeliczania wartości pośrednich czy pochodnych — jedynie unieważnianie zbuforowanych wartości. Pozwala to, by powiadomienie o zmianie dotarło do wszystkich dotkniętych węzłów grafu bez możliwości zaobserwowania stanów pośrednich lub zakłóconych.

Jeśli potrzeba, po zakończeniu tej propagacji zmian (synchronicznej) po tym etapie może nastąpić leniwe obliczanie, które omówiliśmy powyżej.

Aby zobaczyć tę fazę powiadamiania w działaniu, dodajmy do naszego przykładu żywego konsumenta, np. obserwatora. Gdy a jest aktualizowane, aktualizacja propagowana jest 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);

// obserwator ustanowi zależność od `d`
const watcher = createWatch(
  () => console.log(d()),
  () => setTimeout(watcher.run, 1000),
  false
);

watcher.notify();

Gdy tylko zaktualizujemy wartość przez a.set(1), widzimy powiadamianie żywych konsumentów w działaniu:

debugger pokazujący przechodzenie producerNotifyConsumers po węzłach żywych konsumentów

Węzły b i c są żywymi konsumentami węzła a, dlatego wykonując aktualizację dla a, Angular przejdzie po node.liveConsumerNode i powiadomi te węzły o zmianie.

Ale jak wspomniano wcześniej, tak naprawdę nic tu się nie dzieje. Węzeł jest po prostu oznaczany jako brudny (dirty) i propaguje powiadomienie do swoich żywych konsumentów przez producerNotifyConsumers:

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

Wszystko to schodzi w dół aż do obserwatora (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 faza powiadamiania oraz przechodzenie grafu się kończą.

Ten dwuetapowy proces bywa nazywany algorytmem „push/pull”: „brudność” jest zachłannie wypychana przez graf, gdy zmienia się sygnał źródłowy, ale przeliczanie wykonywane jest leniwie, tylko gdy wartości zostają pobrane przez czytanie ich sygnałów.

Change detection

Aby wpleść powiadomienia oparte na sygnałach w proces change detection, Angular opiera się na mechanizmie żywych konsumentów. Szablony komponentów kompilowane są do wyrażeń szablonowych (kodu JS) i wykonywane w kontekście reaktywnym widoku tego komponentu. W takich kontekstach wykonanie sygnału zwróci wartość, ale też zarejestruje ten sygnał jako zależność widoku komponentu.

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

Jak być może już wiesz z moich innych 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 kompilacji wygląda to jak zwykła funkcja JS AppComponent_Template, która wykonywana jest podczas change detection 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 swojej implementacji change detection, owinął wszystkie widoki komponentów (funkcje szablonów) w węzeł ReactiveLViewConsumer:

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

Ten interfejs implementowany jest 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;
  },
};

Ten proces możesz sobie wyobrażać tak: każdy widok dostaje swój własny węzeł-konsumenta ReactiveLViewConsumer, który definiuje kontekst reaktywny dla wszystkich sygnałów, do których następuje dostęp wewnątrz funkcji szablonu.

W naszym przypadku ilekroć funkcja szablonu uruchamiana jest jako część change detection, wykona producenta ctx.value() w kontekście węzła funkcji szablonu, który jest ActiveConsumer:

funkcja szablonu wykonująca sygnał w kontekście reaktywnym

Skutkiem tego węzeł wyrażenia szablonowego (konsument) zostanie dodany jako żywa zależność producenta value():

powiązanie żywego konsumenta od producenta do węzła szablonu

Ta zależność zapewnia, że gdy wartość producenta counter się zmieni, natychmiast powiadomi on węzeł-konsumenta (wyrażenie szablonowe).

Żywi konsumenci implementują metodę consumerMarkDirty, którą producent wywołuje synchronicznie, 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 szablonowego oznaczy przodków do odświeżenia za pomocą markAncestorsForTraversal, w sposób podobny do tego, jak robił to wcześniej 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 brzmi: kiedy Angular ustawia bieżący węzeł-konsumenta LView jako ActiveConsumer? Wszystko to dzieje się wewnątrz funkcji refreshView, którą być może znasz już z moich poprzednich artykułów.

Ta funkcja uruchamia change detection dla każdego LView i wykonuje typowe operacje change detection: wykonanie funkcji szablonu, wykonanie hooków, odświeżenie queries i ustawienie host bindings. W zasadzie cały fragment kodu obsługujący reaktywność został dodany przed tym, jak Angular wykonuje 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ż ten kod wykonywany jest przed tym, jak Angular uruchamia funkcję szablonu komponentu w kodzie executeTemplate, w momencie wykonania funkcji dostępowych sygnałów użytych w szablonie komponentu kontekst reaktywny jest już ustawiony.

Efekty i obserwatorzy

Efekt to specjalizowane narzędzie, którego zadaniem jest wykonywanie operacji z efektami ubocznymi na podstawie stanu aplikacji. Efekty to żywi konsumenci definiowani callbackiem, który wykonywany jest w kontekście reaktywnym. Zależności-sygnały tej funkcji są przechwytywane, a efekt jest powiadamiany, ilekroć któraś z jego zależności wyprodukuje nową wartość.

W większości kodu aplikacyjnego efekty są rzadko potrzebne, ale mogą być użyteczne w konkretnych okolicznościach. Oto kilka przykładów użycia sugerowanych w dokumentacji Angulara:

  • Logowanie danych albo utrzymywanie ich w synchronizacji z window.localStorage
  • Dodawanie własnego zachowania DOM, którego nie da się wyrazić składnią szablonów, np. własne renderowanie do elementu <canvas>

Angular nie używa efektów w mechanizmie change detection do wyzwalania aktualizacji UI komponentu. Jak wyjaśniono w rozdziale o change detection, do tej funkcjonalności opiera się on na mechanizmie żywych konsumentów.

Choć algorytm sygnałów jest ustandaryzowany, szczegóły tego, jak powinny zachowywać się efekty, nie są zdefiniowane i będą się różnić między frameworkami. Wynika to z subtelnej natury planowania efektów, które często wplata się w cykle renderowania frameworka oraz inne wysokopoziomowe, specyficzne dla frameworka stany lub strategie, do których JavaScript nie ma dostępu.

Propozycja sygnałów definiuje jednak zbiór prymitywów, a mianowicie API watch, z którego autorzy frameworków mogą korzystać, by tworzyć własne efekty. Interfejs Watcher służy do obserwowania funkcji reaktywnej i otrzymywania powiadomień, gdy zależności tej funkcji się zmieniają.

W Angularze effect jest owijką nad watcher. Najpierw zbadajmy, jak działają obserwatorzy, a potem zobaczymy, jak używa się ich do zbudowania prymitywu effect.

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(
  // uruchamiamy callback dostarczony przez użytkownika i ustawiamy śledzenie
  // wykona się on 2 razy:
  // 1. po `watcher.notify()`, 2. po `this.counter.set(1)`
  () => counter(),
  // to wywoływane jest przez metodę `notify`
  // albo przez samego konsumenta poprzez metodę consumerMarkDirty,
  // planuje uruchomienie callbacku użytkownika po 1000 ms
  () => setTimeout(watcher.run, 1000),
  false
);

// oznaczamy obserwatora jako brudnego (nieaktualnego), aby wymusić
// uruchomienie callbacku użytkownika i ustawić śledzenie sygnału `counter`
// metoda `notify` wywoła pod spodem `consumerMarkDirty`
watcher.notify();

// gdy wartość się zmienia, wykonywane jest consumerMarkDirty,
// które planuje uruchomienie callbacku dostarczonego przez użytkownika
setTimeout(() => this.counter.set(1), 3000);

Gdy uruchamiamy watcher.notify(), Angular synchronicznie wywołuje metodę consumerMarkDirty na węźle obserwatora. Jednak zdefiniowany przez użytkownika callback powiadomienia nie jest wykonywany natychmiast po powiadomieniu. Zamiast tego jego uruchomienie planowane jest przez watcher.run kiedyś w przyszłości. watch po prostu wywoła tę operację planowania, gdy otrzyma powiadomienie „markDirty”.

Tutaj widzisz to w działaniu:

powiadomienie obserwatora i planowanie

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

Aby zbudować funkcję effect(), Angular owija obserwatora w klasę 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 to miejsce, w którym ustawiany jest obserwator. Dla naszego przykładu powyżej, gdzie używaliśmy obserwatorów wprost, użycie funkcji effect znacząco upraszcza konfigurację:

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

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

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

    // to wykona się 2 razy
    effect(() => this.counter());

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

Gdy używamy funkcji effect bezpośrednio, przekazujemy tylko jeden callback. To zdefiniowany przez użytkownika callback, który ustanawia zależności i którego uruchomienie Angular planuje, gdy zależności zostaną zaktualizowane.

Obecny scheduler używany w efektach Angulara to ZoneAwareEffectScheduler, który wykonuje aktualizacje jako część kolejki mikrozadań po cyklu change detection:

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 ciekawa osobliwość, którą Angular musi zaimplementować, by „zainicjalizować” efekt. Jak widzieliśmy w implementacji z obserwatorem, śledzenie trzeba rozruszać, wywołując raz ręcznie watcher.notify(). Angular też musi to zrobić i robi to w ramach pierwszego przebiegu change detection.

Oto jak to zaimplementowano.

Gdy wykonasz funkcję effect wewnątrz kontekstu wstrzykiwania komponentu, Angular doda callback powiadomienia do obiektu widoku komponentu LView[EFFECTS_TO_SCHEDULE]:

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;
}

Funkcje powiadamiające dodane w ten sposób zostaną wykonane raz, podczas pierwszego przebiegu change detection 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 wyzwoli callback powiadomienia consumerMarkDirty leżącego pod spodem obserwatora, który z kolei zaplanuje uruchomienie efektu (callbacku dostarczonego przez użytkownika) przy użyciu istniejącego schedulera (po change detection):

inicjalizacja efektu przez notifyEffect w refreshView

I to cała historia.