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 aus meiner Schulungspraxis.
In fast jeder Schulung, in der ich Svelte neben React stelle, kommt derselbe Moment: Jemand tippt count += 1, die Zahl im Browser springt hoch, und im Raum entsteht kurz Stille. Keine setState-Funktion, kein Hook, kein Reducer – eine einfache Zuweisung, und die Oberfläche ist aktuell. Der zweite Reflex ist dann meist Misstrauen: "Das kann doch nicht so einfach sein, da muss doch irgendwo ein Trick sein." Der Trick ist real, aber er passiert nicht zur Laufzeit im Browser. Er passiert vorher, beim Kompilieren. Und genau dieser Perspektivwechsel – Arbeit von der Laufzeit in die Build-Zeit verschieben – ist der Kern dessen, was Svelte anders macht.
Ich arbeite in diesem Artikel mit dem Stand von Svelte 3, das im April 2022 die aktuelle Serie ist. Alles, was ich zeige, beruht auf dem assignment-basierten Reaktivitätsmodell und den $:-Deklarationen. Das ist bewusst kein Vollständigkeitsanspruch, sondern der Ausschnitt, an dem sich das mentale Modell am schnellsten begreifen lässt.
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, den ich in Schulungen betone: 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 Reaktionen von Einsteigern 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. Es sind keine "nach dem Mount"-Hooks, sondern Teil des Reaktivitätsflusses selbst.
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
Um den Kontrast zu React greifbar zu machen, stelle ich in Schulungen gern die beiden Spuren nebeneinander. Auf der einen Seite Svelte, wo die Diff-Arbeit in die Build-Zeit wandert. Auf der anderen React, wo sie zur Laufzeit im Browser stattfindet.
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 die Verschiebung des Arbeitszeitpunkts. Bei React zahlt jeder Nutzer bei jedem Update für das Diffing. Bei Svelte zahlt man einmal beim Build – der Nutzer bekommt fertigen Update-Code. Rich Harris hat diese Haltung in "Write less code" verallgemeinert: Weniger Code, der zur Laufzeit ausgeführt wird, ist weniger, was schiefgehen und bremsen kann.
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, und das ist der Teil, wegen dem ich diesen Artikel überhaupt schreibe. 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. Die Faustregel, die ich meinen Teilnehmern mitgebe: Wenn du willst, dass Svelte etwas mitbekommt, muss der Name der Variablen auf der linken Seite eines = stehen.
Dasselbe gilt für Objekte. obj.key = value verändert das Objekt, löst aber kein Update aus, solange obj selbst nicht neu zugewiesen wird. Auch hier hilft ein nachgelagertes obj = obj oder ein Spread wie obj = { ...obj, key: value }.
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. Das ist keine Schwäche, sondern 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 ich am Ende jeder Einheit auf zwei Punkte eindampfe:
- Reaktivität wird durch Zuweisung ausgelöst, nicht durch Mutation. Wer die UI aktualisieren will, sorgt dafür, dass der Variablenname links von einem
=steht – per Neuzuweisung, Spread oder dem bewusstenx = x. - Abhängigkeiten von
$:müssen im Body direkt sichtbar sein. Was hinter einem Funktionsaufruf verschwindet, wird nicht getrackt.
Diese zwei Regeln decken den allergrößten Teil der Überraschungen ab, die mir in Svelte-Projekten begegnen. Und beide folgen unmittelbar aus derselben Designentscheidung: Die Analyse passiert zur Build-Zeit, nicht zur Laufzeit. Man tauscht ein bisschen Wachsamkeit bei Zuweisungen gegen den Wegfall des Laufzeit-Diffings.
Wer nach diesem Modell einen echten kleinen Anwendungsfall bauen möchte, findet in meinem Beitrag Micro-GraphQL: ein kleiner Svelte-Client ein Beispiel, in dem genau diese reaktiven Zuweisungen den Datenfluss tragen.
Fazit
Svelte kehrt eine Grundannahme moderner Frontend-Frameworks um: Statt zur Laufzeit einen Virtual DOM zu vergleichen, lässt es den Compiler zur Build-Zeit ausrechnen, welcher Zustand welchen DOM-Knoten berührt, und erzeugt daraus gezielten Update-Code. Das reduziert den Overhead im Browser spürbar und macht den Alltagscode angenehm direkt – count += 1 genügt. Der Preis dafür ist ein anderes mentales Modell: Reaktivität hängt an der Zuweisung, nicht am Wert. Wer das verinnerlicht, umschifft die push-Falle und die indirekten Abhängigkeiten fast automatisch. Wer es nicht tut, sucht irgendwann ratlos nach einem Update, das der Compiler nie in Auftrag bekommen hat. Beides ist dieselbe Medaille – und genau das finde ich an Svelte 3 lehrreich: Die Stärke und die Fallen entspringen ein und derselben Entscheidung.
Weiterführende Quellen
- Micro-GraphQL: ein kleiner Svelte-Client – ein kleines Svelte-Beispiel mit reaktivem Datenfluss aus meinem Blog
- 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
Kommentare