Un memory leak într-o aplicație Angular rareori strică ceva. Tabul doar devine mai lent și mai greu pe parcursul unei după-amiezi: de fiecare dată când o componentă e distrusă, ceva pornit de ea continuă să ruleze — o subscripție, un timer, un listener pe window — și ține întreaga componentă în viață odată cu el. Plătesc utilizatorii care lasă aplicația deschisă toată ziua.
Pagina asta arată sursele din spatele aproape oricărui leak, cum închizi fiecare sursă și cum le găsești pe cele scăpate cu Chrome DevTools. Laboratorul creează leak-uri reale în acest tab și le curăță când părăsești pagina.
Suspecții obișnuiți
Widget-ul ăsta pare inofensiv și conține cele trei leak-uri clasice. Fiecare callback capturează this și fiecare e ținut de ceva care trăiește mai mult decât widget-ul: un serviciu root, lista de timere a browserului, window. Deschide-l și închide-l de câteva ori în laborator și urmărește ce rămâne în urmă.
@Component({ selector: 'app-price-widget', template: `{{ price() }}` })
export class PriceWidget implements OnInit {
private readonly prices = inject(PriceFeed); // un serviciu root: trăiește cât aplicația
protected readonly price = signal(0);
ngOnInit() {
// ✗ 1. feed-ul ține callback-ul, callback-ul ține `this`, pentru totdeauna
this.prices.updates$.subscribe((p) => this.price.set(p));
// ✗ 2. timer-ul continuă să ruleze după ce componenta a dispărut
setInterval(() => this.refresh(), 5000);
// ✗ 3. window trăiește mai mult decât orice componentă
window.addEventListener('resize', () => this.layout());
}
}Niciun widget pe pagină.
- distruse, încă vii
- 0
- memorie reținută
- 0.00 MB
- muncă de zombie
- 0/s
- subscripții
- 0
- timere
- 0
- listeneri
- 0
- widget-uri create
- 0
Fiecare widget ține un buffer de 256 KB, deci numărul „reținut” e memorie care chiar nu poate fi eliberată cât timp trăiește zombie-ul ei. Căsuțele se aplică widget-urilor create după ce le bifezi; „Curăță zombie-urile” oprește ce au lăsat în urmă cele vechi.
Cum închizi fiecare sursă
Regula e scurtă: pentru tot ce pornești, înregistrezi un stop, chiar în momentul în care pornești. Pentru observables, Angular are două unelte care fac asta pentru tine: toSignal() și takeUntilDestroyed(); pentru rest există DestroyRef.onDestroy(). Listenerii declarați în host sunt scoși de Angular; pentru mai mulți listeneri pe obiecte globale, un AbortController îi închide pe toți într-o linie.
import { Component, DestroyRef, inject, signal } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-price-widget',
template: `{{ price() }}`,
host: { '(window:resize)': 'layout()' }, // ✓ 3. Angular scoate singur host listener-ele
})
export class PriceWidget {
private readonly prices = inject(PriceFeed);
private readonly destroyRef = inject(DestroyRef);
// ✓ 1a. toSignal se dezabonează când componenta e distrusă
protected readonly price = toSignal(this.prices.updates$, { initialValue: 0 });
constructor() {
// ✓ 1b. sau, când chiar ai nevoie de subscribe(): takeUntilDestroyed
this.prices.alerts$.pipe(takeUntilDestroyed()).subscribe((a) => this.notify(a));
// ✓ 2. fiecare timer își are „stop”-ul înregistrat lângă „start”
const id = setInterval(() => this.refresh(), 5000);
this.destroyRef.onDestroy(() => clearInterval(id));
}
}| Sursă | Ce o ține în viață | Remediul |
|---|---|---|
subscribe() | sursa ține callback-ul tău, callback-ul ține componenta | toSignal(), pipe-ul async sau takeUntilDestroyed() |
setInterval | browserul ține callback-ul până când timer-ul e oprit | DestroyRef.onDestroy(() => clearInterval(id)) |
addEventListener | window și document trăiesc mai mult decât orice componentă | host: { '(window:resize)': … }, Renderer2.listen sau un AbortController |
| cache-uri în servicii | un serviciu root trăiește cât aplicația, la fel și Map-ul lui | ștergi intrarea la destroy sau folosești un WeakMap cu elementul drept cheie |
| widget-uri third-party | graficele, hărțile și editoarele își înregistrează propriii listeneri și timere | le apelezi destroy() / remove() în onDestroy |
// ✗ un serviciu root care ține minte componente (sau DOM-ul lor) le ține în viață
@Service()
export class Tooltips {
private readonly open = new Map<string, TooltipComponent>(); // nu se șterge niciodată → leak
}
// ✓ ștergi la destroy sau lași garbage collector-ul să decidă, cu un WeakMap
@Service()
export class Tooltips {
private readonly anchors = new WeakMap<HTMLElement, TooltipComponent>(); // cheia dispare → intrarea dispare
}Ce nu face leak
Nu orice subscribe() e o problemă. Cererile HttpClient se completează singure după răspuns (cu takeUntilDestroyed(), o cerere lentă e și anulată când pleci). Pipe-ul async, toSignal(), output() și host listener-ele fac curat după ele, la fel și effect() și computed() create într-o componentă. Router-ul curăță observables-urile din ActivatedRoute. Ce face leak e o subscripție la ceva care nu se completează niciodată și trăiește mai mult decât tine: un store, un Subject dintr-un serviciu root, interval, fromEvent(window, …), evenimentele router-ului.
Cum găsești leak-uri cu Chrome DevTools
Un leak e un număr care crește când ar trebui să rămână constant. Metoda: repeți o acțiune care n-ar trebui să lase nimic în urmă — deschizi și închizi un dialog, mergi pe o pagină și înapoi — și compari memoria înainte și după.
- Mai sus, în laborator, debifează cele trei reparații. Deschide DevTools → panoul Memory → la Select profiling type alege Heap snapshot. Apasă iconița de coș (Collect garbage), apoi Take snapshot.
- Apasă Deschide și închide de 10 ori și așteaptă să se termine. Din nou Collect garbage și Take snapshot.
- Cu Snapshot 2 selectat, schimbă meniul din stânga sus din Summary în Comparison (comparat cu Snapshot 1) și sortează după # Delta.
- Scrie în Filter by class
app-leaky-widget: +10, câte un element host pentru fiecare widget închis. ApoiDOMTimer(+10 timere încă pornite) șiEventListener(+10 listeneri). Pe site-ul publicat numele claselor sunt minificate, așa căLeakyWidgetapare doar când rulezi aplicația în development. - Dă click pe o instanță și citește panoul Retainers de jos: desfă rândurile ca să urci lanțul până la ce o ține în viață. Apasă Curăță zombie-urile, fă un al treilea snapshot și vei vedea aceleași rânduri la # Deleted.
- Mai direct: la Select profiling type alege Detached elements și apasă Get detached elements. Apare câte un
<app-leaky-widget>pentru fiecare widget închis.
Pentru o verificare rapidă fără snapshot-uri: meniul de comenzi (Ctrl/Cmd+Shift+P) → Show Performance monitor. Urmărește JS event listeners: crește cu 10 la fiecare „de 10 ori” cu reparațiile debifate și nu mai scade până nu cureți.
Toate panourile, pas cu pas, cu exerciții și capcanele (de exemplu DevTools care ține el însuși obiecte în viață), sunt la DevTools: cum te uiți în aplicație.
Un test care prinde leak-ul data viitoare
După ce ai reparat un leak, fă-l imposibil de readus. Un Subject știe dacă mai e cineva abonat (observed); după fixture.destroy() răspunsul trebuie să fie false. Mai multe despre teste în testarea cu Vitest.
import { TestBed } from '@angular/core/testing';
import { describe, expect, it } from 'vitest';
describe('PriceWidget', () => {
it('stops listening to the feed when destroyed', () => {
const feed = TestBed.inject(PriceFeed); // updates$ e un Subject în serviciu
const fixture = TestBed.createComponent(PriceWidget);
fixture.detectChanges();
expect(feed.updates.observed).toBe(true);
fixture.destroy();
expect(feed.updates.observed).toBe(false); // nu mai ascultă nimeni
});
});Când DA, când NU
- Taburi care stau deschise ore întregi: dashboard-uri, unelte de back-office, puncte de vânzare, chat.
- Componente create și distruse des: dialoguri, rânduri de listă, taburi, rute pe care revii des.
- Cod care se abonează la lucruri globale: store-uri, WebSockets, evenimente window, evenimente de router.
- O cerere HttpClient unică: se completează singură.
- Subscripții prin pipe-ul async sau toSignal(): sunt curățate pentru tine.
- Un serviciu root abonat la ceva pe toată durata aplicației: asta e intenționat, nu un leak.
Capcane frecvente
subscribe()înngOnInit, fără teardown, la ceva dintr-un serviciu root.- Încredere în
take(1). Dacă sursa nu emite niciodată — utilizatorul pleacă înainte de prima valoare — subscripția așteaptă la nesfârșit. removeEventListenercu altă funcție..bind(this)creează de fiecare dată o funcție nouă, așa că nu se scoate nimic.- Componente sau noduri DOM într-un
Mapdintr-un serviciu. Serviciul trăiește la nesfârșit, deci și ele. - Măsurători fără garbage collection înainte. Heap-ul urcă și coboară singur; compară snapshot-uri făcute după Collect garbage.
Fișă rapidă
subscribe() la o sursă longevivă takeUntilDestroyed() · toSignal() · pipe-ul async
take(1) / first() sigur doar dacă sursa VA emite; altfel așteaptă la nesfârșit
setInterval / lanțuri de setTimeout DestroyRef.onDestroy(() => clearInterval(id))
listeneri pe window / document host: { '(window:resize)': … } · Renderer2.listen · AbortController
removeEventListener are nevoie de ACEEAȘI funcție: păstrezi referința, nu .bind() de două ori
servicii root niciun Map de componente sau noduri DOM; ștergi la destroy sau WeakMap
widget-uri third-party chart.destroy(), map.remove() în onDestroy
nu face leak HttpClient (se completează), pipe-ul async, toSignal, outputs, host listeners
tot sigure effect() și computed() în componente: distruse odată cu ele
DevTools Memory → snapshot → repeți de N× → GC → snapshot → Comparison
citește retainers Subscriber / Timeout / EventListener = cine îl ține în viață
"Detached" noduri DOM scoase din pagină, dar încă referite
în CI subject.observed === false după fixture.destroy()