Предисловие

Речь пойдёт про мощный инструмент, реализующий собой подход реактивного программирования и в разы упрощающий разработку — RxJS. В частности разберём один момент, про который нужно не забывать при использовании этой библиотеки, а именно — отписки.

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

Кейс — отправка запросов на бэк и показ данных в реальном времени

Задача

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

Решение

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

Функция будет нам отдавать через определённый промежуток времени курс, который будет сгенерирован рандомайзером:

// fake-currency.function.ts

import { interval } from 'rxjs';
import { map } from 'rxjs/operators';

// Простой рандомайзер, который отдаёт случайное число в определённом диапазоне
function getRandomByLimits(min: number, max: number) {
  return Math.round(Math.random() * (max - min) + min);
}

// Функция, отдающая фейковый курс
export function fakeCurrency(time: number) {
  const minLimit = 65;
  const maxLimit = 78;
  return interval(time).pipe(map(() => getRandomByLimits(minLimit, maxLimit)));
}

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

// my-component.component.ts

import { Component } from '@angular/core';
import { fakeCurrency } from '../fake-currency.function';

@Component({
  selector: 'my-component',
  templateUrl: './my-component.component.html',
  styleUrls: ['./my-component.component.css'],
})
export class MemoryLeakComponent {
  // свойство, которое мы будем использовать в шаблоне
  num: number;

  constructor() {
    fakeCurrency(500).subscribe((num) => {
      this.num = num;
    });
  }
}

Шаблон:

<!-- my-component.component.html -->
<p>Random exchange rate: {{ num }}</p>

Поздравляю!!! ????????????
У нас получилось вывести рандомный курс валюты на страницу!!!

Также поздравляю с получением первой утечки памяти!)

О какой утечке памяти идёт речь?

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

// my-component.component.ts

import { Component } from '@angular/core';
import { fakeCurrency } from '../fake-currency.function';

@Component({
  selector: 'my-component',
  templateUrl: './my-component.component.html',
  styleUrls: ['./my-component.component.css'],
})
export class MemoryLeakComponent {
  // свойство, которое мы будем использовать в шаблоне
  num: number;

  constructor() {
    // Случайный id
    const id = Math.floor(Math.random() * 100000);
    fakeCurrency(500).subscribe((num) => {
      console.log(`ID: ${id}`, num);
      this.num = num;
    });
  }
}

Как мы видим — отображения курса на странице нет, но в консоль продолжают сыпаться логи. Если юзер продолжительное время будет переключаться между страницами, то такая утечка может вызвать дикие тормоза на его компьютере.

Как же нам избежать этого?

Конечно же отписаться!)

И способов отписки существует несколько.

Async Pipe

В данном случае этот способ наиболее предпочтительный. Немножко изменим наш код:

// my-component.component.ts

import { Component } from '@angular/core';
import { fakeCurrency } from '../fake-currency.function';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

@Component({
  selector: 'my-component',
  templateUrl: './my-component.component.html',
  styleUrls: ['./my-component.component.css'],
})
export class MemoryLeakComponent {
  // свойство, которое мы будем использовать в шаблоне
  num$: Observable<number>;

  constructor() {
    // Случайный id
    const id = Math.floor(Math.random() * 100000);
    this.num$ = fakeCurrency(500)
      .pipe(
        tap((num) => console.log(`ID: ${id}`, num))
      );
  }
}

Не забываем про наш шаблон:

<!-- my-component.component.html -->
<p>Random exchange rate: {{ num$ | async }}</p>

Destroy Subject Pattern

Если же нам значение в шаблоне не нужно, но при этом подписаться всё-таки нужно, то можно воспользоваться паттерном Destroy Subject и оператором отписки takeUntil:

// my-component.component.ts

import { Component } from '@angular/core';
import { fakeCurrency } from '../fake-currency.function';
import { takeUntil } from 'rxjs/operators';

@Component({
  selector: 'my-component',
  templateUrl: './my-component.component.html',
  styleUrls: ['./my-component.component.css'],
})
export class MemoryLeakComponent implements OnDestroy {
  // свойство, которое мы будем использовать в шаблоне
  num: number;
  destroy$ = new Subject();

  constructor() {
    // Случайный id
    const id = Math.floor(Math.random() * 100000);
    fakeCurrency(500)
      // Прокидываем оператор в поток и передаём ему другой поток
      .pipe(takeUntil(this.destroy$))
      .subscribe((num) => {
        console.log(`ID: ${id}`, num);
        this.num = num;
      });
  }
  
  ngOnDestroy() {
    // завершаем поток
    // когда переданный поток завершается, то оператор takeUntil отписывается от текущего потока
    this.destroy$.next();
    this.destroy$.complete();
  }
}

Может есть решение красивее?

Конечно, есть!)

Не забываем, что Subject сущности — те же классы, от которых мы можем наследоваться. (Нагло беру пример из библиотеки taiga-ui)

Создаём сервис, который будет наследоваться от ReplaySubject и имплементировать OnDestroy интерфейс:

// destroy.service.ts
import { Injectable, OnDestroy } from '@angular/core';
import { ReplaySubject } from 'rxjs';

@Injectable()
export class DestroyService extends ReplaySubject<void> implements OnDestroy {
  constructor() {
    super();
  }

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

Именно в компонент нужно обязательно запровайдить DestroyService, с провайдингом в модуль такое не прокатит:

// my-component.component.ts

import { Component, Inject } from '@angular/core';
import { fakeCurrency } from '../fake-currency.function';
import { takeUntil } from 'rxjs/operators';
import { DestroyService } from '../destroy.service';

@Component({
  selector: 'my-component',
  templateUrl: './my-component.component.html',
  styleUrls: ['./my-component.component.css'],
  // Провайдим сервис
  providers: [DestroyService]
})
export class MemoryLeakComponent implements OnDestroy {
  // свойство, которое мы будем использовать в шаблоне
  num: number;

  constructor(@Inject(DestroyService) private destroy$: Observable<void>) {
    // Случайный id
    const id = Math.floor(Math.random() * 100000);
    fakeCurrency(500)
      // Прокидываем оператор в поток и передаём ему параметром другой поток
      .pipe(takeUntil(destroy$))
      .subscribe((num) => {
        console.log(`ID: ${id}`, num);
        this.num = num;
      });
  }
}

Подытожим:

  1. Почти во всех случаях нам нужно отписываться (исключение: потоки, которые завершаются самостоятельно);

  2. По возможности больше использовать async pipe, если данные отображаются в шаблоне;

  3. Если не обойтись без подписки в компоненте, то используйте операторы take*;

На этом всё, весь код из статьи можно найти тут.

Комментарии (3)


  1. Zxdcm
    24.05.2022 17:15
    +1

    Предложенный вариант с Destroy Subject Pattern можно сделать красивее.
    Все тоже самое можно оборачивается в декоратор (внутри subject & complete в onDestroy) + кастомный пайп, который из переданного объекта достанет subject и добавит takeUntil(subject$) .

    В итоге все сводится к декоратору на компоненте @UntilDestroy() + untilDestroyed(this) на подписках.

    Если не хочется все это писать самому - есть npm пакет ngneat/until-destroyed


    1. Luvolunov Автор
      24.05.2022 17:23
      +2

      Согласен, можно сделать и так.
      Тут больше вкусовщина :)


  1. limitofzero
    25.05.2022 12:06
    +2

    Отличная статья. Я бы еще докинул Self декоратор при инжекте destroy$ сервиса, чтобы он случайно не стянулся с родительского компонента в случае, если разработчик забыл указать сервис в провайдерах компонента.