Фронтенд-разработка в последние годы стала сложнее. Одностраничные приложения живут часами, пользователи открывают вкладки и оставляют их работать, данные приходят с серверов постоянно. В этом хаосе часто кажется, что главное - чтобы компонент рендерился, а Observable выдавал данные.
Но даже в самом аккуратном коде могут появляться утечки памяти. Утечка памяти возникает, когда объекты, которые больше не нужны, остаются в памяти, потому что на них ещё есть ссылки. Для браузера они живы, сборщик мусора их не трогает.
Для Angular-разработчика это важно, потому что:
Утечки проявляются не сразу. Код кажется стабильным, но через час использования вкладка начинает тормозить, события задерживаются, интерфейс медленно реагирует.
Проблему сложно отследить. Часто она возникает на долгих сессиях или в специфических сценариях.
Даже новые возможности Angular, такие как signals и автоматическое управление отписками, не избавляют от ответственности.
Понимание того, как работает память и почему объекты остаются в куче, помогает писать приложения, которые остаются отзывчивыми и стабильными.
Как работает память в браузере
Когда мы говорим о памяти в JavaScript, важно понимать два основных пространства: стек и куча.
Стек хранит вызовы функций и примитивные значения. Когда функция вызывается, её локальные переменные помещаются в стек. Когда функция завершена, они автоматически удаляются. Стек работает быстро, но подходит только для небольших данных.
Куча хранит объекты, массивы, функции и DOM-элементы. Доступ к данным из кучи медленнее, но объекты могут жить дольше. Сборщик мусора периодически проверяет кучу, используя алгоритм mark-and-sweep. Он отмечает объекты, на которые есть ссылки, и удаляет все остальные.
Ключевой момент: объект не будет удалён из памяти, пока есть хотя бы одна ссылка на него. Ссылки могут находиться в стеке, в других объектах кучи или в глобальных структурах. Именно это создаёт основу для утечек.
Примеры утечек памяти в Angular
1. Подписки на Observable
Проблема: подписка хранит ссылку на компонент через callback. Компонент не удаляется, пока поток активен.
ngOnInit() { this.service.data$.subscribe(d => this.value = d); }
Как это работает в памяти:
Observable хранит callback в куче.
Callback захватывает контекст компонента (
this).Сборщик мусора видит, что на компонент есть ссылка через 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 - потенциальная точка утечки. И чем раньше это учитыва��ь, тем проще избежать проблем в продакшене.
