06 lut 2025
7 min

Czym jest LinkedSignal i jak go używać?

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!

Podziel się artykułem

Zapisz się na nasz newsletter

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