DevCezz

Programistyczny blog dla Ciebie

Sposoby odsubskrybowania subskrypcji w Angular
Angular

Sposoby odsubskrybowania subskrypcji w Angular

Skoro w ostatnim wpisie zastanawialiśmy się czy warto odsubskrybować wszystkie subskrypcje to teraz wypadałoby się dowiedzieć w jaki sposób można tego dokonać. Po wstępnej analizie wpisów znalezionych w Internecie muszę przyznać, że możliwości jest sporo. Zweryfikujmy zatem, którym sposobom warto się przyjrzeć, a które niekoniecznie są godne polecenia.

Kilka sposobów zaprzestania subskrypcji

Klasyczna metoda unsubscribe

Nic nie może przecież wiecznie trwać!„. Tak głosi klasyk, który nie raz słyszeliśmy w radiu. Przenosząc ten cytat do świata Angulara wychodzi na to, że każda subskrypcja powinna mieć swój koniec. Tam, gdzie wykorzystamy subscribe musimy też użyć unsubscribe, bo w innym przypadku mogą dziać się różne dziwne rzeczy spowalniające naszą aplikację.

@Component({
  selector: 'app-simple-component',
  templateUrl: './simple-component.html',
  styleUrls: ['./simple-component.scss']
})
export class SimpleComponent implements OnInit, OnDestroy {

  private subscription?: Subscription;

  constructor(private dataService: DataService) {}

  ngOnInit() {
    this.subscription = this.dataService.getData$().subscribe(...);
  }

  ngOnDestroy() {
    this.subscription?.unsubscribe();
  }
}

Wywołanie metody subscribe powoduje zwrócenie obiektu typu Subscription, który powinniśmy przypisać do pola zarządzanego przez nasz komponent, aby mieć później do niego dostęp. W pewnym momencie działania aplikacji może dojść do sytuacji, gdy dany komponent będzie podlegał zniszczeniu, bo np. użytkownik przejdzie do innego widoku. Wtedy tuż przed jego unicestwieniem chcemy zakończyć rozpoczętą wcześniej subskrypcję. Wykorzystujemy do tego lifecycle hook o nazwie ngOnDestroy, w którym wywołujemy unsubscribe na danym polu. I to tyle, nic więcej.

Więcej subskrypcji, więcej pracy?

Natomiast co w przypadku, gdy nasz komponent potrzebuje danych aż z 3 źródeł Observable? Wtedy będziemy mieć do czynienia ze ścianą powtarzalnego kodu.

@Component({
  selector: 'app-simple-component',
  templateUrl: './simple-component.html',
  styleUrls: ['./simple-component.scss']
})
export class SimpleComponent implements OnInit, OnDestroy {

  private subscription1?: Subscription;
  private subscription2?: Subscription;
  private subscription3?: Subscription;

  constructor(private dataService: DataService) {}

  ngOnInit() {
    this.subscription1 = this.dataService.getData1$().subscribe(...);
    this.subscription2 = this.dataService.getData2$().subscribe(...);
    this.subscription3 = this.dataService.getData3$().subscribe(...);
  }

  ngOnDestroy() {
    this.subscription1?.unsubscribe();
    this.subscription2?.unsubscribe();
    this.subscription3?.unsubscribe();
  }
}

Nie wygląda to za ciekawie, ale na szczęście można temu zaradzić. Pierwszym z pomysłów jest dodawanie kolejnych obiektów Subscription do tablicy i potem w ngOnDestroy w pętli zakończyć każdą z nich.

@Component({
  selector: 'app-simple-component',
  templateUrl: './simple-component.html',
  styleUrls: ['./simple-component.scss']
})
export class SimpleComponent implements OnInit, OnDestroy {

  private subscriptions: Subscription[] = [];

  constructor(private dataService: DataService) {}

  ngOnInit() {
    this.subscriptions.push(this.dataService.getData1$().subscribe(...));
    this.subscriptions.push(this.dataService.getData2$().subscribe(...));
    this.subscriptions.push(this.dataService.getData3$().subscribe(...));
  }

  ngOnDestroy() {
    this.subscriptions.forEach(subscription => subscription.unsubscribe());
  }
}

Ten zapis wygląda już dużo lepiej, ale dalej wydaje się dosyć skomplikowany. Może lepiej będzie skorzystać ze zdolności dodawania do siebie subskrypcji? Sprawdźmy jak kod będzie wyglądał w tej sytuacji.

@Component({
  selector: 'app-simple-component',
  templateUrl: './simple-component.html',
  styleUrls: ['./simple-component.scss']
})
export class SimpleComponent implements OnInit, OnDestroy {

  private subscriptions: Subscription = [];

  constructor(private dataService: DataService) {}

  ngOnInit() {
    this.subscriptions.add(this.dataService.getData1$().subscribe(...));
    this.subscriptions.add(this.dataService.getData2$().subscribe(...));
    this.subscriptions.add(this.dataService.getData3$().subscribe(...));
  }

  ngOnDestroy() {
    this.subscriptions.unsubscribe();
  }
}

Jest to rozwiązanie bardzo podobne do poprzedniego. Całość sprowadza się do wywołania tylko raz unsubscribe w ngOnDestroy na agregującym obiekcie subskrypcji. Wygląda to znacznie prościej, ale na tym nie poprzestaniemy. Spróbujemy za chwilę znaleźć jeszcze bardziej kompaktowy zapis. Tymczasem sprawdźmy inny sposób na pracę z subskrypcjami.

Wykorzystanie Async Pipe

Pipes są naprawdę bardzo przydatną rzeczą udostępnioną w Angularze. Nie dość, że mamy możliwość tworzenia samemu nowych rozwiązań to w domyślnej bibliotece jest ich już naprawdę sporo. Jedną z nich jest Async Pipe, która znacznie upraszcza odsubskrybowanie strumienia danych Observable.

@Component({
  selector: 'app-simple-component',
  template: '
    <div>{{ data$ | async }}</div>
  ',
  styleUrls: ['./simple-component.scss']
})
export class SimpleComponent implements OnInit {

  private data$?: Observable<any>;

  constructor(private dataService: DataService) {}

  ngOnInit() {
    this.data$ = this.dataService.getData$();
  }
}

W ten sposób Angular za nas automatycznie zasubskrybował otrzymywane dane. Nie musieliśmy używać nigdzie metody subscribe, a co najważniejsze nie ma też konieczności użycia unsubscribe. Wszystko dzieje się za naszymi plecami. Gdy komponent SimpleComponent zostanie ubity to nasza subskrypcja także zakończy swój żywot. Brzmi to świetnie, ale jest niestety druga strona medalu. Nie możemy poddać żadnej obróbce otrzymanych danych. Brak użycia jakiegokolwiek pipe naprawdę doskwiera. Jak zawsze, coś za coś.

Operatory take* czy first

Kolejnym podejściem na liście są metody take, first, takeWhile czy takeUntil. Jednak w tym miejscu trzeba zaznaczyć wprost – 3 pierwsze rozwiązania dalej wymagają stosowania unsubscribe. One tylko mylnie chronią nas przed wyciekiem pamięci zapewniając nas, że po określonym warunku przestaną istnieć.

  • take(number) – określone wywołanie wykona się n razy
  • first(predicate) albo first() – wywoła się raz dla określonego warunku (jeśli zostanie podany)
  • takeWhile(predicate) – przetwarzanie będzie wykonywało się dopóki dany warunek będzie cały czas spełniony

Czyli tak jak w przypadku HttpClient to i tutaj należy mieć się na baczności. Lepiej dmuchać na zimne i ręcznie odsubskrybować zawczasu Subscription. Inaczej jednak ma się sytuacja w przypadku takeUntil. Jako argument przekazujemy do niej obiekt typu Observable i dopóki on nie wyemituje żadnych danych to inne subskrypcje będą dalej żywe (o ile wykorzystają u siebie takeUntil).

@Component({
  selector: 'app-simple-component',
  templateUrl: './simple-component.html',
  styleUrls: ['./simple-component.scss']
})
export class SimpleComponent implements OnInit, OnDestroy {

  destroy$: Subject<any> = new Subject<>();

  constructor(private dataService: DataService) {}

  ngOnInit() {
    this.dataService.getData1$().pipe(takeUntil(this.destroy$)).subscribe(...));
    this.dataService.getData2$().pipe(takeUntil(this.destroy$)).subscribe(...));
    this.dataService.getData3$().pipe(takeUntil(this.destroy$)).subscribe(...));
  }

  ngOnDestroy() {
    this.destroy$.next(null);
    this.destroy$.unsubscribe();
  }
}

Oczywiście w tej sytuacji musimy pamiętać o tym, aby wyemitować jakąś wartość dla destroy$ w metodzie ngOnDestroy i oczywiście, a i jakże, go odsubskrybować.

Własne rozwiązanie

Idąc dalej, przeszukując portal Medium natrafimy na użytkownika o pseudonimie Reactive Fox, który przedstawił rozwiązanie problemu postawionego przed tym artykułem w postaci stworzenia dodatkowego serwisu. Już na samym początku trzeba zaznaczyć, że do jego realizacji niezbędny jest kontener Dependency Injection. Wyglądałoby to mniej więcej tak, że dany serwis rozszerzałby klasę Subject oraz implementował interfejs OnDestroy. W ciele metody ngOnDestroy emitowana byłaby dowolna wartość (w tym przypadku null) i Observable kończyłby działanie.

@Injectable()
export class NgOnDestroy extends Subject<null> implements OnDestroy {
  ngOnDestroy() {
    this.next(null);
    this.complete();
  }
}

Następnie taki serwis trzeba by dostarczyć do wybranego komponentu i dla każdej rozpoczętej subskrypcji użyć go we wcześniej poznanej metodzie takeUntil. W tym przypadku nie ma potrzeby implementowania ngOnDestroy.

@Component({
  selector: '...',
  template: '...',
  providers: [ NgOnDestroy ]
})
class SimpleComponent implements OnInit {

  constructor(@Self() private destroy: NgOnDestroy,
              private dataService: DataService) {}

  ngOnInit() {
    request.pipe(takeUntil(this.destroy)).subscribe();
    request.pipe(takeUntil(this.destroy)).subscribe();
    request.pipe(takeUntil(this.destroy)).subscribe();
  }
}

Stworzenie reguły pilnującej budowanie kodu

Szukając kolejnych sposobów w Internecie znajdziemy blog Chidume Nnamdi, który proponuje nam utworzenie reguły tslint. Jej zadaniem będzie wychwycenie braku metody ngOnDestroy. Początkowo może wydawać się to dobre rozwiązanie, aby to automat pilnował nas, żebyśmy ją zaimplementowali. Jeśli otrzymamy błąd to zapali nam się lampa, że zapomnieliśmy odsubskrybować danej subskrypcji. Jednak niektóre komponenty mogą nie mieć takiej potrzeby i tylko niepotrzebnie dodawalibyśmy puste metody. Mogłaby też nastać sytuacja, w której to kopiujemy jeden z komponentów mający metodę ngOnDestroy bez ciała i wklejamy ją jako nowy komponent, który już wykorzystuje gdzieś subscribe. Wtedy tslint nas nie uratuje, bo niezbędna metoda już jest, a my i tak zapomnieliśmy w niej wywołać unsubscribe.

import * 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);
    }
}

Zewnętrzne rozwiązania

Oczywiście jak zawsze możemy liczyć na społeczność programistyczną i poszukać rozwiązania wśród osób, które lubią się dzielić swoją pracą. W ten oto sposób natrafimy na dwie adnotacje: @AutoUnsubscribe oraz @UntilDestroy. Przyjrzymy się po krótce każdej z nich.

@AutoUnsubscribe

Pierwsza z nich to pomysł Netanel Basal, który znajdziemy na GitHub. Polega on na tym, że każda subskrypcja jaką zawrzemy w postaci pola komponentu zostanie za nas automatycznie usunięta, gdy dany komponent przestanie istnieć. Brzmi fajnie, ale musimy znowu pamiętać o tym, aby faktycznie stworzyć takie pola i je przypisać oraz zaimplementować pustą metodę ngOnDestroy. Jeśli tego nie zrobimy to narzędzie samo z siebie rzuci nam błąd. Wychodzi na to, że w połączeniu z tslint możemy uzyskać dobrą siatkę bezpieczeństwa dla naszych subskrypcji.

import { AutoUnsubscribe } from "ngx-auto-unsubscribe";

@AutoUnsubscribe()
@Component({
  selector: 'inbox'
})
export class InboxComponent {
  one: Subscription;
  two: Subscription;

  constructor( private store: Store<any>, private element : ElementRef ) {}

  ngOnInit() {
    this.one = store.select("data").subscribe(data => // do something);
    this.two = Observable.interval.subscribe(data => // do something);
  }

  // This method must be present, even if empty.
  ngOnDestroy() {
    // We'll throw an error if it doesn't
  }
}

Do adnotacji @AutoUnsubscribe mamy możliwość przekazania kilka parametrów ułatwiających pracę:

  • arrayName – odsubskrybowanie tylko subskrypcji ze wskazanej tablicy
  • blackList – tablica wykluczeń subskrypcji, które chcemy pozostawić aktywne
  • event – nazwa metody do wywołania na zakończenie istnienia komponentu

Należy mieć na uwadze, że gdy podamy jednocześnie blackList oraz arrayName to ten pierwszy parametr zostanie zignorowany.

@UntilDestroy

W przypadku tego dekoratora mamy również możliwość ustawienia kilku dostępnych opcji. Są one praktycznie identyczne co dla @AutoUnsubscribe, czyli arrayName, blackList oraz checkProperties (ustawiając ją na true wszystkie pola z subskrypcją zostaną odsubskrybowane). Co rzuca się w oczy to brak metody ngOnDestroy, nie ma potrzeby jej dodawania. Twórcy postawili na pragmatyczność i wyręczyli nas z konieczności pamiętania o jednym z niezbędnych kroków.

@UntilDestroy({ checkProperties: true })
@Component({})
export class HomeComponent {
  // We'll dispose it on destroy
  subscription = fromEvent(document, 'mousemove').subscribe();
}

Podsumowanie

Jak widzisz istnieje wiele sposobów kończenia w prawidłowy sposób dostępnych subskrypcji. Ciężko powiedzieć, który jest najlepszy. Każdy ma swoje wady i zalety, które przemawiają za lub przeciw jego korzystaniu. Pewnie wszystko zależy od dyscypliny oraz zaufania do zewnętrznych rozwiązań. A Ty z którego sposobu korzystasz w swoich projektach?

Źródła:

Podziel się tym z innymi!