AUTOR Matthias vom Bruch
Radikale Reaktivität in Angular Teil 4: Memory Leaks
Dieser Artikel ist Teil einer Serie, in der ich versuche, mentale Modelle zu vermitteln, die zu gutem RxJS-Code führen. Ich empfehle, sie der Reihe nach zu lesen. Anhand des Codes einer Demo-Anwendung werden häufige Probleme in RxJS aufgezeigt und Verbesserungsmöglichkeiten vorgeschlagen.
Was ist ein Memory Leak?
Ein Memory Leak tritt auf, wenn ein Teil einer Anwendung ständig "Daten" (Strings, Zahlen, Objekte, Funktionen) produziert, die nie aus dem Speicher freigegeben werden. Im Laufe der Zeit sammeln sich diese Daten an und führen dazu, dass immer mehr Arbeitsspeicher verbraucht wird. Diese Probleme sind schwer zu finden, da sie Ihre Website zwar verlangsamen und unangemessen viele Ressourcen verbrauchen, aber keine Fehler verursachen, sodass Sie nicht leicht herausfinden können, wo sie auftreten.
Anti-Pattern 3: (Unbehandelte) Subscriptions innerhalb von (unbehandelten) Subscriptions
Unbehandelte Abonnements führen oft zu Memory Leaks. Subscriptions innerhalb von Subscriptions führen in der Regel zu Memory Leaks.
Wie sieht ein Memory Leak aus?
// app.component.ts
// ...
userRemove($event: Event) {
$event.stopPropagation();
const removeId = this.selectedClient?.id;
if (!removeId || !this.selectedClient) {
return;
}
this.dataService.deleteUser(this.selectedClient).subscribe({
next: () => {
this.dataService.getAllUsers().subscribe(users => {
this.clients = users.map(user => ({ user, cars: [] }));
this.clients?.forEach(client => {
if (!client.user.id) {
return;
}
this.dataService.getCarsByUserId(client.user.id).subscribe(cars => {
client.cars = cars;
});
});
});
},
error: err => {
if (window.confirm("This user can not be deleted. Refresh the page?")) {
this.refreshState();
}
}
});
this.selectedClient = undefined;
}
Ist dies ein Memory Leak? Wir wissen es nicht, solange wir nicht den dataService
untersuchen. Ist dies also ein Memory Leak? Nein, eigentlich nicht. Warum nicht? Weil in dieser naiven Implementierung this.dataService
eine Instanz des MockDataService
ist, der nur ein dünner Wrapper für den HTTP-Client ist. Die Observables des HTTP-Clients von Angular sind etwas Besonderes, weil sie Daten nur einmal und vollständig ausgeben. Vielleicht sollten sie besser Promises sein. So verhalten sie sich nämlich.
Wird dies zu einem Memory Leak führen? Durchaus möglich! Eines Tages werden wir vielleicht beschließen, diesen Dienst intelligenter zu machen, so dass die Observables, die er zurückgibt, tatsächlich nicht abgeschlossen werden, sondern weiterhin Daten liefern, sobald sie verfügbar sind. Wenn wir dies tun, ohne auch die Komponente drastisch zu verändern, haben wir ein Memory Leak. Wie funktioniert das also?
Wenn wir someObservable$.subscribe(observer)
aufrufen, passiert, dass der Observer, der eine Sammlung von Callback-Funktionen ist, registriert wird und somit innerhalb von someObservable$
gehalten wird. Das nimmt Speicher in Anspruch und definiert Aktionen, die ablaufen, wenn someObservable$
ausgelöst wird.
Wenn wir ein Konstrukt wie
`Typescript someObservable$.subscribe(() => { someOtherObservable$.subscribe((Daten) => { // Daten verarbeiten }); });
Eine verschachtelte Subscription registriert jedes Mal, wenn die äußere Observable ausgelöst wird, mehr und mehr Callbacks. Dies ist ein Memory Leak. Wir registrieren tatsächlich jedes Mal einen neuen Callback in `someOtherObservable$`, wenn `someObservable$` auslöst! Natürlich ist es mehr oder weniger jedes Mal derselbe Callback, aber als Wert, nicht als Referenz. Jedes Mal, wenn `someObservable$` ausgelöst wird, verbrauchen wir ein bisschen mehr Speicher und berechnen ein bisschen mehr Dinge. Denn nachdem das äußere Observable n-mal ausgelöst hat, führen wir n Callbacks (pseudo) gleichzeitig aus. Wir können dies leicht auf verschiedene Arten beheben, eine davon ist die Verwendung von `someOtherObservable$.pipe(first()).subscribe(...)`. Die äußere Subscription ist immer noch unbehandelt, was ein eigenes Memory Leak sein kann, aber wie man das behebt, hängt von der eigenen Domäne ab. Im Allgemeinen ist es jedoch am sichersten, immer eine Referenz auf die Subscription zu behalten, die durch den Aufruf von `subscribe` zurückgegeben wird. Eine Subscription hat eine `unsubscribe` Methode mit einem einzigen Zweck: Den Callback zu entfernen, der im Observable registriert wurde, als die Subscription erstellt wurde. `subscribe` öffnet den Datenstrom, `unsubscribe` schliesst ihn. Vergessen Sie das niemals. ### Wie sieht das in der Praxis aus? Eine Vorlage für die Handhabung von Abonnements unter Verwendung eines aggregierten Master-Abonnements und des OnDestroy-Lifecycle-Hooks. Die beste Praxis ist, eine "Master Subscription" zu erstellen, sobald wir unsere Komponente initialisieren. Andere Subscriptions, die wir während der Lebensdauer der Komponente erstellen, werden zu der Master Subscription hinzugefügt. Im OnDestroy-Lifecycle-Hook, der aufgerufen wird, wenn Angular die Komponente entfernt, melden wir uns von allen Subscriptions ab. Jetzt können wir sicher sein, dass kein Memory Leak zurückbleibt. ## Allgemeine Anmerkung zu Subscriptions: Jedes Mal, wenn Sie `subscribe` schreiben, fragen Sie sich, ob es wirklich notwendig ist, diese Subscription zu erstellen. Subscriptions müssen gehandhabt werden. Sie sind etwas gefährlich. Sie lösen die Ausführung von Logik aus, die sonst schlummern würde. Und sie können oft vermieden werden, indem Sie Ihre Observables mit Pipes kombinieren. Sie sollten also nur dann auftreten, wenn die Daten wirklich benötigt werden. Ich würde sogar so weit gehen zu sagen, dass sie selbst eine Art Anti-Pattern sind. Es gibt zwei Situationen, in denen Sie tatsächlich ein Observable subscriben müssen: Wenn Sie Daten benötigen, um sie in Ihrem Template zu verwenden, oder wenn Sie sie an das Backend senden müssen. Einfach gesagt: An den äußeren Rändern Ihrer Anwendung. Und an den Grenzen zwischen Komponenten (wird in einem späteren Artikel behandelt). Für alle Daten, die in das Template gehen, hat Angular die raffinierte async Pipe. Sie subscribed jede Promise oder Observable wie `<p class="name">{{ name$ | async }}</p>` und unsubscribed sie automatisch, wenn die Komponente zerstört wird. Sicher. Wenn Daten ins Backend gehen, sind sie normalerweise das Ergebnis einer Benutzeraktion. Sie haben vielleicht ein Formular abgeschickt, also wollen Sie etwas tun wie ```Typescript submitForm(data: FormData): void { this.someObservable$.subscribe((result) => { // Verarbeiten des Ergebnisses }); }
Haben Sie bemerkt, dass ich die Subscription nicht gespeichert habe? Das war auch nicht nötig, denn der erste Operator meldet sich von der Quelle Observable ab, nachdem der erste Wert eingetroffen ist. Sicher.
Obwohl dieser Code sicher ist, ist er nicht optimal - zu imperativ. Was ich sehen möchte, ist
submitForm(data: FormData): Observable<Result> { return this.someObservable$.pipe( ); }
und dann behandeln Sie dieses Ereignis irgendwo in Ihrem wohldefinierten Ereignisnetzwerk!
Es gibt andere Situationen, in denen es sehr schwierig oder unmöglich ist, Subscriptions zu umgehen. Ein solcher Fall sind Angular-Formulare. Alles, was z.B. ngModel
verwendet, kann nicht mit einem Observable als Eingabe umgehen, also müssen Sie den Wert auspacken. Wenn Sie einen einfacheren Weg kennen, um Formulare zu handhaben, lassen Sie es mich bitte wissen!
Jetzt wisst ihr, wie man Anwendungen schreibt, die den ganzen Tag laufen können, ohne dass der Browser abstürzt 😉 .