DevGang
Авторизоваться

Сделайте так, чтобы RxJS и Angular Signal сосуществовали в приложении Pokemon

Я написал простое приложение Pokemon на Angular 15 и RxJS для отображения URL-адресов изображений определенного покемона. Существует 2 способа обновить текущий идентификатор покемона, чтобы обновить URL-адреса изображений. Первый метод заключается в нажатии кнопок для увеличения или уменьшения идентификатора на дельту. Другой метод заключается в том, чтобы ввести значение для числового ввода, чтобы перезаписать текущий ID покемона. Однако поле ввода числа продолжает использовать операторы debounceTime, DifferentUntilChanged и filter RxJS для выполнения проверки и ограничения выдаваемых значений. Поэтому задача состоит в том, чтобы упростить реактивные коды и обеспечить сосуществование сигналов RxJS и Angular.

Старый компонент Pokemon с кодами RxJS

// pokemon.component.ts

...omitted import statements...

@Component({
  selector: 'app-pokemon',
  standalone: true,
  imports: [AsyncPipe, NgIf, FormsModule],
  template: `
    <h1>
      Display the first 100 pokemon images
    </h1>
    <div>
      <label>Pokemon Id:
        <span>{{ btnPokemonId$ | async }}</span>
      </label>
      <div class="container" *ngIf="images$ | async as images">
        <img [src]="images.frontUrl" />
        <img [src]="images.backUrl" />
      </div>
    </div>
    <div class="container">
      <button class="btn" #btnMinusTwo>-2</button>
      <button class="btn" #btnMinusOne>-1</button>
      <button class="btn" #btnAddOne>+1</button>
      <button class="btn" #btnAddTwo>+2</button>
      <form #f="ngForm" novalidate>
        <input type="number" [(ngModel)]="searchId" [ngModelOptions]="{ updateOn: 'blur' }" 
          name="searchId" id="searchId" />
      </form>
      <pre>
        searchId: {{ searchId }}
      </pre>
    </div>
  `,
  styles: [`...omitted due to brevity...`],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonComponent implements OnInit {
  @ViewChild('btnMinusTwo', { static: true, read: ElementRef })
  btnMinusTwo!: ElementRef<HTMLButtonElement>;

  @ViewChild('btnMinusOne', { static: true, read: ElementRef })
  btnMinusOne!: ElementRef<HTMLButtonElement>;

  @ViewChild('btnAddOne', { static: true, read: ElementRef })
  btnAddOne!: ElementRef<HTMLButtonElement>;

  @ViewChild('btnAddTwo', { static: true, read: ElementRef })
  btnAddTwo!: ElementRef<HTMLButtonElement>;

  @ViewChild('f', { static: true, read: NgForm })
  myForm: NgForm;

  btnPokemonId$!: Observable<number>;
  images$!: Observable<{ frontUrl: string, backUrl: string }>;

  searchId = 1;

  ngOnInit() {
    const btnMinusTwo$ = this.createButtonClickObservable(this.btnMinusTwo, -2);
    const btnMinusOne$ = this.createButtonClickObservable(this.btnMinusOne, -1);
    const btnAddOne$ = this.createButtonClickObservable(this.btnAddOne, 1);
    const btnAddTwo$ = this.createButtonClickObservable(this.btnAddTwo, 2);

    const inputId$ = this.myForm.form.valueChanges
      .pipe(
        debounceTime(300),
        distinctUntilChanged((prev, curr) => prev.searchId === curr.searchId),
        filter((form) => form.searchId >= 1 && form.searchId <= 100),
        map((form) => form.searchId),
        map((value) => ({
          value,
          action: POKEMON_ACTION.OVERWRITE,
        }))
      );

    this.btnPokemonId$ = merge(btnMinusTwo$, btnMinusOne$, btnAddOne$, btnAddTwo$, inputId$)
      .pipe(
        scan((acc, { value, action }) => { 
          if (action === POKEMON_ACTION.OVERWRITE) {
            return value;
          } else if (action === POKEMON_ACTION.ADD) {
            const potentialValue = acc + value;
            if (potentialValue >= 1 && potentialValue <= 100) {
              return potentialValue;
            } else if (potentialValue < 1) {
              return 1;
            }

            return 100;
          }

          return acc;
        }, 1),
        startWith(1),
        shareReplay(1),
      );

      const pokemonBaseUrl = 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon';
      this.images$ = this.btnPokemonId$.pipe(
        map((pokemonId: number) => ({
          frontUrl: `${pokemonBaseUrl}/shiny/${pokemonId}.png`,
          backUrl: `${pokemonBaseUrl}/back/shiny/${pokemonId}.png`
        }))
      );
  }

  createButtonClickObservable(ref: ElementRef<HTMLButtonElement>, value: number) {
    return fromEvent(ref.nativeElement, 'click').pipe(
      map(() => ({ value, action: POKEMON_ACTION.ADD }))
    );
  }
}

Мои задачи — сохранить большую часть логики $inputId, удалить все вхождения ViewChild и заменить остальные коды RxJS сигналами Angular. Я реорганизую коды RxJS в сигналы и перепишу $inputId Observable в последнюю очередь.

Во-первых, я создаю сигнал для хранения текущего ID покемона.

// pokemon-component.ts

currentPokemonId = signal(1);

Затем я изменяю встроенный шаблон, чтобы добавить событие click к элементам кнопки для обновления сигнала currentPokemonId.

Before (RxJS)

<div class="container">
   <button class="btn" #btnMinusTwo>-2</button>
   <button class="btn" #btnMinusOne>-1</button>
   <button class="btn" #btnAddOne>+1</button>
   <button class="btn" #btnAddTwo>+2</button>
</div>
After (Signal)

<div class="container">
   <button class="btn" (click)="updatePokemonId(-2)">-2</button>
   <button class="btn" (click)="updatePokemonId(-1)">-1</button>
   <button class="btn" (click)="updatePokemonId(1)">+1</button>
   <button class="btn" (click)="updatePokemonId(2)">+2</button>
 </div>

В сигнальной версии я удаляю переменные шаблона, чтобы компонент не требовал, чтобы ViewChild запрашивал HTMLButtonElement.

readonly min = 1;
readonly max = 100;

updatePokemonId(delta: number) {
    this.currentPokemonId.update((pokemonId) => {
      const newId = pokemonId + delta;
      return Math.min(Math.max(this.min, newId), this.max);
    });
}

При нажатии кнопки updatePokemonId устанавливает для currentPokemonId значение от 1 до 100.

Затем я дополнительно модифицирую встроенный шаблон, чтобы заменить images$ Observable на вычисленный сигнал imageUrls и btnPokemonId$ Observable на currentPokemonId.

Before (RxJS)

<div>
   <label>Pokemon Id:
      <span>{{ btnPokemonId$ | async }}</span>
   </label>
   <div class="container" *ngIf="images$ | async as images">
      <img [src]="images.frontUrl" />
      <img [src]="images.backUrl" />
   </div>
</div>
After (Signal)

<div>
   <label>Pokemon Id:
      <span>{{ currentPokemonId() }}</span>
   </label>
   <div class="container">
      <img [src]="imageUrls().front" />
      <img [src]="imageUrls().back" />
   </div>
</div>

В сигнальной версии я вызываю currentPokemonId() для отображения текущего идентификатора покемона. imageUrls — это вычисляемый сигнал, который возвращает передний и задний URL-адреса покемонов.

const pokemonBaseUrl = 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon';

imageUrls = computed(() => ({
    front: `${pokemonBaseUrl}/shiny/${this.currentPokemonId()}.png`,
    back: `${pokemonBaseUrl}/back/shiny/${this.currentPokemonId()}.png`
}));

После применения этих изменений встроенный шаблон не полагается на асинхронный канал и ngIf. Поэтому я могу удалить NgIf и AsyncPipe из массива импорта.

Сделать RxJS и Angular Signal сосуществующими в классе компонентов

Теперь мне нужно заняться $inputId Observable, чтобы он мог вызывать операторы RxJS и корректно обновлять сигнал currentPokemonId. Мое решение — вызвать подписку и обновить в ней currentPokemonId. Вызов подписки, к сожалению, создает подписку; поэтому я импортирую takeUntilDestroyed для завершения Observable в конструкторе.

Before (RxJS)

ngOnInit() {
    const inputId$ = this.myForm.form.valueChanges
       .pipe(
         debounceTime(300),
         distinctUntilChanged((prev, curr) => prev.searchId === curr.searchId),
         filter((form) => form.searchId >= 1 && form.searchId <= 100),
         map((form) => form.searchId),
         map((value) => ({
           value,
           action: POKEMON_ACTION.OVERWRITE,
         }))
       );
}
After (Signal)

<input type="number" 
        [ngModel]="searchIdSub.getValue()"
        (ngModelChange)="searchIdSub.next($event)"
        name="searchId" id="searchId" />

[(ngModel)] разбивается на [ngModel] и (ngModelChange), чтобы заставить мое решение работать. searchIdSub — это BehaviorSubject, который инициализируется значением 1. Входные данные NgModel ограничены searchIdSub.getValue(), и (ngModelChange) обновляет BehaviorSubject при изменении входного значения.

searchIdSub = new BehaviorSubject(1);

constructor() {
    this.searchIdSub
      .pipe(
        debounceTime(300),
        distinctUntilChanged(),
        filter((value) => value >= this.min && value <= this.max),
        takeUntilDestroyed(),
      ).subscribe((value) => this.currentPokemonId.set(value));
}

В конструкторе this.searchIdSub передает значения операторам RxJS и вызывает подписку для обновления сигнала currentPokemonId. В Angular 16 представлен метод takeUntilDestroyed, завершающий Observable; поэтому мне не нужно реализовывать интерфейс OnDestroy для отмены подписки вручную.

Новый компонент Pokemon, использующий сигналы Angular

// pokemon.component.ts

...omitted import statements due to brevity...

const pokemonBaseUrl = 'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon';

@Component({
  selector: 'app-pokemon',
  standalone: true,
  imports: [FormsModule],
  template: `
    <h2>
      Display the first 100 pokemon images
    </h2>
    <div>
      <label>Pokemon Id:
        <span>{{ currentPokemonId() }}</span>
      </label>
      <div class="container">
        <img [src]="imageUrls().front" />
        <img [src]="imageUrls().back" />
      </div>
    </div>
    <div class="container">
      <button class="btn" (click)="updatePokemonId(-2)">-2</button>
      <button class="btn" (click)="updatePokemonId(-1)">-1</button>
      <button class="btn" (click)="updatePokemonId(1)">+1</button>
      <button class="btn" (click)="updatePokemonId(2)">+2</button>
      <input type="number" 
        [ngModel]="searchIdSub.getValue()"
        (ngModelChange)="searchIdSub.next($event)"
        name="searchId" id="searchId" />
    </div>
  `,
  styles: [`...omitted due to brevity...`],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonComponent {
  readonly min = 1;
  readonly max = 100;

  searchIdSub = new BehaviorSubject(1);
  currentPokemonId = signal(1);

  imageUrls = computed(() => {
    const pokemonId = this.currentPokemonId();
    return {
      front: `${pokemonBaseUrl}/shiny/${pokemonId}.png`,
      back: `${pokemonBaseUrl}/back/shiny/${pokemonId}.png` 
    }
  });

  constructor() {
    this.searchIdSub
      .pipe(
        debounceTime(300),
        distinctUntilChanged(),
        filter((value) => value >= this.min && value <= this.max),
        takeUntilDestroyed(),
      ).subscribe((value) => this.currentPokemonId.set(value));
  }

  updatePokemonId(delta: number) {
    this.currentPokemonId.update((pokemonId) => 
      Math.min(Math.max(this.min, pokemonId + delta), this.max);
    )
  }
}

В новой версии RxJS-коды поля ввода числа перемещены в конструктор, удалены интерфейс OnInit и метод ngOnInit. Приложение упрощает реактивные коды; signal заменяет btnPokemonId$, images$ и их логику. Окончательные коды компонентов более лаконичны, чем в предыдущей версии.

Вот и все, и я переписал приложение Pokemon, чтобы коды RxJS и сигналы Angular сосуществовали. Его вы можете посмотреть по ссылке ниже:

https://stackblitz.com/edit/angular-zybm2c?embed=1&file=src%2Fpokemon%2Fpokemon%2Fpokemon.component.ts

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

Ресурсы:

  1. Github Repo: https://github.com/railsstudent/ng-pokemon-signal/tree/main/projects/pokemon-signal-demo-2
  2. Stackblitz: https://stackblitz.com/edit/angular-xhj2xb?file=src%2Fpokemon%2Fpokemon%2Fpokemon.component.ts
  3. PokeAPI: https://pokeapi.co/

Источник:

#Angular #RxJS
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу