12k
All articles

10 JS-Interview-Fragen und was sie wirklich testen

10 JavaScript-Interviewfragen zu Hoisting, Closures, this, Event Loop und Typumwandlung mit Ausgabe und Erklärung, was sie prüfen.

OpenReplay Team
OpenReplay Team
10 JS-Interview-Fragen und was sie wirklich testen

Bei den meisten JavaScript-Interview-Fragen geht es nicht darum, die Ausgabe zu kennen – sondern darum, ob man das Modell versteht, das diese Ausgabe erzeugt. Ein erfahrener Interviewer, der console.log(x); var x = 5; auf das Whiteboard schreibt, weiß bereits, dass undefined ausgegeben wird. Was bewertet wird, ist die Fähigkeit zu erklären, warum – ohne dabei auf die Formulierung „Hoisting verschiebt es nach oben” zurückzugreifen, denn das ist das mentale Modell, nicht der Mechanismus. Kandidaten, die weiterkommen, können das Laufzeitverhalten benennen, die Ausgabe vorhersagen und die Begründung in zwei klaren Sätzen mündlich darlegen.

Dieser Artikel behandelt zehn Fragen, die zehn grundlegenden Konzepten entsprechen – dem Ausführungsmodell, der Temporal Dead Zone, Closures, lexikalischem Scope, this-Binding, dem Event Loop und Typumwandlung (Coercion) – und gliedert jede in vier Teile: den Code-Ausschnitt, die genaue Ausgabe mit Begründung, was der Interviewer wirklich testet und die Folgefrage, die als nächstes kommt. Jede Ausgabe ist spezifikationskonform und in jeder aktuellen Engine reproduzierbar. Die beschriebenen Verhaltensweisen sind in allen aktuellen Engines stabil – es handelt sich um Sprachsemantik, keine versionsspezifischen Eigenheiten.

Wichtigste Erkenntnisse

  • Die var-Hoisting-Frage testet nicht, ob man weiß, dass die Ausgabe undefined ist – sie testet, ob man versteht, dass JavaScript Variable Bindings beim Betreten eines Scopes erstellt, bevor eine einzige Zeile ausgeführt wird.
  • let und const werden ebenfalls gehoisted, bleiben aber in der Temporal Dead Zone uninitialisiert, bis ihre Deklarationszeile erreicht wird – ein frühzeitiger Zugriff wirft daher einen ReferenceError statt undefined zurückzugeben.
  • Beim klassischen setTimeout-in-einem-for-Loop-Bug gibt var den finalen Schleifenwert aus (3 3 3), weil alle Callbacks dasselbe function-scoped Binding teilen, während let die jeweiligen Iterationswerte ausgibt (0 1 2), da pro Iteration ein neues Binding erstellt wird.
  • this wird nicht dort festgelegt, wo eine Funktion geschrieben steht – sondern dadurch, wie die Funktion aufgerufen wird; Arrow Functions bilden die Ausnahme, da sie kein eigenes this besitzen.
  • Promise-Callbacks (Microtasks) werden immer vor setTimeout-Callbacks (Macrotasks) ausgeführt, selbst bei einer Verzögerung von 0 ms.

JavaScript-Interview-Fragen zu Hoisting und dem Ausführungsmodell

1. Warum gibt var vor der Zuweisung undefined aus?

console.log(x); // undefined
var x = 20;
console.log(x); // 20

Die erste Zeile gibt undefined aus, keinen ReferenceError. Die gängige Erklärung lautet, die Deklaration werde „nach oben verschoben” – das ist jedoch eine Metapher. Was tatsächlich passiert: Wenn die Engine einen Scope betritt, instanziiert sie die Bindings für diesen Scope bevor sie Anweisungen ausführt, und ein var-Binding wird dabei mit undefined initialisiert. Die Zuweisung = 20 verbleibt in ihrer ursprünglichen Zeile und wird in der normalen Reihenfolge ausgeführt. Dieser Binding-Erstellungsschritt ist in der ECMAScript Language Specification unter Variable Environment Instantiation definiert – nichts wird physisch verschoben.

Was wirklich getestet wird: ob man versteht, dass JS eine Erstellungsphase vor der Ausführungsphase hat. Die Antwort undefined ist Trivia; das Zweiphasen-Ausführungsmodell ist die eigentliche Kompetenz. Siehe MDNs Hoisting-Glossareintrag für die kanonische Darstellung.

Wahrscheinliche Folgefrage: „Was wäre, wenn es let statt var wäre?” – das ist Frage 2.

2. Warum wirft let einen Fehler statt undefined auszugeben?

console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 20;

let und const werden gehoisted – das Binding wird beim Betreten des Scopes erstellt –, bleiben aber uninitialisiert, bis die Ausführung die Deklarationszeile erreicht. Das Zeitfenster zwischen dem Betreten des Scopes und der Deklarationszeile ist die Temporal Dead Zone; ein Zugriff auf ein Binding innerhalb dieser Zone wirft ReferenceError: Cannot access 'y' before initialization. Das ist der entscheidende Unterschied: var wird bei der Erstellung mit undefined initialisiert; let/const werden überhaupt nicht initialisiert, bis ihre Zeile ausgeführt wird. MDN dokumentiert dies im Abschnitt let Temporal Dead Zone.

Was wirklich getestet wird: ob man Binding-Erstellung von Binding-Initialisierung trennt. Ein Kandidat, der sagt „let wird nicht gehoisted”, hat das falsche Modell; die korrekte Antwort lautet: Es wird gehoisted, aber bleibt uninitialisiert.

Wahrscheinliche Folgefrage: „Warum existiert die TDZ überhaupt?” – Die Antwort: Sie macht const durchsetzbar und verwandelt die Verwendung vor der Deklaration in einen expliziten Fehler statt eines stillen undefined.

Hier ist die Referenztabelle, die Interviewer erwarten, dass man sie mündlich rekonstruieren kann:

Verhaltenvarletconst
Gehoisted (Binding beim Betreten des Scopes erstellt)JaJaJa
Bei Erstellung initialisiertundefinedNein (TDZ)Nein (TDZ)
Zugriff vor DeklarationundefinedReferenceErrorReferenceError
ScopeFunctionBlockBlock
Im selben Scope neu deklarierbarJaNeinNein
Neu zuweisbarJaJaNein

3. Hoisting von Funktionsdeklarationen vs. Funktionsausdrücken

foo(); // "I run"
bar(); // TypeError: bar is not a function

function foo() { console.log("I run"); }
var bar = function () { console.log("I don't"); };

Eine Funktionsdeklaration wird vollständig gehoisted – Name und Rumpf –, sodass foo() vor ihrer Definition funktioniert. Ein Funktionsausdruck, der var bar zugewiesen wird, hoisted nur das bar-Binding, initialisiert mit undefined. Der Aufruf von undefined wirft einen TypeError, keinen ReferenceError – das Binding existiert, ist aber noch keine Funktion. Den Fehlertyp korrekt zu benennen ist hier das entscheidende Merkmal. MDN behandelt diesen Unterschied unter Funktionsdeklarationen.

Was wirklich getestet wird: ob man versteht, dass Deklarationen Werte hoisten, während Ausdrücke nur Bindings hoisten. Das ist dieselbe Unterscheidung wie in den Fragen 1 und 2, angewendet auf Funktionen.

Wahrscheinliche Folgefrage: „Was wäre, wenn bar mit let deklariert wäre?” – Dann wirft der frühe Aufruf einen ReferenceError aus der TDZ statt eines TypeError.

JavaScript-Interview-Fragen zu Closures und Scope

4. Der klassische setTimeout-in-einer-Schleife-Fall

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0); // 3 3 3
}

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0); // 0 1 2
}

Die var-Schleife gibt 3 3 3 aus. Es gibt ein einziges function-scoped i; wenn die Callbacks ausgeführt werden (nachdem die synchrone Schleife abgeschlossen ist), ist i gleich 3, und alle drei Closures lesen dasselbe Binding. Die let-Schleife gibt 0 1 2 aus, weil let für jede Iteration ein neues Binding erstellt, sodass jeder Callback sein eigenes i schließt. Dies ist der am häufigsten falsch beantwortete Code-Ausschnitt im Netz, und er kombiniert drei Konzepte: Closures, Scope und den Event Loop (die Callbacks werden zurückgestellt, sodass die Schleife zuerst abgeschlossen wird). MDNs Closures-Leitfaden dokumentiert das iterationsspezifische let-Binding.

Was wirklich getestet wird: ob man erklären kann, wann eine Closure eine Variable liest, im Gegensatz dazu, wann sie erstellt wurde. Das ist keine Trivia – es ist dieselbe Klasse von Fehlern, die in der Produktion als Stale-Closure-Zustand in Event-Handlern und React-Effekten auftritt. Session-Replays von Stale-Closure-Handlern zeigen häufig, dass ein Wert aus einem früheren Render geloggt wird.

Wahrscheinliche Folgefrage: „Beheben Sie es ohne let.” – Den Rumpf in ein IIFE einwickeln, das i als Argument entgegennimmt und so pro Iteration einen neuen Scope erstellt.

5. Der private Zähler

function makeCounter() {
  let count = 0;
  return {
    increment: () => ++count,
    value: () => count,
  };
}

const c = makeCounter();
c.increment(); // 1
c.increment(); // 2
c.value();     // 2
// count ist von außen nicht erreichbar

Eine Closure ist eine Funktion, die zusammen mit den Variablen gebündelt ist, auf die sie an ihrem Definitionsort Zugriff hatte – nicht an ihrem Aufrufort. Deshalb können die zurückgegebenen Methoden count noch erreichen, nachdem makeCounter bereits zurückgekehrt ist. Da nichts außerhalb count direkt zugänglich macht, ist es effektiv privat. Das ist Kapselung, die aus dem Scope heraus aufgebaut wird – nicht aus einem #private-Feld.

Was wirklich getestet wird: ob man Closures als Werkzeug für Speicherverwaltung und Kapselung versteht, nicht nur als Quiz-Antwort. Die Folgefrage prüft, ob man die Kosten kennt.

Wahrscheinliche Folgefrage: „Verursacht das einen Memory Leak?” – Die count-Variable bleibt am Leben, solange c erreichbar ist, weil die Closure eine Referenz darauf hält; das ist hier beabsichtigt, aber unkontrollierte Closures über große Objekte sind eine reale Leak-Quelle.

6. Lexikalischer Scope: definiert, nicht aufgerufen

const x = 10;

function outer() {
  const x = 20;
  return inner;
}

function inner() {
  console.log(x); // 10
}

outer()(); // 10

inner gibt 10 aus, nicht 20. JavaScript löst freie Variablen danach auf, wo eine Funktion im Quellcode definiert ist, nicht wo sie aufgerufen wird. inner ist auf der obersten Ebene definiert, daher wird ihr x zum obersten 10 aufgelöst – das x innerhalb von outer ist für sie irrelevant. Das ist lexikalisches (statisches) Scoping, und es macht Closures vorhersehbar.

Was wirklich getestet wird: ob man den Call Stack mit der Scope Chain verwechselt. Sprachen mit dynamischem Scope würden 20 ausgeben; JavaScript nicht.

Wahrscheinliche Folgefrage: „Verschieben Sie jetzt die inner-Deklaration innerhalb von outer.” – Dann wird zu 20 aufgelöst, weil sich der Definitionsort geändert hat.

JavaScript-Interview-Fragen zum this-Binding

7. Worauf verweist this hier?

const user = {
  name: "Ada",
  greet() { return this.name; },
};

const fn = user.greet;
user.greet(); // "Ada"
fn();         // undefined (oder wirft im Strict Mode)

this wird nicht dort festgelegt, wo eine Funktion geschrieben steht – sondern dadurch, wie die Funktion aufgerufen wird. Aufgerufen als user.greet() ist das Objekt an der Aufrufstelle user, sodass this.name den Wert "Ada" hat. Wird fn zugewiesen und ohne Kontext aufgerufen, gibt es kein Objekt an der Aufrufstelle: this ist im Sloppy Mode das globale Objekt (wodurch this.name undefined ergibt) oder undefined im Strict Mode (wodurch this.name einen Fehler wirft). Die vier Binding-Regeln sind in MDNs this-Referenz zusammengefasst.

Was wirklich getestet wird: ob man weiß, dass this dynamisch und aufrufstellengesteuert ist, nicht lexikalisch festgelegt.

Wahrscheinliche Folgefrage: „Wie pinnt man this an user?” – fn.call(user), fn.apply(user) oder user.greet.bind(user).

AufrufformWorauf this gebunden wird
fn() (ohne Kontext)undefined (Strict) / globales Objekt (Sloppy)
obj.fn() (Methode)obj (das Objekt links vom Punkt)
fn.call(o) / fn.apply(o) / fn.bind(o)o (explizit)
new Fn()die neu erstellte Instanz
Arrow Functionaus dem umschließenden lexikalischen Scope geerbt

8. this innerhalb eines Callbacks

const timer = {
  seconds: 0,
  startBroken() {
    setInterval(function () { this.seconds++; }, 1000); // this ist falsch
  },
  startFixed() {
    setInterval(() => { this.seconds++; }, 1000); // this ist timer
  },
};

In startBroken wird die einfache function, die an setInterval übergeben wird, vom Timer-Mechanismus ohne Aufrufstellen-Objekt aufgerufen, sodass this nicht timer ist – this.seconds++ mutiert das falsche Objekt. In startFixed hat die Arrow Function kein eigenes this und erbt es aus dem Scope von startFixed, wo this gleich timer ist. Das ist der Lehrbuchgrund, warum Arrow Functions für Callbacks existieren. Siehe MDN zu Arrow Functions.

Was wirklich getestet wird: ob man lexikalisches this versteht und warum Arrow Functions den alten var self = this-Workaround ersetzt haben. Der Fehler mit verlorenem this tritt in der Produktion ständig in Klassenkomponenten und Event-Listenern auf.

Wahrscheinliche Folgefrage: „Warum kann man eine Arrow Function nicht als Konstruktor oder als Objektmethode verwenden, die ein eigenes this benötigt?” – Weil sie kein this-Binding besitzt, das zugewiesen werden könnte: new wirft daher einen Fehler, und eine Arrow Function auf Methodenebene erfasst das äußere this statt das des Objekts.

JavaScript-Interview-Fragen zum Laufzeitmodell

9. Die Konsolenreihenfolge vorhersagen (Event Loop)

console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
// Ausgabe: 1 4 3 2

Promise-Callbacks (Microtasks) werden immer vor setTimeout-Callbacks (Macrotasks) ausgeführt, selbst bei einer Verzögerung von 0 ms. Der Ausführungsablauf:

  1. console.log("1") wird synchron ausgeführt → 1.
  2. setTimeout stellt seinen Callback in die Macrotask-Queue.
  3. Promise.resolve().then(...) stellt seinen Callback in die Microtask-Queue.
  4. console.log("4") wird synchron ausgeführt → 4.
  5. Der Call Stack ist nun leer. Die Engine leert die gesamte Microtask-Queue, bevor eine Macrotask ausgeführt wird → 3.
  6. Erst dann wird die nächste Macrotask aufgenommen → 2.

MDNs Microtask-Leitfaden beschreibt diese Reihenfolge.

Was wirklich getestet wird: ob man versteht, dass der Event Loop zwei Prioritätsstufen hat, nicht eine einzige Queue. Das zweistufige Event-Loop-Modell liegt jedem „mein Zustand wurde einen Tick zu spät aktualisiert”-Fehler zugrunde.

Wahrscheinliche Folgefrage: „Wo passt async/await hinein?” – Der Code nach einem await wird als Microtask ausgeführt und hat daher dieselbe Priorität wie ein .then()-Callback.

10. == vs. === und Typumwandlung (Coercion)

0 == "0";        // true
0 === "0";       // false
null == undefined; // true
NaN === NaN;     // false
[] == ![];       // true

== führt vor dem Vergleich eine Typumwandlung durch, === nicht – deshalb ist 0 == "0" true, aber 0 === "0" false. Die kontraintuitiven Fälle: null == undefined ist aufgrund einer Sonderregel true (und beide sind unter == mit nichts anderem gleich); NaN ist niemals gleich irgendetwas, auch nicht sich selbst; und [] == ![] ist true, weil ![] false ergibt, was zu 0 umgewandelt wird, und [] zu "" und dann zu 0 umgewandelt wird. Der vollständige Algorithmus ist im MDN-Leitfaden zu Gleichheitsvergleichen beschrieben.

Was wirklich getestet wird: ob man Typumwandlung vorhersagen kann – nicht ob man „immer === verwenden” aufsagen kann. Der Interviewer möchte den Mechanismus verstehen, dann die Faustregel.

Wahrscheinliche Folgefrage: „Was passiert bei der Zuweisung an eine nicht deklarierte Variable?” – Ob value = 42 (ohne var/let/const) einen Fehler wirft oder stillschweigend eine globale Variable erstellt, hängt vom Modus ab: Im Strict Mode und in ES-Modulen wird ein ReferenceError geworfen; in Sloppy-Mode-Skripten wird eine implizite globale Variable erstellt. Derselbe Code-Ausschnitt, zwei korrekte Antworten – diese Unterscheidung zu benennen ist selbst ein Signal für Senior-Niveau.

Was einen eingestellten Kandidaten von einem abgelehnten unterscheidet

Der rote Faden durch alle zehn Fragen: Interviewer bewerten die Erklärung, nicht nur die Ausgabe. 3 3 3 oder 1 4 3 2 vorherzusagen beweist, dass man das Rätsel kennt; das Binding-Modell, die Scope Chain, die Aufrufstellen-Regel und das zweistufige Event-Loop-Modell zu benennen beweist, dass man die Sprache gut genug versteht, um unter Druck debuggen zu können. Diese zehn Fragen so lange üben, bis man das Warum in zwei Sätzen ohne Notizen erklären kann – und da keines dieser Verhaltensweisen versionsabhängig ist, kann man jeden Code-Ausschnitt in jeder aktuellen Engine selbst überprüfen und dem Ergebnis vertrauen.

FAQs

Was ist der Unterschied zwischen der Temporal Dead Zone und einer Variable, die einfach undefined ist?

Eine Variable in der Temporal Dead Zone wurde gehoisted, aber noch nicht initialisiert, sodass ein Zugriff darauf einen ReferenceError wirft. Eine undefined var hingegen wurde gehoisted und mit dem Wert undefined initialisiert, sodass ein Zugriff undefined zurückgibt. Nur let und const erzeugen eine TDZ; sie beginnt beim Betreten des Scopes und endet, wenn die Deklarationszeile ausgeführt wird. Der Unterschied liegt in der Binding-Erstellung gegenüber der Binding-Initialisierung.

Warum funktionieren Arrow Functions nicht als Objektmethoden oder Konstruktoren?

Arrow Functions haben kein eigenes this-Binding und können daher keine Rollen übernehmen, die eines erfordern. Als Objektmethode verwendet, erfasst eine Arrow Function this aus dem umschließenden lexikalischen Scope statt aus dem Objekt, sodass this.property nicht auf das Objekt zeigt. Bei Verwendung mit new wirft die Engine einen TypeError, weil kein this-Binding vorhanden ist, dem die neue Instanz zugewiesen werden könnte. Für beide Fälle sollten reguläre Funktionen verwendet werden.

Verhält sich der setTimeout-in-einer-Schleife-Bug in jeder JavaScript-Engine gleich?

Ja. Die var-Version gibt in jeder aktuellen Engine den finalen Schleifenwert aus, weil var ein einziges function-scoped Binding erstellt, das alle Callbacks teilen. Die let-Version gibt überall iterationsspezifische Werte aus, weil die Spezifikation für let in einer for-Schleife ein neues Binding pro Iteration definiert. Dieses Verhalten ist in ECMA-262 spezifiziert, nicht engine-spezifisch, sodass Node.js, V8, SpiderMonkey und JavaScriptCore identische Ausgaben erzeugen.

Ändert async/await die Event-Loop-Priorität im Vergleich zu Promise.then?

Nein. Code nach einem await wird als Microtask ausgeführt, mit exakt derselben Priorität wie ein Promise.then-Callback, und wird daher vor jeder setTimeout-Macrotask ausgeführt. Ein await pausiert die Funktion effektiv und stellt die Fortsetzung in die Microtask-Queue, sobald der erwartete Wert verfügbar ist. Das bedeutet: Eine awaited Fortsetzung und ein zum selben Zeitpunkt eingereihter then-Callback werden in der Quellcode-Reihenfolge aufgelöst, und beide werden vor jedem auf null Millisekunden gesetzten Timer-Callback ausgeführt.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.