Фронтенд-разработка в последние годы стала сложнее. Одностраничные приложения живут часами, пользователи открывают вкладки и оставляют их работать, данные приходят с серверов постоянно. В этом хаосе часто кажется, что главное - чтобы компонент рендерился, а Observable выдавал данные.

Но даже в самом аккуратном коде могут появляться утечки памяти. Утечка памяти возникает, когда объекты, которые больше не нужны, остаются в памяти, потому что на них ещё есть ссылки. Для браузера они живы, сборщик мусора их не трогает.

Для Angular-разработчика это важно, потому что:

  1. Утечки проявляются не сразу. Код кажется стабильным, но через час использования вкладка начинает тормозить, события задерживаются, интерфейс медленно реагирует.

  2. Проблему сложно отследить. Часто она возникает на долгих сессиях или в специфических сценариях.

  3. Даже новые возможности Angular, такие как signals и автоматическое управление отписками, не избавляют от ответственности.

Понимание того, как работает память и почему объекты остаются в куче, помогает писать приложения, которые остаются отзывчивыми и стабильными.

Как работает память в браузере

Когда мы говорим о памяти в JavaScript, важно понимать два основных пространства: стек и куча.

Стек хранит вызовы функций и примитивные значения. Когда функция вызывается, её локальные переменные помещаются в стек. Когда функция завершена, они автоматически удаляются. Стек работает быстро, но подходит только для небольших данных.

Куча хранит объекты, массивы, функции и DOM-элементы. Доступ к данным из кучи медленнее, но объекты могут жить дольше. Сборщик мусора периодически проверяет кучу, используя алгоритм mark-and-sweep. Он отмечает объекты, на которые есть ссылки, и удаляет все остальные.

Ключевой момент: объект не будет удалён из памяти, пока есть хотя бы одна ссылка на него. Ссылки могут находиться в стеке, в других объектах кучи или в глобальных структурах. Именно это создаёт основу для утечек.

Примеры утечек памяти в Angular

1. Подписки на Observable

Проблема: подписка хранит ссылку на компонент через callback. Компонент не удаляется, пока поток активен.

ngOnInit() {
  this.service.data$.subscribe(d => this.value = d);
}

Как это работает в памяти:

  1. Observable хранит callback в куче.

  2. Callback захватывает контекст компонента (this).

  3. Сборщик мусора видит, что на компонент есть ссылка через callback → объект остаётся живым.

Решение:

  • Использовать async пайп в шаблоне для автоматического управления подпиской.

  • Для Angular <16 использовать takeUntil с Subject:

private destroy$ = new Subject<void>();

ngOnInit() {
  this.service.data$
    .pipe(takeUntil(this.destroy$))
    .subscribe(d => this.value = d);
}

ngOnDestroy() {
  this.destroy$.next();
  this.destroy$.complete();
}
  • Для Angular 16+ использовать takeUntilDestroyed с DestroyRef:

this.service.data$
  .pipe(takeUntilDestroyed(this.destroyRef))
  .subscribe(d => this.value = d);

2. Таймеры и setInterval

Проблема: setInterval хранит callback, который ссылается на компонент. Компонент остаётся живым, пока таймер не остановлен.

setInterval(() => this.loadData(), 5000);

В памяти:

  • Таймер в куче → callback → компонент

  • GC не может удалить компонент, пока callback жив

Решение: очищать таймеры в ngOnDestroy/DestroyRef

private oldTimer: any;

  // ===== Современный подход =====
  private destroyRef = inject(DestroyRef);

  ngOnInit() {
    // Старый способ с ngOnDestroy
    this.oldTimer = setInterval(() => this.loadDataOld(), 5000);

    // Новый способ с DestroyRef
    const newTimer = setInterval(() => this.loadDataNew(), 5000);
    this.destroyRef.onDestroy(() => clearInterval(newTimer));
  }

  // При использовании angular < 16
  ngOnDestroy() {
    clearInterval(this.oldTimer);
  }

3. Слушатели DOM

Проблема: addEventListener создаёт сильную ссылку на callback. Если callback ссылается на компонент или объекты данных, они остаются живыми после удаления DOM-узла.

document.addEventListener('scroll', this.onScroll);

В памяти:

  • DOM → callback → компонент

  • Компонент и связанные данные остаются живыми

Решение:

  • Использовать Angular-события через шаблон:

<div (scroll)="onScroll($event)"></div>
  • Или очищать вручную:

ngOnDestroy() {
  document.removeEventListener('scroll', this.onScroll);
}

4. Singleton-сервисы

Проблема: сервис живёт столько же, сколько приложение. Если хранить в нём компоненты, массивы или объекты с ссылками на DOM, GC не сможет удалить их.

@Injectable({ providedIn: 'root' })
export class CacheService {
  bigList: any[] = [];
}

В памяти:

  • Сервис → bigList → объекты → ссылки на компоненты

  • Всё остаётся живым до конца жизни приложения

Решение:

  • Очищать массивы/объекты, когда они больше не нужны


5. Замыкания

Проблема: функция захватывает контекст с большими объектами. Пока существует callback, на который есть ссылка, сборщик мусора не может удалить объект из кучи, даже если визуально компонент или элемент уже удалён.

const bigData = new Array(100000).fill('data');
document.body.onclick = () => console.log(bigData.length);

В памяти:

  • Callback → замыкание → bigData

  • Объект остаётся в памяти, потому что на него есть ссылка через замыкание.

Решение:

Разрывать ссылки на большие объекты

  • Не храните массивы, объекты или компоненты в переменных, которые будут захвачены замыканием.

  • Обнуляйте ссылки после использования, если они больше не нужны.

let bigData = new Array(100000).fill('data');

const handler = () => {
  console.log(bigData.length);
  bigData = null; // разрываем ссылку, GC сможет собрать объект
};

document.body.addEventListener('click', handler);

Учитывая все вышеизложенное можно составить краткий чеклист для предотвращения утечек:

  • Использовать async пайп для Observable, чтобы автоматически управлять подписками.

  • Отписываться от потоков через takeUntil (для старых версий) или takeUntilDestroyed с DestroyRef (для Angular 16+).

  • Очищать таймеры и слушатели в ngOnDestroy или через Renderer2.listen с автоматическим управлением.

  • Не хранить тяжёлые объекты, компоненты или ссылки на DOM в singleton-сервисах.

  • Разрывать замыкания и удалять обработчики событий при уничтожении компонентов.

  • Регулярно проверять состояние памяти и профилировать приложение с помощью DevTools и Performance профайлера.

Подведем итоги

Память — это не «дополнительная тема», это фундамент стабильного приложения.

Утечки возникают из-за живых ссылок: callback-ов, таймеров, подписок, сервисов. Angular помогает, но ответственность остаётся за разработчиком.

Понимание стека, кучи, ссылок и GC помогает писать приложения, которые не раздуваются со временем, остаются отзывчивыми и удобными для пользователя.

Каждый подписанный Observable, каждый таймер, каждый обработчик DOM - потенциальная точка утечки. И чем раньше это учитывать, тем проще избежать проблем в продакшене.

Комментарии (0)