6 способов отписаться от Observables в Angular
- среда, 22 января 2020 г. в 00:22:06
У Observables есть метод subscribe, который вызывается с помощью callback-функции, чтобы получить значения, отправляемые (emit) в Observable. В Angular он используется в компонентах/директивах, а особенно в router-модуле, NgRx и HTTP.
Если мы подпишемся на поток, он останется открытым и будет вызываться всякий раз, когда в него передаются значения из любой части приложения, до тех пор, пока он не будет закрыт с помощью вызова unsubscribe.
@Component({...})
export class AppComponent implements OnInit {
subscription: Subscription
ngOnInit () {
const observable = Rx.Observable.interval(1000);
this.subscription = observable.subscribe(x => console.log(x));
}
}
В данной реализации мы используем интервал для отправки значений каждую секунду. Мы подписываемся на него, чтобы получить отправленное значение, а наша callback-функция пишет результат в консоль браузера.
Теперь, если AppComponent будет уничтожен, например, после выхода из компонента или с помощью метода destroy(), мы все равно увидим лог консоли в браузере. Это связано с тем, что хотя AppComponent был уничтожен, подписка не была отменена.
Если подписка не закрыта, callback-функция будет непрерывно вызываться, что приведет к серьёзной утечке памяти и проблемам с производительностью. Для того, чтобы избежать утечек необходимо каждый раз «отписываться» от Observable.
Любой Subscription имеет функцию unsubscribe() для освобождения ресурсов и отмены исполнения Observable. Чтобы предотвратить утечку памяти необходимо отменить подписки с помощью метода unsubscribe в Observable.
В Angular нужно отписаться от Observable, когда компонент уничтожается. К счастью, в Angular есть хук ngOnDestroy, который вызывается перед уничтожением компонента, что позволяет разработчикам обеспечить очистку памяти, избежать зависания подписок, открытых портов и прочих «выстрелов в ногу».
@Component({…})
export class AppComponent implements OnInit, OnDestroy {
subscription: Subscription
ngOnInit () {
const observable = Rx.Observable.interval(1000);
this.subscription = observable.subscribe(x => console.log(x));
}
ngOnDestroy() {
this.subscription.unsubscribe()
}
}
Мы добавили ngOnDestroy в наш AppComponent и вызвали метод unsubscribe на Observable this.subscription. Когда AppComponent будет уничтожен (с помощью перехода по ссылке, метода destroy() и т. д.), подписка не будет зависать, интервал будет остановлен, а в браузере больше не будет логов консоли.
А что если у нас есть несколько подписок?
@Component({…})
export class AppComponent implements OnInit, OnDestroy {
subscription1$: Subscription;
subscription2$: Subscription;
ngOnInit () {
const observable1$ = Rx.Observable.interval(1000);
const observable2$ = Rx.Observable.interval(400);
this.subscription1$ = observable.subscribe(x => console.log("From interval 1000" x));
this.subscription2$ = observable.subscribe(x => console.log("From interval 400" x));
}
ngOnDestroy() {
this.subscription1$.unsubscribe();
this.subscription2$.unsubscribe();
}
}
В AppComponent две подписки и обе отписались в хуке ngOnDestroy, предотвращая утечку памяти.
Так же можно собрать все подписки в массив и отписаться от них в цикле:
@Component({…})
export class AppComponent implements OnInit, OnDestroy {
subscription1$: Subscription;
subscription2$: Subscription;
subscriptions: Subscription[] = [];
ngOnInit () {
const observable1$ = Rx.Observable.interval(1000);
const observable2$ = Rx.Observable.interval(400);
this.subscription1$ = observable.subscribe(x => console.log("From interval 1000" x));
this.subscription2$ = observable.subscribe(x => console.log("From interval 400" x));
this.subscriptions.push(this.subscription1$);
this.subscriptions.push(this.subscription2$);
}
ngOnDestroy() {
this.subscriptions.forEach((subscription) => subscription.unsubscribe());
}
}
Метод subscribe возвращает объект RxJS типа Subscription. Он представляет собой одноразовый ресурс. Подписки могут быть сгруппированы с помощью метода add, который прикрепит дочернюю подписку к текущей. Когда подписка отменяется, все ее дочерние элементы также отписываются. Попробуем переписать наш AppComponent:
@Component({…})
export class AppComponent implements OnInit, OnDestroy {
subscription: Subscription;
ngOnInit () {
const observable1$ = Rx.Observable.interval(1000);
const observable2$ = Rx.Observable.interval(400);
const subscription1$ = observable.subscribe(x => console.log("From interval 1000" x));
const subscription2$ = observable.subscribe(x => console.log("From interval 400" x));
this.subscription.add(subscription1$);
this.subscription.add(subscription2$);
}
ngOnDestroy() {
this.subscription.unsubscribe()
}
}
Так мы отпишем this.subscripton1$ и this.subscripton2$ в момент уничтожения компонента.
Pipe async подписывается на Observable или Promise и возвращает последнее переданное значение. Когда новое значение отправляется, pipe async чекает данный компонент на отслеживание изменений. Если компонент уничтожается, pipe async автоматически отписывается.
@Component({
...,
template: `
<div>
Interval: {{observable$ | async}}
</div>
`
})
export class AppComponent implements OnInit {
observable$;
ngOnInit () {
this.observable$ = Rx.Observable.interval(1000);
}
}
При инициализации AppComponent создаст Observable из метода интервала. В шаблоне observable$ передается async. Он подпишется на observable$ и отобразит его значение в DOM. Так же он отменит подписку, когда AppComponent будет уничтожен. Pipe async в своем классе содержит хук ngOnDestroy, поэтому тот вызовется, когда его view будет уничтожен.
Pipe async очень удобно использовать, потому что он сам будет подписываться на Observable и отписываться от них. И мы можем теперь не беспокоиться если забудем отписаться в ngOnDestroy.
RxJS содержит полезные операторы, которые можно использовать декларативным способом, чтобы отменять подписки в нашем Angular-проекте. Один из них — операторы семейства *take**:
take(n)
Этот оператор emit-ит исходную подписку указанное количество раз и завершается. Чаще всего в take передается единица (1) для подписки и выхода.
Данный оператор полезно использовать, если мы хотим, чтобы Observable передал значение один раз, а затем отписался от потока:
@Component({...})
export class AppComponent implements OnInit {
subscription$;
ngOnInit () {
const observable$ = Rx.Observable.interval(1000);
this.subscription$ = observable$.pipe(take(1)).
subscribe(x => console.log(x));
}
}
subscription$ отменит подписку, когда интервал передаст первое значение.
Обратите внимание: даже если AppComponent будет уничтожен, subscription$ не отменит подписку, пока интервал не передаст значение. Поэтому все равно лучше убедиться, что все отписано в хуке ngOnDestroy:
@Component({…})
export class AppComponent implements OnInit, OnDestroy {
subscription$;
ngOnInit () {
var observable$ = Rx.Observable.interval(1000);
this.subscription$ = observable$.pipe(take(1)).subscribe(x => console.log(x));
}
ngOnDestroy() {
this.subscription$.unsubscribe();
}
}
takeUntil(notifier)
Этот оператор emit-ит значения из исходного Observable, до тех пор, пока notifier не отправит сообщение о завершении.
@Component({…})
export class AppComponent implements OnInit, OnDestroy {
notifier = new Subject();
ngOnInit () {
const observable$ = Rx.Observable.interval(1000);
observable$.pipe(takeUntil(this.notifier)).subscribe(x => console.log(x));
}
ngOnDestroy() {
this.notifier.next();
this.notifier.complete();
}
}
У нас есть дополнительный Subject для уведомлений, который отправит команду, чтобы отписать this.subscription. Мы pipe-им Observable в takeUntil до тех пор, пока мы подписаны. TakeUntil будет emit-ить сообщения интервала, пока notifier не отменит подписку observable$. Удобнее всего помещать notifier в хук ngOnDestroy.
takeWhile(predicate)
Этот оператор будет emit-ить значения Observable, пока они соответствуют условию предиката.
@Component({...})
export class AppComponent implements OnInit {
ngOnInit () {
const observable$ = Rx.Observable.interval(1000);
observable$.pipe(takeWhile(value => value < 10)).subscribe(x => console.log(x));
}
}
Мы pip-им observable$ с оператором takeWhile, который будет отправлять значения до тех пор, пока они меньше 10. Если придет значение большее или равное 10, оператор отменит подписку.
Важно понимать, что подписка observable$ будет открыта, пока интервал не выдаст 10. Поэтому для безопасности мы добавляем хук ngOnDestroy, чтобы отписаться от observable$, когда компонент уничтожен.
@Component({…})
export class AppComponent implements OnInit, OnDestroy {
subscription$;
ngOnInit () {
var observable$ = Rx.Observable.interval(1000);
this.subscription$ = observable$.pipe(takeWhile(value => value < 10)).subscribe(x => console.log(x));
}
ngOnDestroy() {
this.subscription$.unsubscribe();
}
}
Этот оператор похож на объединенный take(1) и takeWhile.
Если он вызывается без параметра, то emit-ит первое значение Observable и завершается. Если он вызывается с функцией предиката, то emit-ит первое значение исходного Observable, которое соответствует условию функции предиката, и завершается.
@Component({...})
export class AppComponent implements OnInit {
observable$;
ngOnInit () {
this.observable = Rx.Observable.interval(1000);
this.observable$.pipe(first()).subscribe(x => console.log(x));
}
}
observable$ завершится, если интервал передаст свое первое значение. Это означает, что в консоли мы увидим только 1 сообщение лога.
@Component({...})
export class AppComponent implements OnInit {
observable$;
ngOnInit () {
this.observable$ = Rx.Observable.interval(1000);
this.observable$.pipe(first(val => val === 10)).subscribe(x => console.log(x));
}
}
Здесь first не будет emit-ить значения, пока интервал не передаст 10-ку, а затем завершит observable$. В консоли увидим только одно сообщение.
В первом примере, если AppComponent уничтожен до того, как first получит значение из observable$, подписка будет по-прежнему открыта до получения первого сообщения.
Так же, во втором примере, если AppComponent уничтожен до того, как интервал отдаст подходящее под условие оператора значение, подписка будет по-прежнему открыта до тех пор, пока интервал не отдаст 10. Поэтому, чтобы обеспечить безопасность, мы должны явно отменять подписки в хуке ngOnDestroy.
Все мы люди, нам свойственно забывать. Большинство предыдущих способов опираются на хук ngOnDestroy, чтобы удостовериться в очищении подписки перед уничтожением компонента. Но мы можем забыть прописать их в ngOnDestroy, — может быть из-за дедлайна, или нервного клиента, который знает, где вы живете…
В этом случае мы можем использовать Декораторы в наших Angular-проектах, чтобы автоматически отписаться от всех подписок в компоненте.
Вот пример такой полезной реализации:
function AutoUnsub() {
return function(constructor) {
const orig = constructor.prototype.ngOnDestroy;
constructor.prototype.ngOnDestroy = function() {
for(let prop in this) {
const property = this[prop];
if(typeof property.subscribe === "function") {
property.unsubscribe();
}
}
orig.apply();
}
}
}
Этот AutoUnsub является декоратором, который можно применять к классам в нашем Angular-проекте. Как видите, он сохраняет оригинальный хук ngOnDestroy, затем создает новый и подключает его к классу, к которому тот применяется. Таким образом, когда класс уничтожается, вызывается новый хук. Его функция просматривает свойства класса, и если находит Observable, то отписывается от него. Затем он вызывает оригинальный хук ngOnDestroy в классе, если тот имеется.
@Component({...})
@AutoUnsub
export class AppComponent implements OnInit {
observable$;
ngOnInit () {
this.observable$ = Rx.Observable.interval(1000);
this.observable$.subscribe(x => console.log(x))
}
}
Мы применяем его к нашему AppComponent и больше не беспокоимся о том, что забыли отписаться от observable$ в ngOnDestroy, — декоратор сделает это за нас.
Но у этого способа есть и обратная сторона — возникнут ошибки если в нашем компоненте будет Observable без подписки.
Иногда может быть полезным сообщение от tslint, чтобы сообщить в консоли, что у наших компонентов или директив не объявлен метод ngOnDestroy. Можно добавить пользовательское правило в tslint, чтобы предупреждать в консоли в момент выполнения lint или build, что в наших компонентах нет хука ngOnDestroy:
// ngOnDestroyRule.tsimport * as Lint from "tslint"
import * as ts from "typescript";
import * as tsutils from "tsutils";
export class Rule extends Lint.Rules.AbstractRule {
public static metadata: Lint.IRuleMetadata = {
ruleName: "ng-on-destroy",
description: "Enforces ngOnDestory hook on component/directive/pipe classes",
optionsDescription: "Not configurable.",
options: null,
type: "style",
typescriptOnly: false
}
public static FAILURE_STRING = "Class name must have the ngOnDestroy hook";
public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] {
return this.applyWithWalker(new NgOnDestroyWalker(sourceFile, Rule.metadata.ruleName, void this.getOptions()))
}
}
class NgOnDestroyWalker extends Lint.AbstractWalker {
visitClassDeclaration(node: ts.ClassDeclaration) {
this.validateMethods(node);
}
validateMethods(node: ts.ClassDeclaration) {
const methodNames = node.members.filter(ts.isMethodDeclaration).map(m => m.name!.getText());
const ngOnDestroyArr = methodNames.filter( methodName => methodName === "ngOnDestroy");
if( ngOnDestroyArr.length === 0)
this.addFailureAtNode(node.name, Rule.FAILURE_STRING);
}
}
Если у нас есть такой компонент без ngOnDestroy:
@Component({...})
export class AppComponent implements OnInit {
observable$;
ngOnInit () {
this.observable$ = Rx.Observable.interval(1000);
this.observable$.subscribe(x => console.log(x));
}
}
Lint-инг AppComponent-а предупредит нас о пропущенном хуке ngOnDestroy:
$ ng lint
Error at app.component.ts 12: Class name must have the ngOnDestroy hook
Повисшая или открытая подписка могут привести к утечкам памяти, ошибкам, нежелательному поведению или снижению производительности приложений. Чтобы этого избежать, мы рассмотрели разные способы отписки от Observable в проектах Angular. А какой использовать в конкретной ситуации — решать вам.