«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 (в іншому разі — так і робіть).
Деякі приклади:
- Observables, які створюються на основі дій користувача (і таким чином є безтерміновими), наприклад, на основі
click
-подій за допомогою оператора fromEvent. - Форми — наприклад, треба відписуватися від valueChanges.
- Subjects (у більшості випадків). Їх зазвичай використовують для стейт-менеджменту. Він також містить NgRx store, наприклад.
- Деякі оператори RxJS, які продукують нескінченний потік значень. У прикладах ми будемо використовувати оператор interval.
- 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. Серед інших цікавих можливостей, він дозволить рефакторити ваш спосіб обробки відписок і зробить ваш код більш чистим і лаконічним.
1 коментар
Додати коментар Підписатись на коментаріВідписатись від коментарів