Pull to refresh

6 способов отписаться от Observables в Angular

JavaScriptAngular
Translation
Original author: Chidume Nnamdi


Обратная сторона подписки на 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. А какой использовать в конкретной ситуации — решать вам.

Tags:angularjavascriptobservablerxjs
Hubs: JavaScript Angular
Total votes 3: ↑2 and ↓1 +1
Views7.9K

Popular right now

Angular разработчик
from 100,000 to 160,000 ₽KotelovСанкт-ПетербургRemote job
Senior Angular Engineer (Remote)
from 3,500 to 4,000 €Jimmy TechnologiesRemote job
Fullstack JavaScript developer
from 5,500 to 6,500 $BrightdataRemote job

Top of the last 24 hours