javascript

RxJS Interop в Angular 18: основные изменения и преимущества

  • суббота, 19 октября 2024 г. в 00:00:04
https://habr.com/ru/articles/851516/

С выпуском Angular 18 команда команда разработчиков значительно расширила функциональность RxJS Interop, что упрощает интеграцию между Signals и RxJS Observables, оптимизируя производительность и улучшая читаемость кода. В этой статье мы рассмотрим новые возможности RxJS Interop, примеры их применения и объясним, как они помогают сделать ваш код чище и эффективнее.

Эволюция RxJS Interop в Angular

RxJS Interop впервые был представлен в Angular 16 для преодоления разрыва между Signals и RxJS Observables. Первая версия позволяла разработчикам конвертировать Signals в Observables и наоборот. В Angular 17 функциональность была улучшена, чтобы сделать преобразования более эффективными и обеспечить лучшую интеграцию операторов RxJS с Signals.

С выпуском Angular 18 функциональность RxJS Interop значительно расширилась, предлагая улучшенную поддержку операторов, более гибкие настройки и дополнительные возможности, что делает управление реактивным состоянием ещё более удобным.

Что такое RxJS Interop?

RxJS Interop позволяет разработчикам Angular легко комбинировать и конвертировать Signals и Observables. Традиционно Angular активно использовал RxJS для обработки асинхронных операций, таких как HTTP-запросы или пользовательские события. С появлением Signals Angular предлагает ещё один способ управления реактивным состоянием, который помогает упростить реактивные паттерны.

Основные функции RxJS Interop

RxJS Interop позволяет:

  • Конвертировать Signals в Observables и наоборот.

  • Использовать операторы RxJS, такие как map, filter и merge, с Signals.

  • Упрощать работу с реактивными данными.

  • Использовать более гибкие настройки конверсии и управления состоянием.

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

Краткий обзор RxJS в Angular

RxJS лежит в основе реактивной системы Angular, предоставляя тип Observable для управления асинхронными данными. Он используется во многих частях Angular, таких как HttpClient, формы и обработка событий.

  • Observable: поток данных, который генерирует множество значений с течением времени.

  • Subject: Observable, который рассылает значения нескольким наблюдателям.

  • BehaviorSubject: хранит последнее значение и немедленно отправляет его новым подписчикам.

  • ReplaySubject: повторяет буфер предыдущих значений для новых подписчиков.

Эти паттерны мощные, но могут быть сложными при управлении состоянием или обработке нескольких асинхронных операций. Signals в сочетании с RxJS Interop помогают упростить такие сценарии.

RxJS Interop в Angular 18

Signals vs Observables: когда использовать что?

С Angular 18 разработчики получают больше гибкости в выборе между Signals и Observables:

  • Observables идеальны для непрерывных событийных данных, таких как HTTP-запросы или события форм.

  • Signals лучше подходят для реактивного состояния с предсказуемыми потоками данных.

RxJS Interop делает интеграцию между этими двумя системами бесшовной, повышая возможность повторного использования кода.

Основные функции RxJS Interop

Пример 1: Конвертация Signals в Observables

В этом примере мы создаем Signal и преобразуем его в Observable для использования в шаблоне:

<div>
  <button (click)="incrementSignal()">Increment Signal</button>
  <p>Observable Value: {{ myObservable$ | async }}</p>
  <p>Signal Value: {{ mySignal() }}</p>
  <p>Transformed Signal Value {{ squaredSignal() }}</p>
</div>
import { Component, computed, signal, WritableSignal } from '@angular/core';
import { Observable } from 'rxjs';
import { toObservable } from '@angular/core/rxjs-interop';

@Component({
  selector: 'app-first-example',
  templateUrl: './first-example.component.html',
})
export class FirstExampleComponent {
  mySignal: WritableSignal<number> = signal(0);
  squaredSignal = computed(() => this.myDataTransformFn(this.mySignal()));
  myObservable$: Observable<number>;

  constructor() {
    this.myObservable$ = toObservable(this.mySignal);
  }

  incrementSignal() {
    this.mySignal.set(this.mySignal() + 1);
  }

  myDataTransformFn(value: number): number {
    // You can implement any data transformation logic here
    return value ** 2;
  }
}

Этот пример демонстрирует несколько аспектов, таких как:

  1. Конвертация Signal в Observable и их использование в шаблоне;

  2. Формирование нового значения из Signal при помощи функции computed.

Хотелось бы отметить что Signal содержит в себе встроенный функционал кеширования (меморизации), что благотворно сказывается производительности вашего Angular приложения.

Пример 2: Использование Signals с дочерним компонентом

Этот пример демонстрирует передачу значений от дочернего компонента к родительскому с использованием Signals и Observables:

Родительский компонент:

<div>
  <p>Check Child Observable Value In Parent: {{ myChildOutputtedSignal() }}</p>
  <app-second-example-child></app-second-example-child>
</div>
import { AfterViewInit, Component, signal, ViewChild } from '@angular/core';
import { SecondExampleChildComponent } from './second-example-child/second-example-child.component';
import { outputToObservable } from '@angular/core/rxjs-interop';
import { Observable } from 'rxjs';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';

@UntilDestroy()
@Component({
  selector: 'app-second-example',
  templateUrl: './second-example.component.html',
})
export class SecondExampleComponent implements AfterViewInit {
  @ViewChild(SecondExampleChildComponent) private childComponent: SecondExampleChildComponent | undefined;
  myChildOutputtedSignal = signal(0);
  myChildOutputtedObservable$: Observable<number> | undefined;

  ngAfterViewInit() {
    // No need to unsubscribe from this subscription on Destroy
    this.childComponent!.myObservableChange.subscribe((value: number) => {
      // Save the value as you wish: BehaviourSubject, Signal, etc.
      this.myChildOutputtedSignal.set(value);
    });

    // Better to unsubscribe from this subscription on Destroy Because the Observable is created in the constructor
    this.myChildOutputtedObservable$ = outputToObservable(this.childComponent!.myObservableChange).pipe(
      untilDestroyed(this),
    );
  }
}

Дочерний компонент:

<div>
  <p>Child Observable Value: {{ myObservableSubject$ | async }}</p>
  <p>Child Signal From Observable Value: {{ mySignalFromObservable() }}</p>
  <br>
  <button (click)="incrementValue()">Increment</button>
</div>
import { Component, OutputRef } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { outputFromObservable, toSignal } from '@angular/core/rxjs-interop';

@Component({
  selector: 'app-second-example-child',
  templateUrl: './second-example-child.component.html',
})
export class SecondExampleChildComponent {
  myObservableSubject$ = new BehaviorSubject<number>(0);
  myObservableChange: OutputRef<number> = outputFromObservable(this.myObservableSubject$);
  // Some Observables are guaranteed to emit synchronously, such as BehaviorSubject.
  // In those cases, you can specify the requireSync: true option
  mySignalFromObservable = toSignal(this.myObservableSubject$, { requireSync: true });

  incrementValue() {
    this.myObservableSubject$.next(this.myObservableSubject$.value + 1);
  }
}

Этот пример иллюстрирует работу таких функций:

  1. Применение outputFromObservable - позволяет передавать потоки данных наружу в виде Observable.

  2. Вызов toSignal - конвертирует любой Observable / Subject в Signal.

  3. Добавление функции outputToObservable дает возможность подписаться на Output свойство дочернего компонента

Пример 3: Переход от старого подхода к новому

В этом примере показано, как переход от использования Observables к Signals может улучшить производительность и упростить код:

Родительский компонент:

<div>
  <p>Old Approach Value: {{ oldApproachValue }}</p>
  <p>New Approach Value: {{ newApproachValue }}</p>
  <br>
  <app-third-example-child
    (incrementOldApproach)="incrementDefault()"
  ></app-third-example-child>
</div>
import { AfterViewInit, Component, ViewChild } from '@angular/core';
import { ThirdExampleChildComponent } from './third-example-child/third-example-child.component';

@Component({
  selector: 'app-third-example',
  templateUrl: './third-example.component.html',
})
export class ThirdExampleComponent implements AfterViewInit {
  @ViewChild(ThirdExampleChildComponent) childComponent: ThirdExampleChildComponent | undefined;
  oldApproachValue = 0;
  newApproachValue = 0;

  ngAfterViewInit() {
    // No need to unsubscribe from this subscription on Destroy
    this.childComponent?.incrementNewApproach.subscribe(() => {
      this.newApproachValue++;
    });
  }

  incrementDefault() {
    this.oldApproachValue++;
  }
}

Дочерний компонент:

<div>
  <button (click)="increment()">increment</button>
</div>
import { Component, EventEmitter, output, Output } from '@angular/core';

@Component({
  selector: 'app-third-example-child',
  templateUrl: './third-example-child.component.html',
})
export class ThirdExampleChildComponent {
  @Output() incrementOldApproach = new EventEmitter<void>();
  // The output() function provides numerous benefits over decorator-based @Output and EventEmitter:
  incrementNewApproach = output<void>();

  increment() {
    this.incrementOldApproach.emit();
    this.incrementNewApproach.emit();
  }
}

Функция output() обеспечивает более легкую читаемость кода и повышает производительность по сравнению с Output и EventEmitter на основе декораторов.

Пример 4: Сигналы с Firestore

В последнем примере мы используем Signals для управления данными, поступающими из Firestore:

Шаблон компонента:

<div>
  <h2>Items From Signal</h2>
  <ul>
    <li *ngFor="let item of dataSignal()">{{ item.name }}</li>
  </ul>
  <h2>Items From Observable</h2>
  <ul>
    <li *ngFor="let item of (data$ | async)">{{ item.name }}</li>
  </ul>
</div>

Код компонента:

import { Component, inject, Signal } from '@angular/core';
import { Observable, shareReplay } from 'rxjs';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { toSignal } from '@angular/core/rxjs-interop';
import { collection, collectionData, Firestore } from '@angular/fire/firestore';

interface Item {
  id: number;
  name: string;
};

@UntilDestroy()
@Component({
  selector: 'app-fourth-example',
  templateUrl: './fourth-example.component.html'
})
export class FourthExampleComponent {
  dataSignal: Signal<Item[] | undefined>;
  data$: Observable<Item[]>;
  db: Firestore = inject(Firestore);

  constructor() {
    const itemCollection = collection(this.db, 'items');

    // Подход 1
    this.data$ = collectionData(itemCollection).pipe(
      shareReplay(1),
      untilDestroyed(this),
    );

    // Подход 2
    this.dataSignal = toSignal(collectionData(itemCollection));
  }
}

Этот пример демонстрирует, как можно использовать Signals и Observables для работы с данными, поступающими из Firestore, что позволяет выбирать подход в зависимости от потребностей приложения.

Подход 1: Подписка на Observable и сохранение его значения в локальной привносит необходимость отписываться от подписки в ngOnDestroy и кешировать значение, что бы избежать проблем с производительностью.

Подход 2: Вместо подписки на Observable, мы можем установить значение Signal напрямую. Сигналы автоматически освобождают ресурсы, когда компонент уничтожается, что устраняет риск утечек памяти,который может возникать при работе с Observable, если вы забыли отписаться от подписки.

Таким образом Signals могут быть полезны для управления локальным состоянием компонента, когда данные приходят из внешнего источника, например Firestore.

Заключение

Новый RxJS Interop в Angular 18 предоставляет мощный инструмент для интеграции Signals и Observables, упрощая управление реактивным состоянием. Использование Signals позволяет сократить сложность асинхронного кода и улучшить производительность приложения, делая его более предсказуемым и легко поддерживаемым.

Angular 18 открывает новые возможности для управления состоянием, и теперь разработчики могут использовать лучший подход для каждого конкретного сценария, комбинируя мощь Observables и простоту Signals. Это делает процесс разработки более удобным и сосредотачивает усилия на создании качественного пользовательского опыта.

Более того Сигналы легче интегрируются с различными частями приложения и позволяют избежать проблем, связанных с подписками (unsubscribe) на Observable и также могут быть полезны для управления локальным состоянием компонента, когда данные приходят из внешнего источника, например Firestore.

Вы можете ознакомиться с работой кода из этой статьи тут.