Angular 19 był niesamowitym wydaniem i wprowadził świetne funkcje. Jedną z nich jest LinkedSignal. W skrócie, LinkedSignal jest podobny do funkcji computed, z tą różnicą, że computed zwraca sygnał tylko do odczytu, podczas gdy LinkedSignal zwraca sygnał zapisywalny. Przyjrzyjmy się bliżej, czym jest i jak go używać.
Składnia
Aby wyjaśnić składnię, zobaczmy prosty przykład.
Załóżmy, że mamy listę elementów i chcemy wyprowadzić liczbę tych elementów.
listOfItems = signal(['item1', 'item2', 'item3']);
countOfItems = linkedSignal(() => this.listOfItems().length);
// countOfItems = computed(() => this.listOfItems().length);
Składnia tutaj jest podobna do computed, gdzie stan jest wyprowadzany z sygnału źródłowego. Jak wspomnieliśmy na początku tego artykułu, różnica w przypadku LinkedSignal polega na tym, że możemy zmieniać wyprowadzoną wartość:
changeTheCountOfItems() {
this.countOfItems.set(0)
}
Zróbmy teraz to samo, ale z inną notacją:
countOfItems = linkedSignal({
source: this.listOfItems,
computation: (items) => items.length,
});
W tej notacji źródło akceptuje referencję do sygnału, a gdy wartość tego sygnału się zmienia, wywoływana jest funkcja obliczeniowa.
Niezależnie od notacji, jeśli listOfItems zawiera 3 elementy, countOfItems zwróci wartość 3. Jeśli dodamy element do listOfItems: this.listOfItems.update((items) => […items, 'item4′]) oczekujemy, że countOfItems dynamicznie zmieni się na 4.
Na pierwszy rzut oka wydaje się, że możemy osiągnąć to samo za pomocą dwóch różnych implementacji i że krótsza notacja to jedynie syntactic sugar. Jednak tak nie jest. Do końca tego artykułu zobaczysz różne przypadki użycia i poznasz różnice między tymi dwiema notacjami.
Przypadek użycia – Signal Input
Inputy sygnałowe są sygnałami tylko do odczytu, ale mimo to istnieją przypadki, w których musimy zaktualizować ich wartość. Zobaczmy krótki przykład niestandardowego komponentu Accordion, w którym powinniśmy przełączać jego stan po kliknięciu.
Close State
Open State
Code – Accordion Component
@Component({
selector: 'app-accordion',
template: `
<div class="accordion">
<div
(click)="toggle()"
[class.chevron-down]="!isOpen()"
[class.chevron-up]="isOpen()"
>
{{ isOpen() ? 'Close' : 'Open' }} Accordion
</div>
@if (isOpen()) {
<div class="content">
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua.
</p>
</div>
}
</div>
`,
styles: `
// I omitted the styles for brevity
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AccordionComponent {
isOpen = signal(false);
toggle() {
this.isOpen.set(!this.isOpen());
}
}
Aby umożliwić otwieranie i zamykanie, przechowujemy stan w zmiennej writable-signal o nazwie isOpen. To działa dobrze, ale co jeśli chcemy, aby użytkownicy tego komponentu mogli definiować jego początkowy stan (otwarty/zamknięty)?
Jeden użytkownik może potrzebować, aby domyślny stan accordionu był otwarty, podczas gdy inni mogą wymagać, aby był zamknięty.
Aby umożliwić interakcję użytkownika z komponentem, musimy przekonwertować isOpen na input sygnałowy. Jednak jest on tylko do odczytu, co oznacza, że nie będziemy mogli zmieniać stanu z poziomu szablonu HTML.
Z tego powodu musimy wykorzystać zarówno input sygnałowy, jak i LinkedSignal.
Code – Accordion Component z linkedSignal
export class AccordionComponent {
readonly isOpen = input(false);
state = linkedSignal(() => this.isOpen());
toggle() {
this.state.set(!this.state());
}
}
W powyższym kodzie:
- Konwertujemy isOpen na input sygnałowy.
- Tworzymy writable-signal, wyprowadzając jego wartość z inputu isOpen.
- Przełączamy wartość sygnału stanu i używamy state (a nie isOpen) w szablonie HTML.
Finałowy szablon HTML
<div class="accordion">
<div
(click)="toggle()"
[class.chevron-down]="!state()"
[class.chevron-up]="state()"
>
{{ state() ? 'Close' : 'Open' }} Accordion
</div>
@if (state()) {
<div class="content">
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua.
</p>
</div>
}
</div>
Ta zmiana zapewnia, że nie modyfikujemy oryginalnej wartości, jednocześnie utrzymując wszystko poprawnie zaktualizowane.
Przypadek użycia – Conditional Derived State
Zobaczmy kolejny prosty przykład z listą rozwijaną, gdzie elementy mogą się zmieniać w czasie działania, na przykład na skutek wywołania HTTP (lub innego źródła danych).
Na powyższym obrazku nie mamy wybranego elementu, dlatego Selected ma wartość null.
Jeśli wybierzemy element, oczekujemy, że:
Zobaczmy teraz kod i stopniowo zasugerujemy kolejne zmiany.
component.html
<mat-form-field appearance="fill">
<mat-label>Select an item</mat-label>
<mat-select [(value)]="selectedItem">
<mat-option [value]="null">Select</mat-option>
@for (item of listOfItems(); track $index) {
<mat-option [value]="item">
{{ item.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
Selected: {{ selectedItem() | json }}
component.ts
selectedItem = signal<Item | null>(null);
listOfItems: WritableSignal<Item[]> = signal([
{ id: 1, name: 'item 1' },
{ id: 2, name: 'item 2' },
{ id: 3, name: 'item 3' },
]);
Kod jest prosty. Sygnał selectedItem odpowiada za przechowywanie wybranego stanu, a tablica sygnałów listOfItems reprezentuje nasze źródło danych.
Załóżmy, że źródło danych dynamicznie zmienia się na skutek odpowiedzi HTTP i chcemy:
- Wyczyścić stan selectedItem, jeśli nowe dane nie zawierają wybranego elementu.
- Zachować stan w przeciwnym przypadku.
Wprowadzimy dwie metody, które będą symulować działanie zapytań HTTP.
changeTheItemsIncludingTheDefaultOnes() {
this.listOfItems.set([
{ id: 1, name: 'item 1' },
{ id: 2, name: 'item 2' },
{ id: 3, name: 'item 3' },
{ id: 4, name: 'item 4' }, // introduced item
{ id: 5, name: 'item 5' }, // introduced item
]);
}
changeTheItemsExcludingTheDefaultOnes() {
this.listOfItems.set([
{ id: 4, name: 'item 4' },
{ id: 5, name: 'item 5' },
]);
}
Metody są intuicyjne:
- Pierwsza dodaje dwa nowe elementy, zachowując wcześniejsze.
- Druga zastępuje listę nowymi elementami.
Pierwszy przykład:
Wybierzmy element z menu rozwijanego, a następnie wywołajmy changeTheItemsIncludingTheDefaultOnes.
W tym scenariuszu element 1 powinien pozostać zaznaczony, ponieważ nowe dane nadal go zawierają.
Jak widać na powyższym obrazku, rzeczy nie działają zgodnie z oczekiwaniami ❌
Mamy zapisany stan wyboru, ale brakuje zaznaczenia w menu. Dzieje się tak, ponieważ menu śledzi wybrane elementy, porównując referencje obiektów, a my właśnie wprowadziliśmy nowe obiekty.
Drugi przykład:
Wybierzmy element, a następnie wywołajmy changeTheItemsExcludingTheDefaultOnes.
W tym scenariuszu, ponieważ nowe źródło danych nie zawiera wybranego elementu, chcemy:
- Usunąć wybrany element z menu rozwijanego.
- Wyczyścić stan selectedItem.
Ponownie, jak widać na powyższym obrazku, rzeczy nie działają zgodnie z oczekiwaniami ❌
Wybrany element nie znajduje się w menu rozwijanym, ale stan zaznaczenia wciąż jest obecny.
Problem
Problem w obu przypadkach polega na tym, że nie zarządzamy selectedItem w odpowiedni sposób.
Rozwiązanie
Rozwiązaniem, jak można się domyślić, jest właściwe zarządzanie selectedItem 🙂.
// selectedItem = signal<Item | null>(null);
selectedItem = linkedSignal<Item[], Item | null>({
source: this.listOfItems,
computation: (items, previous) => {
return items.find((item) => item.id === previous?.value?.id) || null;
},
});
Sztuczka polega na funkcji obliczeniowej. Za każdym razem, gdy listOfItems ma nową wartość, funkcja obliczeniowa jest wywoływana z dwoma argumentami. Pierwszy to surowe dane źródła sygnału, a drugi to stan poprzednio wybranych wartości.
linkedSignal z wieloma źródłami
Możesz się zastanawiać, czy możemy używać linkedSignal z więcej niż jednym źródłem sygnału. Prosta odpowiedź to „Tak”. Zobaczmy, jak wygląda deklaracja API:
export declare function linkedSignal<S, D>(options: {
source: () => S;
computation: (source: NoInfer<S>, previous?: {
source: NoInfer<S>;
value: NoInfer<D>;
}) => D;
equal?: ValueEqualityFn<NoInfer<D>>;
}): WritableSignal<D>;
Źródło: () => S to funkcja, która zwraca typ S. Oznacza to, że możemy podać tyle źródeł, ile chcemy.
Nie mam konkretnego przykładu, ale możemy wyobrazić sobie wymagania. Załóżmy, że mamy dwa źródła sygnałów, z których każde zwraca liczbę. Musimy zwrócić wynik tych liczb przy użyciu linkedSignal.
Wiem, że to nie jest idealny przykład i na pewno nie coś, co wykorzystalibyśmy w systemie produkcyjnym. Mimo to, jest to wystarczająco dobre do eksperymentowania.
signalSourceOne = signal(1);
signalSourceTwo = signal(2);
singleFromMultiple = linkedSignal<
{ sourceOne: number; sourceTwo: number }, // type of the source
number // type of the return value
>({
source: () => ({
sourceOne: this.signalSourceOne(),
sourceTwo: this.signalSourceTwo(),
}),
computation: (data) => {
return data.sourceOne + data.sourceTwo;
},
});
linkedSignal akceptuje dwa typy generyczne. Pierwszy typ to źródło, a drugi to wartość zwróconą. W naszym przypadku źródłem jest obiekt {sourceOne: number, sourceTwo: number}, a ponieważ zwracamy wynik obu, typ zwróconej wartości to number.
W funkcji źródłowej zwracamy obiekt, w którym każda właściwość przechowuje wartość sygnału (zwróć uwagę na nawiasy). Jeśli to jest nieco mylące, przepiszmy selectedItem z poprzedniego przykładu, aby używał tej samej notacji:
selectedItem = linkedSignal<Item[], Item | null>({
source: () => this.listOfItems(), // we return a function
computation: (items, previous) => {
return items.find((item) => item.id === previous?.value?.id) || null;
},
});
W momencie pisania tego artykułu, ta funkcja znajduje się w fazie developer preview, ale nie powinno nas to powstrzymywać przed jej wypróbowaniem.
Dziękuję za przeczytanie mojego artykułu!