Фронтенд-разработка в последние годы стала сложнее. Одностраничные приложения живут часами, пользователи открывают вкладки и оставляют их работать, данные приходят с серверов постоянно. В этом хаосе часто кажется, что главное - чтобы компонент рендерился, а 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 - потенциальная точка утечки. И чем раньше это учитывать, тем проще избежать проблем в продакшене.