Предисловие
Речь пойдёт про мощный инструмент, реализующий собой подход реактивного программирования и в разы упрощающий разработку — 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;
});
}
}
Подытожим:
Почти во всех случаях нам нужно отписываться (исключение: потоки, которые завершаются самостоятельно);
По возможности больше использовать async pipe, если данные отображаются в шаблоне;
Если не обойтись без подписки в компоненте, то используйте операторы
take*
;
На этом всё, весь код из статьи можно найти тут.
Комментарии (3)
limitofzero
25.05.2022 12:06+2Отличная статья. Я бы еще докинул Self декоратор при инжекте destroy$ сервиса, чтобы он случайно не стянулся с родительского компонента в случае, если разработчик забыл указать сервис в провайдерах компонента.
Zxdcm
Предложенный вариант с Destroy Subject Pattern можно сделать красивее.
Все тоже самое можно оборачивается в декоратор (внутри subject & complete в onDestroy) + кастомный пайп, который из переданного объекта достанет subject и добавит takeUntil(subject$) .
В итоге все сводится к декоратору на компоненте @UntilDestroy() + untilDestroyed(this) на подписках.
Если не хочется все это писать самому - есть npm пакет ngneat/until-destroyed
Luvolunov Автор
Согласен, можно сделать и так.
Тут больше вкусовщина :)