Руководство, которое помогает разрабатывать приложения и библиотеки Angular в режиме реактивного программирования с помощью RxJS.

Несмотря на отсутствие строгого правила, сочетание императивного и декларативного программирования с RxJS может усложнить разработку, а код - менее чистым.

Для перехода от (часто) стандартного способа программирования (то есть императивного) к реактивному (то есть декларативному) подходу нужно время.

Однако есть одно правило, которое может помочь вам сделать это:

⚠️ Не подписывайтесь, точка ⚠️

Не знаете о разнице между императивным и декларативным? Взгляните на отличную статью Josh Morony, в которой сравнивается и то, и другое.

Почему?

Правило не строгое, это ориентир, как кодекс пиратов Карибского моря 🏴‍☠️.

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

Конкретно, при разработке функций в компонентах, попытка использовать в основном Angular | async канал, который автоматически отменяет подписку при уничтожении компонентов, может, помимо предотвращения утечки памяти, помочь улучшить стиль кодирования.

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

Отправная точка

В следующей демонстрации используется CoinPaprika API для отображения пользователю списка криптовалют.

Исходный код доступен на GitHub. Каждые последующие главы (шаги 1, 2, 3 и 4) являются отдельными ветвями.

Он откладывает HTTP-запросы до coins.service и представляет результаты в coins.component.

Услуга

Провайдер действует как магазин. Он запрашивает список криптовалют, фильтрует результаты и сохраняет их в памяти.

Функция list() является и реактивной, сообщая, что она хочет (httpClient.get), и императивной, проверяя и фильтруя результаты.

import { Injectable, OnDestroy } from '@angular/core';
import { HttpClient } from '@angular/common/http';

import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

export type Coin = Record<string, string | number | boolean>;

@Injectable({
  providedIn: 'root'
})
export class CoinsService implements OnDestroy {
  constructor(private httpClient: HttpClient) {}

  private coins: Coin[] = [];

  private destroy$: Subject<void> = new Subject();

  list() {
    this.httpClient
      .get<Coin[]>(`https://api.coinpaprika.com/v1/coins`)
      .pipe(takeUntil(this.destroy$))
      .subscribe((allCoins: Coin[]) => {
        if (allCoins.length > 10) {
          this.coins = allCoins.filter(
            (coin: Coin) =>
              !coin.is_new && coin.rank > 0 && coin.rank < 100
          );
        }
      });
  }

  getCoins(): Coin[] {
    return this.coins;
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

Составная часть

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

import { Component, OnInit } from '@angular/core';
import { CoinsService } from '../coins.service';

@Component({
  selector: 'app-coins',
  templateUrl: './coins.component.html',
  styleUrls: ['./coins.component.css']
})
export class CoinsComponent implements OnInit {
  constructor(private readonly coinsService: CoinsService) {}

  ngOnInit(): void {
    this.coinsService.list();
  }

  get coins() {
    return this.coinsService.getCoins();
  }
}

Шаблон

Список монет в HTML.

<article *ngFor="let coin of coins">
  <h1>{{ coin.name }}</h1>
  <p>Symbol: {{ coin.symbol }}</p>
  <p>Rank: {{ coin.rank }}</p>
  <hr />
</article>

Шаг 1: (Подробнее) Декларативный

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

Поскольку мы не хотим подписываться, мы должны сначала преобразовать метод, вызываемый компонентом, чтобы он возвращал Observable.

list(): Observable<Coin[]> {
  return this.httpClient
    .get<Coin[]>(`https://api.coinpaprika.com/v1/coins`)
    ...
}

Без каких-либо других изменений компилятор предупредит вас о возвращаемых значениях, которые не совпадают (поскольку мы все еще подписываемся на поток и, следовательно, фактически возвращаем Subscription). Вот почему мы заменяем subscribe оператором RxJS. В конкретном случае мы используем тап, потому что мы все еще хотим присвоить результат магазину.

list(): Observable<Coin[]> {
  return this.httpClient
    .get<Coin[]>(`https://api.coinpaprika.com/v1/coins`)
    .pipe(
      tap((allCoins: Coin[]) => {
        if (allCoins.length > 10) {
          this.coins = allCoins.filter(
            (coin: Coin) =>
              !coin.is_new && coin.rank > 0 && coin.rank < 100
          );
        }
      }),
      takeUntil(this.destroy$))
}

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

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

export type Coin = Record<string, string | number | boolean>;

@Injectable({
  providedIn: 'root'
})
export class CoinsService {
  constructor(private httpClient: HttpClient) {}

  private coins: Coin[] = [];

  list(): Observable<Coin[]> {
    return this.httpClient
      .get<Coin[]>(`https://api.coinpaprika.com/v1/coins`)
      .pipe(
        tap((allCoins: Coin[]) => {
          if (allCoins.length > 10) {
            this.coins = allCoins.filter(
              (coin: Coin) =>
                !coin.is_new && coin.rank > 0 && coin.rank < 100
            );
          }
        })
      );
  }

  getCoins(): Coin[] {
    return this.coins;
  }
}

Код уже стал чище, больше нет подписки и уничтожения жизненного цикла, но код по-прежнему смешивает разные подходы. Вот почему мы пользуемся преимуществами операторов filter и map RxJS, чтобы сделать его более реактивным.

list(): Observable<Coin[]> {
  return this.httpClient
    .get<Coin[]>(`https://api.coinpaprika.com/v1/coins`)
    .pipe(
      filter((allCoins: Coin[]) => allCoins.length > 10),
      map((allCoins: Coin[]) =>
        allCoins.filter(
          (coin: Coin) =>
            !coin.is_new && coin.rank > 0 && coin.rank < 100
        )
      ),
      tap((topCoins: Coin[]) => (this.coins = topCoins))
    );
}

Императив if стал реактивным filter, а array.filter был перемещен на map трансформатор. Благодаря этим последним изменениям источники данных проходят через поток, описывающий то, что мы хотим получить в качестве результатов.

Шаг 2. Подпишитесь на компонент

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

Поскольку мы действуем итеративно, мы в основном воспроизводим то, что мы удалили в службе ранее, мы подписываемся внутри компонента.

import { Component, OnDestroy, OnInit } from '@angular/core';

import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { CoinsService } from '../coins.service';
@Component({
  selector: 'app-coins',
  templateUrl: './coins.component.html',
  styleUrls: ['./coins.component.css']
})
export class CoinsComponent implements OnInit, OnDestroy {
  constructor(private readonly coinsService: CoinsService) {}

  private destroy$: Subject<void> = new Subject<void>();

  ngOnInit(): void {
    this.coinsService
      .list()
      .pipe(takeUntil(this.destroy$))
      .subscribe(() => {});
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }

  get coins() {
    return this.coinsService.getCoins();
  }
}

Я знаю, я сказал «никогда не подписывайтесь», это еще не конец 😉. Тем не менее, мы замечаем, что криптовалюта снова в списке.

Шаг 3: асинхронный канал

Чтобы достичь нашей конечной цели, мы хотим удалить подписку в компоненте, чтобы использовать канал | async. Следовательно, мы должны улучшать наш сервис. С другой стороны, мы по-прежнему хотим, чтобы он работал как магазин.

Вот почему в качестве промежуточного шага мы заменяем императивное состояние coins службы на BehaviorSubject, особый тип Observable, который позволяет многоадресно передавать значения многим Observers (source) и публично раскрывает его потоки как readonly Observable переменная.

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

import { BehaviorSubject, Observable } from 'rxjs';
import { filter, map, tap } from 'rxjs/operators';

export type Coin = Record<string, string | number | boolean>;

@Injectable({
  providedIn: 'root'
})
export class CoinsService {
  constructor(private httpClient: HttpClient) {}

  private coins: BehaviorSubject<Coin[]> = new BehaviorSubject<
    Coin[]
  >([]);

  readonly coins$: Observable<Coin[]> = this.coins.asObservable();

  list(): Observable<Coin[]> {
    return this.httpClient
      .get<Coin[]>(`https://api.coinpaprika.com/v1/coins`)
      .pipe(
        filter((allCoins: Coin[]) => allCoins.length > 10),
        map((allCoins: Coin[]) =>
          allCoins.filter(
            (coin: Coin) =>
              !coin.is_new && coin.rank > 0 && coin.rank < 100
          )
        ),
        tap((topCoins: Coin[]) => this.coins.next(topCoins))
      );
  }
}

По сравнению с нашими предыдущими изменениями это ломается. Вот почему мы должны адаптировать компонент, чтобы удалить getter и заменить его наблюдаемым, которое мы в конечном итоге сможем использовать в шаблоне.

import { Component, OnDestroy, OnInit } from '@angular/core';

import { Observable, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

import { Coin, CoinsService } from '../coins.service';

@Component({
  selector: 'app-coins',
  templateUrl: './coins.component.html',
  styleUrls: ['./coins.component.css']
})
export class CoinsComponent implements OnInit, OnDestroy {
  constructor(private readonly coinsService: CoinsService) {}

  private destroy$: Subject<void> = new Subject<void>();

  coins$: Observable<Coin[]> = this.coinsService.coins$;

  ngOnInit(): void {
    this.coinsService
      .list()
      .pipe(takeUntil(this.destroy$))
      .subscribe(() => {});
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

Наконец, мы представляем знаменитую трубку async.

<article *ngFor="let coin of coins$ | async">

Шаг 4: не подписывайтесь и не реагируйте

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

Вот почему мы стараемся убрать тему.

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';

export type Coin = Record<string, string | number | boolean>;

@Injectable({
  providedIn: 'root'
})
export class CoinsService {
  constructor(private httpClient: HttpClient) {}

  readonly coins$: Observable<Coin[]> = ... // <- TODO

  list(): Observable<Coin[]> {
    return this.httpClient
      .get<Coin[]>(`https://api.coinpaprika.com/v1/coins`)
      .pipe(
        filter((allCoins: Coin[]) => allCoins.length > 10),
        map((allCoins: Coin[]) =>
          allCoins.filter(
            (coin: Coin) =>
              !coin.is_new && coin.rank > 0 && coin.rank < 100
          )
        )
      );
  }
}

Мы замечаем, что у открытой наблюдаемой coins$ теперь нет источника.

С другой стороны, у нас все еще есть поток, который обрабатывает поток данных, как мы, за исключением.

Да, верно, мы соединяем и то, и другое.

readonly coins$: Observable<Coin[]> = this.httpClient
  .get<Coin[]>(`https://api.coinpaprika.com/v1/coins`)
  .pipe(
    filter((allCoins: Coin[]) => allCoins.length > 10),
    map((allCoins: Coin[]) =>
      allCoins.filter(
        (coin: Coin) =>
          !coin.is_new && coin.rank > 0 && coin.rank < 100
      )
    )
  );

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

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

import { Observable } from 'rxjs';
import {filter, map, shareReplay} from 'rxjs/operators';

export type Coin = Record<string, string | number | boolean>;

@Injectable({
  providedIn: 'root'
})
export class CoinsService {
  constructor(private httpClient: HttpClient) {}

  readonly coins$: Observable<Coin[]> = this.httpClient
    .get<Coin[]>(`https://api.coinpaprika.com/v1/coins`)
    .pipe(
      filter((allCoins: Coin[]) => allCoins.length > 10),
      map((allCoins: Coin[]) =>
        allCoins.filter(
          (coin: Coin) =>
            !coin.is_new && coin.rank > 0 && coin.rank < 100
        )
      ),
      shareReplay({ bufferSize: 1, refCount: true })
    );
}

Если вы никогда раньше не использовали shareReplay, будьте осторожны при его использовании. Подробнее читайте в блоге Kwinten Pisman.

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

import { Component } from '@angular/core';

import { Observable } from 'rxjs';

import { Coin, CoinsService } from '../coins.service';

@Component({
  selector: 'app-coins',
  templateUrl: './coins.component.html',
  styleUrls: ['./coins.component.css']
})
export class CoinsComponent {
  constructor(private readonly coinsService: CoinsService) {}

  readonly coins$: Observable<Coin[]> = this.coinsService.coins$;
}

Если сравнивать с исходной версией, разве компонент не стал действительно тонким и легким для понимания?

Последняя проверка графического интерфейса.

Все криптовалюты по-прежнему перечислены, код является реактивным, и мы больше не используем «подписку» 🥳.

Резюме

Попытка отказаться от подписки с использованием RxJS в Angular не является окончательным и строгим правилом, но при применении в качестве руководства может помочь сделать код более чистым и реактивным, может помочь улучшить опыт и время в RxJS.

Бесконечность не предел!

Дэйвид

Вы можете связаться со мной в Твиттере или на моем сайте.

Попробуйте DeckDeckGo для ваших следующих презентаций.