February 19

Примеры использования Signals в Angular

Сигналы имеют важное значение в разработке на Angular, поскольку открывают новые возможности. Например, Signal input упрощает создание вычисляемых сигналов, обеспечивая более эффективные процессы разработки. .

В течение этого поста проиллюстрируем эти концепции на практических примерах, используя выдуманный API.

Обязательный Signal Input

// pokemon.component.ts

import { ChangeDetectionStrategy, Component, input } from '@angular/core';

@Component({
  selector: 'app-pokemon',
  standalone: true,
  imports: [PokemonCardComponent],
  ],
  template: `
    <p>Pokemon id: {{ id() }}</p>
    <hr />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonComponent {
  id = input.required<number>();
}

Компонент PokemonComponent имеет обязательный входной сигнал id, который ожидает числовое значение. Это значение отображается встроенным шаблоном.

// main.ts

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [PokemonComponent, RouterOutlet, RouterLink, FormsModule],
  template: `
    <div>
      <app-pokemon [id]="25"  />
      <app-pokemon [id]="52" />
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class App {}

Вычисляемый сигнал, основанный на входящем

// pokemon.componen.ts

import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';

@Component({
  selector: 'app-pokemon',
  standalone: true,
  template: `
    <p>Pokemon id: {{ id() }}, Next Pokemon id: {{ nextId() }}</p>
    <hr />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonComponent {
  id = input.required<number>();
  nextId = computed(() => this.id() + 1);

nextId - это вычисляемый сигнал, который увеличивает входной сигнал id на 1.

Входящий сигнал со значением по умолчанию

// pokemon.componen.ts

import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';

@Component({
  selector: 'app-pokemon',
  standalone: true,
  template: `
    <p>Pokemon id: {{ id() }}, Next Pokemon id: {{ nextId() }}</p>
    <p [style.background]="bgColor()">
      Background color: {{ bgColor() }}
    </p>
    <hr />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonComponent {
  id = input.required<number>();
  bgColor = input('cyan', { alias: 'backgroundColor' });

  nextId = computed(() => this.id() + 1);
}

В компоненте объявлен входной сигнал с начальным значением cyan, и алиасом backgroundColor. Когда у компонента нет входного backgroundColor, применяется значение cyan.

// main.ts

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [PokemonComponent, RouterOutlet, RouterLink, FormsModule],
  template: `
    <div>
      <app-pokemon [id]="25"  />
      <app-pokemon [id]="52" backgroundColor="yellow" />
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class App {}

Измененный Signal Input

// pokemon.component.ts

import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';

@Component({
  selector: 'app-pokemon',
  standalone: true,
  template: `
    <p>Pokemon id: {{ id() }}, Next Pokemon id: {{ nextId() }}</p>
    <p [style.background]="bgColor()">
      Background color: {{ bgColor() }}
    </p>
    <p>Transformed: {{ text() }}</p>
    <hr />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonComponent {
  id = input.required<number>();
  bgColor = input('cyan', { alias: 'backgroundColor' });
  text = input<string, string>('', {
    alias: 'transformedText',
    transform: (v) => `transformed ${v}!`,
  });

  nextId = computed(() => this.id() + 1);
}

В компоненте объявлен еще один входной сигнал с пустой строкой, которой присваивается псевдоним transformedText, а также transform-функция.

// main.ts

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [PokemonComponent, RouterOutlet, RouterLink, FormsModule],
  template: `
    <div>
      <app-pokemon [id]="25"  transformedText="red" />
      <app-pokemon [id]="52" backgroundColor="yellow" transformedText="green" />
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class App {}

Рассмотрим примеры посложнее.

Signal input + API in effect()

// get-pokemon.util.ts

import { HttpClient } from '@angular/common/http';
import { assertInInjectionContext, inject } from '@angular/core';
import { PokemonType } from '../types/pokemon.type';

export function getPokemonFn() {
    assertInInjectionContext(getPokemonFn);
    const httpClient = inject(HttpClient);
    const URL = `https://pokeapi.co/api/v2/pokemon`;

    return function (id: number) {
      return httpClient.get<PokemonType>(`${URL}/${id}/`)
    }
}
// pokemon.componen.ts

@Component({
  selector: 'app-pokemon',
  standalone: true,
  imports: [PokemonCardComponent],
  template: `
     ... omitted unrelated codes ...

     <div class="container">
       @if (pokemon(); as pokemon) {
           <app-pokemon-card [pokemon]="pokemon" />
        }

        @if (nextPokemon(); as pokemon) {
             <app-pokemon-card [pokemon]="pokemon" />
         }
    </div>
    <hr />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonComponent {
  id = input.required<number>(); 
  nextId = computed(() => this.id() + 1);
  getPokemon = getPokemonFn();

  pokemon = signal<PokemonType | undefined>(undefined);
  nextPokemon = signal<PokemonType | undefined>(undefined);

  constructor() {
    effect((onCleanup) => {
      const sub = this.getPokemon(this.id())
        .subscribe((pokemon) => this.pokemon.set(pokemon));
      const sub2 = this.getPokemon(this.nextId())
        .subscribe((pokemon) => this.nextPokemon.set(pokemon));

      onCleanup(() => {
        sub.unsubscribe();
        sub2.unsubscribe();
      });
    });
  }
}

В функции effect() обратный вызов вызывает функцию this.getPokemon для получения объекта Pokemon по входному сигналу id и вычисленному сигналу nextId соответственно. Когда подписывается первый Observable, объект Pokemon устанавливается в сигнал pokemon. Когда подписывается второй Observable, объект Pokemon устанавливается в сигнал nextPokemon. Кроме того, функция onCleanup отписывает подписки для предотвращения утечки памяти. В шаблоне кода новый контрольный поток проверяет, определены ли сигналы перед передачей данных в PokemonCardComponent для отрисовки.

Signal input + withComponentInputBinding

// app.config.ts

import { provideHttpClient } from '@angular/common/http';
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(),
    provideRouter(routes, withComponentInputBinding())
  ]
};

Функция provideRouter имеет функцию withComponentInputBinding(), поэтому данные роута преобразуются в сигналы ввода в компоненте.

// app.route.ts

import { Routes } from '@angular/router';

export const routes: Routes = [
    {
        path: 'pokemons/pidgeotto',
        loadComponent: () => import('./pokemons/pokemon/pokemon.component').then((m) => m.PokemonComponent),
        data: {
            id: 17,
            backgroundColor: 'magenta',
            transformedText: 'magenta',
        }
    },
];

В пути маршрута pokemons/pidgeotto я передаются данные маршрута: id, backgroundColor и tranformedText. Поскольку включена функция withComponentInputBinding(), данные маршрута автоматически преобразуются в сигналы ввода: id, bgColor и text.

// main.ts

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [PokemonComponent, RouterOutlet, RouterLink, FormsModule],
  template: `
    <div>
    <h2>Signal inputs with route data</h2>
    <ul>
         <li><a [routerLink]="['/pokemons/pidgeotto']">pidgeotto</a></li>
    </ul>
    <router-outlet />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class App {}

Signal input + Computed signals + Host property

// font-size.directive.ts

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

@Directive({
  selector: '[appFontSize]',
  standalone: true,
  host: {
    '[style.font-size.px]': 'size()',
    '[style.font-weight]': 'fontWeight()',
    '[style.font-style]': 'fontStyle()',
    '[style.color]': 'color()'
  },
})
export class FontSizeDirective {
  size = input(14);
  shouldDoStyling = computed(() => this.size() > 20 && this.size() <= 36);
  fontWeight = computed(() => this.shouldDoStyling() ? 'bold' : 'normal');
  fontStyle = computed(() => this.shouldDoStyling() ? 'italic' : 'normal');
  color = computed(() => this.shouldDoStyling() ? 'blue' : 'black');
}

Создана директива для изменения размера, насыщенности, стиля шрифта и цвета. Всякий раз, когда изменяется размер, shouldDoStyling определяет, должны ли применяться стили CSS.

// pokemon.component.ts

@Component({
  selector: 'app-pokemon',
  standalone: true,
  imports: [PokemonCardComponent],
  hostDirectives: [
    {
      directive: FontSizeDirective,
      inputs: ['size'],
    }
  ],
  template: `
    <p>Pokemon id: {{ id() }}, Next Pokemon id: {{ nextId() }}</p>
    <p [style.background]="bgColor()">
      Background color: {{ bgColor() }}
    </p>
    <p>Transformed: {{ text() }}</p>

     // omitted because the code is irrelevant
    <hr />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonComponent {
  id = input.required<number>();
  bgColor = input('cyan', { alias: 'backgroundColor' });
  text = input<string, string>('', {
    alias: 'transformedText',
    transform: (v) => `transformed ${v}!`,
  });

  nextId = computed(() => this.id() + 1);

  // omitted because the code is irrelevant
}

PokemonComponent регистрирует директиву FontSizeDirective как хост-директиву с входным параметром size. Стили CSS применяются к элементам абзаца, когда компонент App присваивает значение входному параметру size.

// main.ts

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [PokemonComponent, RouterOutlet, RouterLink, FormsModule],
  template: `
    <div>
      <label for="size">
        <span>Size: </span>
        <input type="number" id="size" name="size" [ngModel]="size()" (ngModelChange)="size.set($event)" min="8" />
      </label>
      <app-pokemon [id]="25" transformedText="red" [size]="size()" />
      <app-pokemon [id]="52" backgroundColor="yellow" transformedText="green" [size]="size()" />
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class App {
  size = signal(16);
}

Остался еще один пример, который касается взаимодействия сигнального ввода и RxJS.

// pokemon.component.ts

@Component({
  selector: 'app-pokemon',
  standalone: true,
  imports: [PokemonCardComponent, AsyncPipe],
  hostDirectives: [
    {
      directive: FontSizeDirective,
      inputs: ['size'],
    }
  ],
  template: `
    <p>Observable: {{ value$ | async }}</p>
    <hr />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonComponent {
  id = input.required<number>();
  bgColor = input('cyan', { alias: 'backgroundColor' });
  text = input<string, string>('', {
    alias: 'transformedText',
    transform: (v) => `transformed ${v}!`,
  });

  value$ = toObservable(this.bgColor)
    .pipe(
      combineLatestWith(toObservable(this.text)),
      map(([color, color2]) => `${color}|${color2}`),
      map((color) => `@@${color.toUpperCase()}@@`),
    );
}

value$ - это Observable, который объединяет toObservable(this.bgColor) и toObservable(this.text) для преобразования сигнальных входов в новый текст. Во встроенном шаблоне я использую асинхронную операцию для разрешения value$ и отображения его окончательного значения.

Примеры доступны по ссылке

Это перевод оригинального материала