Сделайте так, чтобы 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 сосуществовали. Его вы можете посмотреть по ссылке ниже:
На этом запись в блоге заканчивается, и я надеюсь, что вам понравится содержание и вы продолжите следить за моим опытом изучения Angular и других технологий.