Модуль 4 · урок 10 з 13
Відписка й витоки
«Zero manual .subscribe() leaks» — окремий критерій у таблиці.
Витік підписки не падає з помилкою й не видно в інтерфейсі: застосунок просто поступово
важчає, а старі компоненти продовжують реагувати на дані. Розберемось, звідки це береться
й чому в сучасному коді проблема майже зникає.
Як виглядає витік
export class TaskList implements OnInit {
tasks: Task[] = [];
ngOnInit(): void {
this.store.visible$.subscribe(t => this.tasks = t); // ❌ ніхто не відписався
}
}
Користувач заходить на екран десять разів — залишається десять живих підписок. Усі вони й далі отримують дані й виконують колбеки для компонентів, яких давно немає в DOM. Памʼять не звільняється, бо посилання на компонент тримає сама підписка.
Потік, який завершується сам — наприклад, один HTTP-запит — не тече: після
complete підписка закривається автоматично. Течуть нескінченні потоки:
Subject, BehaviorSubject, interval,
fromEvent, valueChanges. Тобто рівно те, на чому будується
патерн стору з попереднього уроку.
Спосіб 1: async pipe — найкращий
@Component({
template: `
@for (task of store.visible$ | async; track task.id) {
<app-task-card [task]="task" />
}
`,
})
export class TaskList {
protected readonly store = inject(TaskStore);
}
async pipe підписується при створенні представлення й сам відписується,
коли воно знищується. Плюс сам викликає markForCheck для OnPush.
Дві проблеми одним оператором — тому це варіант за замовчуванням.
Спосіб 2: takeUntilDestroyed
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
export class TaskList {
private readonly destroyRef = inject(DestroyRef);
constructor() {
// у контексті інʼєкції DestroyRef можна не передавати
this.store.visible$.pipe(
takeUntilDestroyed(),
).subscribe(t => this.tasks.set(t));
}
ngOnInit(): void {
// поза контекстом інʼєкції — передаємо явно
this.store.visible$.pipe(
takeUntilDestroyed(this.destroyRef),
).subscribe(t => this.tasks.set(t));
}
}
Це заміна старому патерну з takeUntil(this.destroy$) і
ngOnDestroy, який ти напевно побачиш у чужому коді:
// класика, яку більше не пишуть
private destroy$ = new Subject<void>();
ngOnInit(): void {
source$.pipe(takeUntil(this.destroy$)).subscribe(…);
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
Спосіб 3: take(1) для разових речей
this.api.save(payload).pipe(take(1)).subscribe(); // хоча HTTP і так завершиться сам
this.dialog.afterClosed().pipe(take(1)).subscribe(result => …);
Вкладені підписки — найгірший варіант
// ❌ так робити не можна: неможливо скасувати, важко читати, легко втратити
this.route.params.subscribe(params => {
this.api.getTask(params['id']).subscribe(task => {
this.api.getComments(task.id).subscribe(comments => {
…
});
});
});
// ✅ те саме через оператори
this.route.params.pipe(
map(p => p['id'] as string),
switchMap(id => this.api.getTask(id)),
switchMap(task => this.api.getComments(task.id)),
takeUntilDestroyed(),
).subscribe(comments => …);
Вкладені підписки — це «callback hell» на новий лад. Крім нечитабельності, вони ламають скасування: коли зовнішній потік видасть нове значення, внутрішні підписки лишаться живими й продовжать працювати паралельно.
Як побачити витік
- Відкрий DevTools → Memory, зроби Heap snapshot.
- Перейди на екран і назад разів десять.
- Зроби другий snapshot і порівняй за класами компонентів.
- Якщо кількість екземплярів росте — щось тримає посилання. Найчастіше саме підписка.
- Дані для шаблону —
asyncpipe. - Потрібен побічний ефект —
takeUntilDestroyed(). - Разова дія —
take(1)або просто HTTP. - Кілька залежних запитів — оператори, не вкладені підписки.
subscribeуngOnInitбез жодного захисту.- Ручний масив
subscriptions.push(...). - Підписка всередині підписки.
subscribe, який лише присвоює поле, — це має бутиasyncpipe.