Svelte-Reaktivität: der Compiler statt Virtual DOM
Svelte kompiliert Komponenten zur Build-Zeit in gezielten DOM-Code, statt zur Laufzeit einen Virtual DOM zu diffen. Ich zeige das mentale Modell, die reaktive Zuweisung mit $: und die klassische push-Falle, die mir in Projekten immer wieder begegnet.
Zwei Regeln tragen einen großen Teil des Legacy-Modells von Svelte 3. Erstens: Der Compiler erkennt Zuweisungen wie count += 1, obj.key = value oder array[index] = value; ein bloßer Methodenaufruf wie array.push(...) ist keine solche Zuweisung. Zweitens: Eine $:-Anweisung hängt an den Variablen, die der Compiler in ihrem Rumpf statisch erkennt. Daraus folgen die direkte Syntax und einige typische Überraschungen.
Beide Regeln stammen aus derselben Quelle. Svelte entscheidet schon beim Übersetzen einer Komponente, welche Variable welchen DOM-Knoten berührt. Was im Browser ankommt, sind fertige Update-Anweisungen; ein Baumvergleich zur Laufzeit findet nicht statt. count += 1 genügt deshalb tatsächlich – aber nur, solange man weiß, woran der Compiler eine Änderung erkennt.
Grundlage ist der Stand von Svelte 3, im April 2022 die aktuelle Hauptversion. Alles Folgende beruht auf dem assignment-basierten Reaktivitätsmodell und den $:-Deklarationen. Vollständig ist das nicht; es ist der Ausschnitt, an dem sich das mentale Modell am schnellsten begreifen lässt.
Redaktionelle Einordnung (Juli 2026): Svelte 5 arbeitet für neuen Code bevorzugt mit Runes wie $state, $derived und $effect. Das hier erklärte $:- und Zuweisungsmodell gehört zum weiterhin unterstützten Legacy-Modus. „Ohne Virtual DOM“ stimmt nach wie vor, beschreibt allein aber nicht mehr die heutige Reaktivitäts-API.
Der Denkfehler, den fast alle mitbringen
Wer aus React kommt, trägt ein bestimmtes Bild im Kopf: Eine Komponente ist eine Funktion, die bei jeder Zustandsänderung neu läuft und eine Beschreibung des gewünschten UI-Zustands zurückgibt – einen Virtual-DOM-Baum. Das Framework vergleicht diesen neuen Baum mit dem alten, findet die Unterschiede und überträgt nur die auf das echte DOM. Dieses Diffing ist clever, aber es ist Arbeit, die bei jedem Update im Browser des Nutzers stattfindet. Rich Harris hat das in seinem oft zitierten Text „Virtual DOM is pure overhead“ scharf formuliert: Der Virtual DOM ist kein Feature, für das man dankbar sein sollte, sondern ein Mittel zum Zweck – und ein Mittel mit Preis.
Svelte stellt die Frage anders. Wenn schon zur Build-Zeit feststeht, welche Teile des Markups von welchen Variablen abhängen, warum sollte man diese Zuordnung dann bei jedem Update im Browser neu ausrechnen? Der Svelte-Compiler analysiert die Komponente statisch und erzeugt imperativen JavaScript-Code, der genau die betroffenen DOM-Knoten aktualisiert. Kein Diff-Baum, kein Vergleich zur Laufzeit. Der Compiler weiß schon, dass sich bei einer Änderung von count genau dieser eine Textknoten ändern muss, und schreibt exakt diese Anweisung hin.
Wichtig ist mir dabei eine Präzisierung, die in Blogposts gern verschwimmt: Svelte ist nicht „gar keine Runtime“. Der kompilierte Code bringt einen kleinen Kern an Hilfsfunktionen mit, in der Größenordnung von wenigen Kilobyte. Was fehlt, ist der Laufzeit-Diff-Algorithmus und ein schwergewichtiges Framework, das im Bundle mitreist. Der korrekte Satz lautet also: kein Virtual DOM, kein Laufzeit-Diffing – nicht: null Runtime.
Was der Compiler übernimmt
Bevor ich zum Code komme, lohnt sich der Blick darauf, welche Aufgaben in Svelte vom Compiler statt von einer Laufzeit-Bibliothek erledigt werden. Genau diese Verschiebung erklärt, warum das Ganze weniger Overhead im Browser bedeutet:
- Die statische Analyse, welcher DOM-Knoten von welcher Zustandsvariablen abhängt – das Ergebnis landet als fester Code im Bundle, nicht als Berechnung zur Laufzeit.
- Die Erzeugung gezielter Update-Anweisungen, die nur die tatsächlich betroffenen Knoten anfassen, statt einen kompletten Baum zu vergleichen.
- Die Auflösung der
$:-Abhängigkeiten: Der Compiler bestimmt aus dem Quelltext, welche Variablen eine reaktive Anweisung liest, und erzeugt daraus den Code für die Neuauswertung.
Dieser letzte Punkt ist der, an dem die meisten Fallen liegen, und ihm widme ich weiter unten den größten Teil.
Reaktivität über Zuweisung
Schauen wir uns die einfachste reaktive Komponente an. Eine Datei mit der Endung .svelte bündelt Skript, Markup und Style. Eine ganz normale let-Variable auf der obersten Ebene des <script>-Blocks ist bereits Komponentenzustand.
<script>
let count = 0;
function handleClick() {
count += 1;
}
</script>
<button on:click={handleClick}>
Clicked {count} times
</button>
Es gibt hier keine spezielle Zustands-API. count ist eine Variable, count += 1 ist eine Zuweisung. Der Punkt, an dem für mich alles hängt: Nicht die Variable an sich ist reaktiv, sondern die Zuweisung an sie ist das auslösende Ereignis. Der Compiler hat gesehen, dass count im Markup referenziert wird. Also erzeugt er um jede Zuweisung herum den Code, der die Komponente als „dirty“ markiert und den betroffenen Textknoten neu schreibt. Was aussieht wie eine gewöhnliche Wertänderung, ist im kompilierten Ergebnis von einem gezielten Update begleitet.
Das erklärt auch, warum manche Änderungen ins Leere laufen. Wer count nur liest oder in einer Hilfsfunktion herumreicht, löst nichts aus. Erst der Zuweisungsoperator ist das Signal.
Abgeleitete Werte mit $:
Der nächste Baustein sind Werte, die sich aus anderen ergeben. In React greift man dafür zu useMemo oder rechnet im Render-Körper. In Svelte 3 gibt es die reaktive Deklaration mit dem $:-Label.
<script>
let count = 0;
$: doubled = count * 2;
function handleClick() {
count += 1;
}
</script>
<button on:click={handleClick}>
Clicked {count} times
</button>
<p>{count} * 2 = {doubled}</p>
$: doubled = count * 2; liest sich wie eine normale Zuweisung mit einem seltsamen Präfix, ist aber eine Anweisung an den Compiler: „Halte doubled aktuell, sobald sich eine seiner Abhängigkeiten ändert.“ Die Abhängigkeiten muss ich dabei nicht selbst angeben. Der Compiler bestimmt sie zur Compile-Zeit aus dem Quelltext – es sind alle Variablen, die in der Anweisung gelesen, aber nicht zugewiesen werden. Hier also count. Ändert sich count per Zuweisung, läuft die Deklaration erneut, doubled bekommt einen neuen Wert, und der abhängige Textknoten aktualisiert sich.
$: kann mehr als abgeleitete Werte. Man kann damit auch Seiteneffekte an Zustandsänderungen koppeln:
<script>
let count = 0;
$: if (count >= 10) {
alert("count is dangerously high!");
}
$: console.log(`count is ${count}`);
</script>
Beide Formen laufen erneut, sobald sich count ändert. Der Mechanismus ist derselbe wie bei der Deklaration: gelesene Variablen werden zu Abhängigkeiten, Zuweisungen an sie lösen die Neuauswertung aus. Ein Detail für die Praxis: Diese Anweisungen laufen auch beim serverseitigen Rendern und vor dem ersten Client-Render einmal durch. Sie gehören zum Reaktivitätsfluss selbst und sind kein Ersatz für einen Mount-Hook.
Das folgende Diagramm zeigt diesen Fluss von der Zuweisung bis zum DOM-Update – inklusive des Seitenpfads, der später zur Falle wird.
flowchart TD A[Zuweisung: count += 1] --> B[Variable als dirty markiert] B --> C[reaktive $:-Deklarationen<br/>neu ausgewertet] C --> D[gezieltes DOM-Update<br/>nur betroffene Knoten] E[array.push item] -.-> F[keine Zuweisung<br/>keine Referenzänderung] F -.-> G[kein Update ausgelöst]
Build-Zeit gegen Laufzeit
Der Kontrast zu React wird deutlich, wenn man beide Spuren nebeneinanderlegt. Svelte bestimmt Abhängigkeiten und erzeugt Update-Code beim Build; der Browser führt diesen Code bei jeder Änderung weiterhin aus. React erzeugt beim Rendern einen neuen Elementbaum und gleicht ihn zur Laufzeit ab. Svelte verschiebt also die Analyse, nicht die gesamte Aktualisierungsarbeit, in den Build.
flowchart LR
subgraph Build[Build-Zeit – Svelte]
S1[.svelte-Komponente] --> S2[Svelte-Compiler]
S2 --> S3[gezielter Update-Code<br/>optimiertes Vanilla-JS]
end
subgraph Runtime[Laufzeit – React]
R1[Render-Funktion] --> R2[neuer Virtual-DOM-Baum]
R2 --> R3[Diff gegen alten Baum]
R3 --> R4[Patch auf echtes DOM]
end
Der Kern des Bildes ist nicht „Svelte gut, React schlecht“, sondern eine andere Aufteilung der Arbeit. React gleicht Elementbäume zur Laufzeit ab. Svelte kennt die statisch sichtbaren Abhängigkeiten bereits nach dem Build und liefert dafür gezielten Update-Code aus. Der Nutzer führt diesen Code weiterhin aus, nur keinen Virtual-DOM-Diff für diese Aktualisierung.
Bei den Bundle-Größen bleibe ich bewusst vorsichtig. Der Svelte-Kern liegt in der Größenordnung von wenigen Kilobyte, React plus ReactDOM deutlich darüber. Ich nenne das als Größenordnung, nicht als exakte Messung – die konkreten Zahlen hängen von Version, Bundler und dem, was man tatsächlich nutzt, ab, und wer eine harte Zahl braucht, misst am eigenen Projekt.
Die push-Falle
Jetzt zur Kehrseite desselben Modells – der Teil, um den es mir hier eigentlich geht. Weil Reaktivität an die Zuweisung gebunden ist, gibt es eine Klasse von Änderungen, die Svelte 3 schlicht nicht bemerkt: in-place-Mutationen ohne Zuweisung. Der Klassiker ist Array.push.
<script>
let numbers = [1, 2, 3, 4];
function addBroken() {
numbers.push(numbers.length + 1);
}
</script>
<button on:click={addBroken}>Add number</button>
<p>{numbers.join(", ")}</p>
Man klickt, die Konsole bestätigt, dass das Array wächst – und im Browser tut sich nichts. Kein Fehler, kein Warnhinweis, einfach kein Update. Der Grund ist konsequent aus dem Modell abgeleitet: numbers.push(...) verändert das bestehende Array, aber es gibt keine Zuweisung an numbers. Ohne Zuweisung markiert Svelte die Variable nicht als dirty, und ohne dirty-Markierung läuft kein Update. Der Compiler kann nicht wissen, dass eine Methode das Innere des Arrays angefasst hat – er sieht nur Zuweisungen.
Der Fix ist so einfach wie das Problem, sobald man das Modell verinnerlicht hat: Man muss eine Zuweisung erzwingen. Zwei Wege sind gängig.
<script>
let numbers = [1, 2, 3, 4];
function addWorking() {
numbers = [...numbers, numbers.length + 1];
}
function addAlsoWorking() {
numbers.push(numbers.length + 1);
numbers = numbers;
}
</script>
<button on:click={addWorking}>Add number</button>
<ul>
{#each numbers as n}
<li>{n}</li>
{/each}
</ul>
numbers = [...numbers, ...] erzeugt ein neues Array und weist es zu – die saubere, an anderen Frameworks geschulte Variante. numbers = numbers sieht dagegen aus wie eine Nulloperation, ist aber genau das, was der Compiler braucht: eine Zuweisung an numbers, die die dirty-Markierung setzt. Damit Svelte eine Änderung überhaupt bemerkt, muss der Name der Variablen also auf der linken Seite eines = landen.
Bei Objekten ist die Lage anders als beim Methodenaufruf: Eine direkte Zuweisung wie obj.key = value erkennt Sveltes Legacy-Compiler und aktualisiert abhängige Stellen. Nicht sichtbar bleibt eine Mutation, die nur innerhalb einer aufgerufenen Funktion oder Methode geschieht. Dann braucht es anschließend eine für den Compiler sichtbare Zuweisung oder einen unveränderlichen Ersatz per Spread.
Die stille Schwester: indirekte Abhängigkeiten
Es gibt eine zweite Falle, die aus demselben Prinzip folgt und in Reviews seltener sofort erkannt wird. Der Compiler bestimmt die Abhängigkeiten einer $:-Anweisung daraus, welche Variablen im Body direkt referenziert werden. Versteckt man eine Variable hinter einem Funktionsaufruf, taucht sie im Body nicht auf – und wird nicht als Abhängigkeit erkannt.
<script>
let count = 0;
function getCount() {
return count;
}
// count is not read directly here,
// so it is not a detected dependency
$: label = `value is ${getCount()}`;
</script>
label wird beim ersten Durchlauf berechnet und danach nie wieder aktualisiert, weil count im Body der reaktiven Deklaration nicht sichtbar ist – dort steht nur getCount(). Der Compiler hat keine Möglichkeit, die Abhängigkeit durch die Funktionsgrenze hindurch zu erkennen. Die Lösung ist, die Abhängigkeit sichtbar zu machen, also count direkt in der Anweisung zu referenzieren. Eine Schwäche ist das nicht; es ist die logische Konsequenz eines Modells, das statisch zur Compile-Zeit analysiert, statt dynamisch zur Laufzeit zu verfolgen.
Was das für die Praxis heißt
Wenn ich diese beiden Fallen zusammenziehe, ergibt sich ein erstaunlich kompaktes mentales Modell für Svelte 3, das sich auf zwei Punkte eindampfen lässt:
- Reaktivität wird im Legacy-Modus durch statisch sichtbare Zuweisungen ausgelöst. Dazu zählen auch direkte Property- und Index-Zuweisungen, nicht aber beliebige Methodenaufrufe mit internen Mutationen.
- Abhängigkeiten von
$:müssen im Body direkt sichtbar sein. Was hinter einem Funktionsaufruf verschwindet, wird nicht getrackt.
Diese zwei Regeln decken einen großen Teil der Überraschungen ab, die mir in Legacy-Svelte-Projekten begegnen. Beide folgen aus derselben Designentscheidung: Die Abhängigkeiten werden beim Build statisch analysiert. Das verlangt Wachsamkeit an Funktionsgrenzen und bei Methoden, die intern mutieren.
In einem kleinen Daten-Client tragen genau diese reaktiven Zuweisungen den Datenfluss: Eine Antwort ersetzt den bisherigen Wert, und die abhängigen Ausdrücke werden vom Compiler gezielt aktualisiert.
Fazit
Svelte kehrt eine Grundannahme vieler Frontend-Frameworks um: Statt zur Laufzeit einen Virtual DOM zu vergleichen, lässt es den Compiler beim Build bestimmen, welcher Zustand welchen DOM-Knoten berührt, und erzeugt daraus gezielten Update-Code. Damit entfällt diese Kategorie von Laufzeit-Diffing; wie groß der praktische Vorteil ist, hängt von Anwendung und Update-Muster ab. Der Alltagscode bleibt angenehm direkt – count += 1 genügt. Im Legacy-Modus hängt Reaktivität an statisch sichtbaren Zuweisungen. Wer das verinnerlicht, versteht sowohl die push-Falle als auch indirekte Abhängigkeiten.
Weiterführende Quellen
- Foliensatz zu diesem Thema: https://supabase.mikebild.dev/storage/v1/object/public/slides/introduction-svelte.html
- Svelte Docs – die
$:-Syntax und die zur Compile-Zeit bestimmten Abhängigkeiten im Component-Format - Svelte Tutorial – die Kapitel zu Reactivity, Assignments und Updating arrays and objects
- Virtual DOM is pure overhead – Rich Harris zur Motivation hinter dem Compiler-Ansatz
- Write less code – das Leitprinzip von Svelte, weniger Laufzeit-Code auszuliefern
Wie fandest du diesen Beitrag?
Kommentare