Browser-JavaScript in Interviews erklären: Warteschlangen, Heap, Lecks, Closures
Eine strukturierte Übersicht über den Browser-Runtime für die Vorbereitung auf Interviews: Call Stack, Task- und Microtask-Warteschlangen, Heap und Garbage Collection, Lecks, Delegation, Prototypen und Closures.
Quellen: #1 FrontEnd Concepts For Interviews — Javascript Fundament
Frontend-Interviews belohnen selten auswendig gelernte Sprüche. Die Interviewer prüfen, ob man erklären kann, was der Engine mit dem Code angetan wird: wo ein Callback wartet, warum ein Objekt länger existiert als seine Funktion, warum ein einziger Listener in einem Container hunderte ersetzen kann. Dieser Leitfaden behandelt diese Grundlagen in der Reihenfolge, in der sie aufeinander aufbauen, damit man an einer unbekannten Frage arbeiten kann, anstatt einfach eine Antwort auswendig aufzusagen. Ein guter Selbsttest: Nach jedem Abschnitt erklärt man die Idee laut, als würde man mit einem Kollegen sprechen, der noch nie davon gehört hat.
Der Aufrufstapel: Wo Ihr Code ausgeführt wird
Jeder JavaScript-Engine führt Code auf einem Aufrufstapel aus, einer Last-in-First-out-Struktur, die man sich als Array vorstellen kann, bei dem nur am Ende eingefügt und entfernt wird. Der Aufruf einer Funktion fügt einen Frame für ihren Ausführungskontext hinzu; das Zurückgeben entfernt ihn wieder. Es gibt nur einen solchen Stapel auf dem Hauptthread, sodass während ein Frame ausgeführt wird, nichts anderes möglich ist. Eine lange synchrone Schleife blockiert die Darstellung und Eingaben, bis sie abgeschlossen ist.
Warum eine Netzwerkanfrage die Seite nicht blockiert
Falls gleichzeitig nur eine Sache ausgeführt werden kann, warum stockt die Seite dann nicht beim Warten auf einen Backend-Server? Weil nicht JavaScript wartet. Stellen Sie sich ein Gerät vor, das eine große Datensammlung verarbeitet und anschließend nacheinander mehrere APIs aufruft. Der Aufruf von fetch() überträgt die Aufgabe an eine vom Browser bereitgestellte Web-API, die die Netzwerkkommunikation außerhalb des Stapels abwickelt. Ihre Funktion gibt sofort einen ausstehenden Promise zurück, sodass der Benutzer weiter interagieren kann.
Sobald die Antwort eintrifft, wird die Promise abgeschlossen und ihre .then()-Callback-Funktionen werden als Microtasks geplant, die ausgeführt werden, sobald der Stack leer ist. Genauer gesagt wird die Abschlussmeldung des Netzwerks über das Task-System des Browsers übermittelt, und in die Microtask-Warteschlange gelangen die Reaktionen der Promise. Der entscheidende Punkt auf Interviewebene ist, dass Ihre Fortsetzungslogik in der Warteschlange bleibt und nicht direkt ausgeführt wird.
Task-Warteschlange versus Microtask-Warteschlange
Timer folgen einem anderen Weg: Ein setTimeout()-Callback gelangt in die Task-Warteschlange, auch Macrotask-Warteschlange genannt. Die Regel des Event-Loops besagt, dass nach Abschluss der aktuellen Aufgabe und wenn der Stack leer ist, zunächst die gesamte Microtask-Warteschlange abgearbeitet wird, bevor die nächste Aufgabe ausgewählt wird. Diese Funktion plant jeweils eine Aufgabe vom Typ Task und eine vom Typ Microtask; die Zahlen in den Meldungen geben die Reihenfolge an.
function triggerBothQueues() {
console.log("1. Synchronous: Code starts on Call Stack");
// Enqueue a Macrotask (Task Queue)
setTimeout(() => {
console.log("4. Task Queue (Macrotask): setTimeout callback executed");
}, 0);
// Enqueue a Microtask (Microtask Queue)
Promise.resolve().then(() => {
console.log("3. Microtask Queue: Promise.then callback executed");
});
console.log("2. Synchronous: Code ends on Call Stack");
}
triggerBothQueues();
Die beiden synchronen Anweisungen werden zuerst ausgeführt, anschließend die Promise-Kallback-Funktion und danach der Timer. Eine Verzögerung von 0 bedeutet „so schnell wie möglich in die Warteschlange einreihen“, nicht „jetzt ausführen“, und der Microtask hat dennoch Vorrang. Ein häufiges Problem: Ein Microtask, der einen weiteren Microtask plant, wird in dieselbe Warteschlange aufgenommen und vor jedem Timer ausgeführt – deshalb kann eine endlose Kette von Microtasks die Rendering-Ausführung blockieren.
Der Heap: Wo Objekte gespeichert sind
Der Stack enthält die Ausführungskontexte; der Heap enthält die Daten, auf die sie verweisen. Objekte, Arrays, Funktionen sowie alles, was über Referenz verarbeitet wird, befinden sich im Heap, während lokale Primitive wie Zahlen und Boolesche Werte konzeptionell zusammen mit dem Ausführungskontext der Funktion aufbewahrt werden. Sagen Sie in einem Interview, dass dies eine Vereinfachung ist: Compiler verteilen Strings in der Regel im Heap und können sie teilen, und eine Primitive, die als Eigenschaft eines Objekts gespeichert ist, befindet sich zusammen mit diesem Objekt im Heap. Das Modell dient lediglich als gedankliches Hilfsmittel, nicht als Spezifikation.
Abfallentsorgung und Erreichbarkeit
Der Heap ist nicht unendlich. Ein Hintergrund-Abfallentsorger sichert Speicherplatz mithilfe des Konzepts der Erreichbarkeit:
- Wurzeln sind per Definition erreichbar: Globale Variablen sowie Parameter und lokale Variablen von Funktionen, die sich derzeit im Stack befinden.
Alles, was von keiner Wurzel aus erreichbar ist, gilt als Müll. Dem Sammler ist es egal, ob Sie ein Objekt noch verwenden möchten; wenn eine erreichbare Referenz vorhanden ist, bleibt es. So entstehen Speicherverluste. Für eine ausführlichere Darstellung mit Fokus auf Tools siehe das Suchen nach JavaScript-Speicherverlusten mithilfe von Erreichbarkeit und Retainern.
Zwei klassische Wege zum Speicherverlust
Ein Timer, der niemals stoppt
In diesem Dashboard-Tracker wird innerhalb einer Funktion ein großer Array erstellt, und ein Interval-Callback liest jede Sekunde seine Länge aus.
function startDashboardTracker() {
const hugeDataPayload = new Array(1000000).fill("data");
// Timer is launched
setInterval(() => {
// Because hugeDataPayload is referenced here,
// it stays trapped in memory forever!
console.log("Tracker pinging... Payload length:", hugeDataPayload.length);
}, 1000);
}
startDashboardTracker();
// Even after startDashboardTracker finishes executing and leaves the Call Stack,
// hugeDataPayload CANNOT be garbage collected because the timer's closure holds a reference to it.
Wenn startDashboardTracker zurückkehrt, könnte man erwarten, dass das Array als „Müll“ behandelt wird. Das ist jedoch nicht der Fall: Der Browser behält den aktiven Zeitintervall bei, dieses enthält seine Callback-Funktion, und die Closure dieser Funktion speichert hugeDataPayload. Bewahren Sie die von setInterval() zurückgegebene ID auf und rufen Sie clearInterval() auf, sobald die Arbeit abgeschlossen ist oder ein UI-Komponente deinstalliert wird; das Zerbrechen dieser ersten Verbindung befreit die gesamte Kette.
Ein Listener auf einem globalen Objekt
Dieser Fall ist subtiler, da scheinbar nichts läuft. Ein Suchwidget erstellt einen großen Index und registriert einen Größenanpassungs-Handler bei window.
function setupSearchWidget() {
// Heavy data tied to the widget scope
const heavySearchIndex = new Array(1000000).fill("search_index_item");
function handleResize() {
console.log("Window resized. Index size:", heavySearchIndex.length);
}
// Listener attached to the global 'window' object (a Root)
window.addEventListener("resize", handleResize);
}
setupSearchWidget();
// Later, user navigates away and the Search Widget DOM element is deleted.
// BUT 'window' still holds a reference to 'handleResize',
// which holds a reference to 'heavySearchIndex'.
Wenn der Benutzer weggeht und das DOM des Widgets entfernt wird, listet window, der Wurzelelement, weiterhin handleResize auf, welches sich auf heavySearchIndex bezieht. Rufen Sie removeEventListener auf, wenn das Widget deinstalliert wird, und übergeben Sie dieselbe Funktionsreferenz, die Sie registriert haben; eine neue Pfeilfunktion ist ein anderes Objekt und entfernt nichts.
Event-Bubbling und Delegation
React und Angular verbergen einen Großteil dieser Verwaltung. Ein Grund dafür ist die Event-Delegation: Anstatt eines Listeners auf jedem Knoten lauscht React im Wurzelcontainer und leitet Events an Ihre Handler weiter, während sie nach oben „bubblen“. Zudem verknüpft sein synthetisches Ereignissystem Handler, wenn Komponenten geladen und entladen werden, und löst sie wieder auf.
Delegation basiert auf dem Prinzip des Aufwärtsverlaufs: Ein Klick wird an das Zielelement gesendet und gelangt anschließend durch jedes Vorelément nach oben. Auf dieser Seite sind drei farbige Kästchen eingebettet, von denen jedes einen Klick-Listener besitzt.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Event Bubbling Example</title>
<style>
#grandparent {
background-color: lightcoral;
padding: 20px;
}
#parent {
background-color: lightblue;
padding: 20px;
}
#child {
background-color: lightgreen;
padding: 20px;
}
</style>
</head>
<body>
<div id="grandparent">
<h1>Grandparent - only one event listener</h1>
<div id="parent">
Parent
<div id="child">
Child
</div>
</div>
</div>
<script>
document.getElementById('grandparent').addEventListener('click', function(event) {
console.log('Grandparent clicked!');
console.log('target: ', event.target.getAttribute("id"));
// if (event.target.getAttribute("id") === 'child') {
// //
// }
});
document.getElementById('parent').addEventListener('click', function(event) {
console.log('Parent clicked!');
// event.stopPropagation();
});
document.getElementById('child').addEventListener('click', function(event) {
console.log('Child clicked!');
// event.stopPropagation();
});
</script>
</body>
</html>
Beim Klicken auf das innerste Kästchen wird zunächst der Kind-Element, danach das Elternelement und schließlich das Großeltern-Element protokolliert. Das Großeltern-Element liest event.target, um herauszufinden, welches Element tatsächlich geklickt wurde. Der kommentierte if-Befehl zeigt an, wie dieser einzige Listener je nach ID unterschiedlich handeln könnte, sodass die Kind-Elemente keinen eigenen Listener benötigen. Das ist Delegation in Miniaturform: Ein Listener hoch im Baum deckt alle Nachkommen ab, einschließlich solcher, die später hinzugefügt werden, und verbraucht dabei nur einen Bruchteil des Speichers.
Die kommentierten Aufrufe von event.stopPropagation() zeigen das Gegenteil: Wenn sie im Kind-Element aktiviert sind, stoppen sie das Ereignis, bevor es die Vorfahren erreicht (siehe die stopPropagation-Dokumentation). Verwenden Sie sie sparsam, da dadurch auch die weiter oben liegenden delegierten Handler, einschließlich des Root-Listeners eines Frameworks, beeinträchtigt werden. Es lohnt sich, direkt mit dieser Datei zu experimentieren; das einfache addEventListener sollte Ihnen vertrauter vorkommen als die Abstraktionen von Frameworks.
Kurz über die funktionale Programmierung
Die funktionale Programmierung (FP) wird oft als eine Produktionslinie aus isolierten Maschinen dargestellt: Jede nimmt Eingaben entgegen, erzeugt Ausgaben und berührt nichts anderes. Während objektorientierter Code dazu neigt, den Zustand von Objekten zu verändern, meidet die funktionale Programmierung solche Veränderungen und stützt sich auf fünf Prinzipien:
- Pure Functions: Die Ausgabe hängt nur von den Eingaben ab, ohne externe Nebeneffekte.
map und filter, anstatt Zähler und Zustand zu verwalten.Klassen und Prototypen
Since ES2022 unterstützen Klassen echte private Felder mit dem Präfix #. Hier ist #balance nur innerhalb des Klassenkörpers zugänglich.
// 1. PURE JAVASCRIPT (Native ES2022+)
class BankAccount {
#balance = 0; // Native JS private field (using #)
constructor(owner, initialBalance) {
this.owner = owner;
this.#balance = initialBalance;
}
deposit(amount) {
this.#balance += amount;
}
}
const account = new BankAccount("Alex", 1000);
// account.#balance; ❌ Uncaught SyntaxError (Hard runtime privacy)
Das Lesen von account.#balance von außen führt zu einem SyntaxError; somit wird die Privatsphäre durch die Sprache selbst gewährleistet, nicht durch Konventionen.
Darunter verwendet JavaScript weiterhin die prototypbasierte Vererbung: Objekte delegieren die Abfrage von Eigenschaften an ein Prototypobjekt. Vor ES6 wurden klassenähnliche Strukturen manuell mit Konstruktorfunktionen erstellt, wie in dieser Version des Kontos mit einer Unterklasse für Sparräume.
// 1. BASE CONSTRUCTOR FUNCTION (Parent Class)
function BankAccount(owner, initialBalance) {
this.owner = owner; // Public property
// PRIVATE VARIABLE (Data Privacy via Closure)
// Lives in function scope, completely hidden from the outside world!
var balance = initialBalance;
// Public method privileged to access the private 'balance' variable
this.deposit = function (amount) {
balance += amount;
this._logTransaction("Deposited quot; + amount + ". New Balance: quot; + balance);
};
}
// PROTECTED / SHARED METHODS (Prototypal Inheritance)
// Attached to the prototype so methods aren't re-created for every single instance
BankAccount.prototype._logTransaction = function (action) {
console.log("[" + this.owner + "]: " + action);
};
// 2. DERIVED CONSTRUCTOR FUNCTION (Child Class)
function SavingsAccount(owner, initialBalance, interestRate) {
// Call Parent Constructor with 'this' context (Super Call)
BankAccount.call(this, owner, initialBalance);
this.interestRate = interestRate;
}
// Inherit parent prototype methods on the child
SavingsAccount.prototype = Object.create(BankAccount.prototype);
SavingsAccount.prototype.constructor = SavingsAccount; // Reset constructor reference
// Child Public Method
SavingsAccount.prototype.addInterest = function () {
// Calls the inherited prototype method
this._logTransaction("Applied " + this.interestRate + "% interest");
};
// 3. USAGE
var mySavings = new SavingsAccount("Alex", 1000, 5);
mySavings.deposit(200); // -> [Alex]: Deposited $200. New Balance: $1200
mySavings.addInterest(); // -> [Alex]: Applied 5% interest
Beachten Sie, dass balance durch eine Schließung privat ist: Es befindet sich im Scope des Konstruktors und nur deposit kann darauf zugreifen. _logTransaction befindet sich auf dem Prototyp, sodass alle Instanzen dieselbe Funktion teilen; das Unterstrichsymbol bedeutet nach Konvention lediglich „geschützt“. BankAccount.call(this, ...) ist die manuelle Entsprechung von super(), und Object.create verknüpft die Prototypen, bevor die überschriebene constructor-Eigenschaft wiederhergestellt wird.
Moderner Frontend-Code bevorzugt ohnehin die Komposition vor der Vererbung. Wenn Sie strengere OOP-Prüfungen wie Zugriffsmodifikatoren und Schnittstellen wünschen, eignet sich TypeScript besser; diese ausführlichere Einführung geht noch weiter ins Detail.
Closures
Es gibt noch viel mehr zu entdecken – von Canvas und anderen Browser-APIs bis hin zu Modulen, Iteratoren und Generatoren – doch Closures tauchen in fast jedem Vorstellungsgespräch auf, und Sie sind ihnen bereits in den Leck-Beispielen begegnet. Ein Closure ist eine Funktion, die mit dem lexikalischen Umfeld verbunden ist, in dem sie erstellt wurde. Da die innere Funktion eine Referenz auf dieses Umfeld behält, kann sie auch lange nach dem Rückgang der äußeren Funktion deren Variablen weiterhin lesen und aktualisieren.
function createCounter() {
let count = 0; // Outer variable
return function() {
count++; // Inner function accesses 'count'
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
count bleibt zwischen den Aufrufen bestehen, weil die zurückgegebene Funktion auf ihr Umfeld zugreifen kann – was direkt mit den Regeln der Garbage Collection zusammenhängt. Jeder Aufruf von createCounter erzeugt einen unabhängigen Zähler.
Eine praktische Schließfunktion: Debouncing
Ein realistischer Anwendungsfall ist das Debouncing: Eine Funktion wird nur dann ausgeführt, wenn nach einer bestimmten Verzögerung keine weiteren Aufrufe mehr erfolgen, wie beispielsweise bei der Suchfunktion während des Eingabens.
function debounce(func, delay = 300) {
let timer; // 1. The closure variable that holds the timeout reference
return function (...args) {
// 2. If the function is called again before the delay finishes,
// cancel the previous scheduled run
clearTimeout(timer);
// 3. Schedule a new run after the delay
timer = setTimeout(() => {
func.apply(this, args);
}, delay);
};
}
// --- Usage Example ---
function handleSearch(query) {
console.log(`Searching API for: ${query}`);
}
// Wrap our search function in the debounce function
const debouncedSearch = debounce(handleSearch, 500);
// Simulating fast typing into an input field:
debouncedSearch("a"); // Timer starts...
debouncedSearch("ap"); // Cancels "a", resets timer...
debouncedSearch("app"); // Cancels "ap", resets timer...
debouncedSearch("apple");// Cancels "app", resets timer...
// Only "Searching API for: apple" will log to the console
// 500ms after the last keypress!
Der geschlossene timer stellt den Zustand dar. Jeder Aufruf löscht die ausstehende Zeitüberschreitung und plant eine neue an, sodass nur der letzte Aufruf in einer Serie ausgelöst wird: Das Schnelleingeben von „a“, „ap“, „app“ oder „apple“ führt 500 Millisekunden nach der letzten Tastenbetätigung zu einer Suche nach „apple“. func.apply(this, args) leitet sowohl die Argumente als auch den Aufrufkontext weiter. Zu Stornierungen und Randfällen siehe die Erstellung einer Debounce-Hilfsfunktion von Grund auf.
Haupterkenntnisse
- Eine Aufrufstack führt Ihren Code aus; Browser-APIs übernehmen das Warten und liefern Ergebnisse über Warteschlangen zurück.
- Sämtliche ausstehenden Microtasks werden vor der nächsten Aufgabe ausgeführt, wie beispielsweise einem Timer-Callback.
- Der Sammler behält alles bei, was von einer Wurzelebene aus erreichbar ist – unabhängig davon, ob es benötigt wird oder nicht.