Angular IDPStrong Junior
Модулі / Дані, моки й RxJS / Відписка й витоки
🔥 0

Модуль 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» на новий лад. Крім нечитабельності, вони ламають скасування: коли зовнішній потік видасть нове значення, внутрішні підписки лишаться живими й продовжать працювати паралельно.

Як побачити витік

  1. Відкрий DevTools → Memory, зроби Heap snapshot.
  2. Перейди на екран і назад разів десять.
  3. Зроби другий snapshot і порівняй за класами компонентів.
  4. Якщо кількість екземплярів росте — щось тримає посилання. Найчастіше саме підписка.
Порядок вибору
  • Дані для шаблону — async pipe.
  • Потрібен побічний ефект — takeUntilDestroyed().
  • Разова дія — take(1) або просто HTTP.
  • Кілька залежних запитів — оператори, не вкладені підписки.
Ознаки проблеми
  • subscribe у ngOnInit без жодного захисту.
  • Ручний масив subscriptions.push(...).
  • Підписка всередині підписки.
  • subscribe, який лише присвоює поле, — це має бути async pipe.