«Destroy, відписка»: патерни відписок в Angular і новий спосіб, аби робити це якнайкраще

Усі статті, обговорення, новини про Front-end — в одному місці. Підписуйтеся на телеграм-канал!

Всім привіт! Мене звати Ярослав Ларін, Senior Web Developer та тренер в Luxoft. Загалом я займаюсь front-end, спеціалізуюсь переважно на Javascript, Typescript та Angular. Упродовж своєї кар’єри встиг попрацювати над стартапами, у дуже й не дуже великих комерційних проєктах, open-source та над розробкою додатків для VS Code. Тепер ділюся досвідом з іншими як тренер.

У зв’язку з тим, що Angular зараз розвивається дуже швидко, серед нових фіч можна легко пропустити щось насправді важливе. Тож сьогодні поговоримо про патерни відписок.

Чому і коли ми повинні відписуватися від Observable

Коли ми працюємо з RxJS, правильне забезпечення відписки має вирішальне значення для запобігання витоку пам’яті в застосунку. Проблема полягає в тому, що коли компонент руйнується, наявні підписки в ньому залишаються і все ще спрацьовують (навіть якщо компонент уже не існує). Підписки зберігають посилання на об’єкти і таким способом не дають «збирачу сміття» Javascript звільнити пам’ять, що й призводить до витоків пам’яті.

Деколи Angular здатний самостійно автоматично відписуватися, наприклад, якщо ми використовуємо AsyncPipe. Але це не завжди можливо. Як правило, нам необхідно відписуватися вручну, коли ми:

  • маємо справу з будь-яким Observable з довгим (або нескінченним) строком життя (так звані long-lived Observables, які не завершуються самі собою);
  • маємо потребу отримати доступ до значення в самому компоненті, тобто ми не можемо використати AsyncPipe (в іншому разі — так і робіть).

Деякі приклади:

  1. Observables, які створюються на основі дій користувача (і таким чином є безтерміновими), наприклад, на основі click-подій за допомогою оператора fromEvent.
  2. Форми — наприклад, треба відписуватися від valueChanges.
  3. Subjects (у більшості випадків). Їх зазвичай використовують для стейт-менеджменту. Він також містить NgRx store, наприклад.
  4. Деякі оператори RxJS, які продукують нескінченний потік значень. У прикладах ми будемо використовувати оператор interval.
  5. Observables, які належать до API маршрутизатора, наприклад router.events.

Отже, проблема була, але довгий час не існувало загальноприйнятого методу її вирішення (способи, які використовувалися, розглянемо далі). І ось, нарешті, в Angular 16 з’явилася довгоочікувана функція — «офіційний» та зручний метод відписки від Observables, який, безумовно, поступово замінить усі інші. Тим не менш, ви все ще часто натраплятимете на всі ці методи в старому коді, тому корисно знати, як вони працюють.

Далі розглянемо кілька поширених підходів:

subscriptions array pattern

Ми додаємо всі підписки в масив, а потім, коли компонент руйнується, відписуємося від них у циклі forEach:
import { Component, OnDestroy } from '@angular/core';
import { Subscription, interval } from 'rxjs';


@Component({
  standalone: true,
  selector: 'app-subscriptions-array-example',
  template: `
    <p>subscriptions-array example works!</p>
  `,
})
export class SubscriptionsArrayComponent implements OnDestroy {
  private subscriptions: Subscription[] = [];


  ngOnInit() {
    this.subscriptions.push(
      interval(1000).subscribe((value) => {
        console.log(value);
      })
    );
  }


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

subscriptions composition pattern

Дуже схожий на попередній, але використовує можливість «компонувати» підписки за допомогою методу subscription.add(). Таким способом, відписка від батьківської підписки автоматично спричинить відписку від усіх підписок, які було додано до неї:

import { Component, OnDestroy } from '@angular/core';
import { Subscription, interval } from 'rxjs';


@Component({
  standalone: true,
  selector: 'app-subscriptions-composition-example',
  template: `<p>subscriptions-composition example works!</p>`,
})
export class SubscriptionsCompositionComponent implements OnDestroy {
  private subscription = new Subscription();


  ngOnInit() {
    this.subscription.add(
      interval(1000).subscribe((value) => {
        console.log(value);
      })
    );
  }


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

takeUntil pattern

Нам потрібен спеціальний subject destroy$ і оператор takeUntil. Знову ж таки, треба імплементувати OnDestroy.

Оператор takeUntil виконує відписку, коли subject destroy$ видає значення (а це відбувається в хуку ngOnDestroy, тобто коли компонент знищується). Ми повинні переконатися, що takeUntil є останнім оператором у ланцюгу, інакше стає можливим витік пам’яті.

Ми також повинні подбати про те, щоб завершити сам subject destroy$.

import { Component, OnDestroy, OnInit } from '@angular/core';
import { Subject, interval, takeUntil } from 'rxjs';


@Component({
  standalone: true,
  selector: 'app-take-until-example',
  template: `<p>take-until-example works!</p>`,
})
export class TakeUntilComponent implements OnInit, OnDestroy {
  private destroy$ = new Subject<void>();


  ngOnInit() {
    interval(1000)
      .pipe(takeUntil(this.destroy$))
      .subscribe((value) => {
        console.log(value);
      });
  }


  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

ngneat/until-destroy package

Обидва попередні підходи є багатослівними і ускладнюють логіку компонентів. Саме тому npm-пакет ngneat/until-destroy набув популярності. Він додає синтаксичний цукор до шаблону untilDestroy за допомогою декоратора:

import { Component, OnInit } from '@angular/core';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { fromEvent, interval } from 'rxjs';


@UntilDestroy()
@Component({
  standalone: true,
  selector: 'app-until-destroy-example',
  template: `
    <p>until-destroy-example works!</p>
  `,
})
export class UntilDestroyComponent implements OnInit {
  ngOnInit() {
    interval(1000)
      .pipe(untilDestroyed(this))
      .subscribe((value) => {
        console.log(value);
      });
  }
}

Якщо передати декоратору @UntilDestroy опцію { checkProperties: true }, то він автоматично перевірить поля класу, коли компонент буде знищено, і відпишеться від усіх підписок, які зберігаються як поля класу:

@UntilDestroy({ checkProperties: true })
@Component({
  standalone: true,
  selector: 'app-auto-until-destroy-example',
  template: `
    <p>auto-until-destroy-example works!</p>
  `,
})
export class AutoUntilDestroyComponent implements OnInit {
  subscription = fromEvent(document, 'mousemove').subscribe();


  ngOnInit() {
    this.subscription.add(
      interval(1000)
        .pipe(untilDestroyed(this))
        .subscribe((value) => {
          console.log(value);
        })
    );
  }
}

Цей підхід може бути скомбінований з патерном subscriptions composition.

takeUntilDestroyed operator

А тепер — наш переможець!

Усі попередні підходи є правильними. Однак проблема полягала в тому, що різні розробники надавали перевагу різним підходам, що призводило до потенційного змішування цих підходів в межах одного проєкту (щоправда, трохи зарадити з цим міг Eslint: ось це правило надає спосіб контролювати, який підхід повинен застосовуватися).

Починаючи з 16 версії, у Angular наявний оператор takeUntilDestroyed, який надає елегантний і простий підхід до відписки. Цей оператор усуває необхідність створення subject, реалізації хука OnDestroy і застосування декораторів — він уже має все, що нам потрібно. Для цього просто імпортуйте його та додайте до pipe перед .subscribe():

import { Component } from '@angular/core';
import { interval } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';


@Component({
  standalone: true,
  selector: 'app-take-until-destroyed-example',
  template: `
    <p>take-until-destroyed-example works!</p>
  `,
})
export class TakeUntilDestroyedComponent {
  constructor() {
    interval(1000)
      .pipe(takeUntilDestroyed())
      .subscribe((value) => {
        console.log(value);
      });
  }
}

Але треба пам’ятати кілька важливих моментів, коли ви використовуєте takeUntilDestroyed:

  • як і з іншими способами відписки за допомогою операторів, цей оператор повинен бути останнім у ланцюгу;
  • ви не зможете використати цей оператор, наприклад, у хуку ngOnInit. Тобто він буде працювати в контексті конструктора або ініціалізації поля класу, але ви отримаєте помилку у рантаймі: takeUntilDestroyed() can only be used within an injection context, якщо ви спробуєте використати його ще десь;
  • якщо ж вам все ж-таки треба це зробити, ви повинні передати destroyRef як аргумент в оператор.
@Component({
  standalone: true,
  selector: 'app-take-until-destroyed-example',
  template: `
    <p>take-until-destroyed-example works!</p>
  `,
})
export class TakeUntilDestroyedOnInitComponent implements OnInit {
  constructor(private destroyRef: DestroyRef) {}


  ngOnInit() {
    interval(1000)
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe((value) => {
        console.log(value);
      });
  }
}

У підсумку скажу: не зволікайте з оновленням до Angular 16. Серед інших цікавих можливостей, він дозволить рефакторити ваш спосіб обробки відписок і зробить ваш код більш чистим і лаконічним.

👍ПодобаєтьсяСподобалось13
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

як тільки жаль що не всі можуть юзати 16 ангуляр....

Підписатись на коментарі