
Обратная сторона подписки на Observable
У 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.
1. Использование метода unsubscribe
Любой 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$ в момент уничтожения компонента.
2. Использование Async | Pipe
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.
3. Использование операторов RxJS take*
RxJS содержит полезные операторы, которые можно использовать декларативным способом, чтобы отменять подписки в нашем Angular-проекте. Один из них — операторы семейства *take**:
- take(n)
- takeUntil(notifier)
- takeWhile(predicate)
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(); } }
4. Использование оператора RxJS first
Этот оператор похож на объединенный 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.
5. Использование Декоратора для автоматизации отписки
Все мы люди, нам свойственно забывать. Большинство предыдущих способов опираются на хук 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 без подписки.
6. Использование tslint
Иногда может быть полезным сообщение от 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. А какой использовать в конкретной ситуации — решать вам.
ссылка на оригинал статьи https://habr.com/ru/post/484762/
Добавить комментарий