Vue 3 Proxy-Reaktivität: Wie der Reactivity-Kern wirklich funktioniert
AI generated
<v/>
{ }
Vue.js · Reactivity · JavaScript Proxy · Internas
Vue 3 Proxy-Reaktivität
wie der Reactivity-Kern wirklich funktioniert

Hinter jedem reactive()-Aufruf steckt ein ES2015-Proxy mit get- und set-Traps, ein WeakMap-Dependency-Graph und ein feingranulares track/trigger-System. Wer die Proxy-Reaktivität von Vue 3 wirklich versteht, erkennt sofort, warum Destrukturierung Reaktivität zerstört und warum manche Objekte niemals reaktiv werden.

18 Min. Lesezeit Proxy · track/trigger · WeakMap · Dependency Graph Vue 3.x · @vue/reactivity

1. Warum Proxy statt Object.defineProperty

Vue 2 baute seine Reaktivität auf Object.defineProperty() auf, indem jede Objekteigenschaft einzeln in einen Getter und Setter umgewandelt wurde. Das funktionierte, hatte aber zwei strukturelle Grenzen: Neue Eigenschaften, die nach der Initialisierung hinzugefügt wurden, blieben unreaktiv, weil niemand für sie einen Getter/Setter definiert hatte. Und Array-Indizes ließen sich mit defineProperty nicht effizient abfangen, weshalb Vue 2 Array-Mutationsmethoden wie push und splice patchen musste. Die Vue 3 Proxy-Reaktivität löst beide Probleme grundlegend, weil ein Proxy nicht auf einzelne, vorher bekannte Eigenschaften angewiesen ist.

Ein Proxy in modernem JavaScript umschließt ein komplettes Objekt und fängt Operationen wie Lesen, Schreiben, Löschen und Existenzprüfung über sogenannte Traps ab, unabhängig davon, welche Eigenschaft betroffen ist. Damit ist die Vue 3 Proxy-Reaktivität von Natur aus vollständig: Ein neu hinzugefügtes Property, ein gelöschtes Property und ein Array-Index werden alle über denselben Mechanismus erfasst. Das reduziert nicht nur Sonderfälle im Framework-Code selbst, sondern macht auch das mentale Modell für Entwickler einfacher, weil reaktives Verhalten nicht mehr von der Art der Mutation abhängt.

2. Wie reactive() intern einen Proxy erzeugt

Ruft man reactive(obj) auf, prüft Vue zunächst, ob obj bereits ein reaktiver Proxy ist oder bereits einen zugehörigen Proxy besitzt, um doppelte Wrapper zu vermeiden. Ist das nicht der Fall, erstellt Vue einen new Proxy(target, handler) mit einem Handler-Objekt, das get, set, has, deleteProperty und ownKeys implementiert. Der get-Trap ruft bei jedem Lesezugriff die Funktion track(target, key) auf, die den aktuell laufenden Effekt als Abhängigkeit dieser Kombination aus Zielobjekt und Schlüssel registriert. Genau hier entsteht die Feingranularität der Vue 3 Proxy-Reaktivität: Nicht das ganze Objekt wird als Abhängigkeit markiert, sondern exakt die gelesene Eigenschaft.

Der set-Trap prüft zunächst, ob sich der neue Wert überhaupt vom alten unterscheidet, mit einem Object.is()-Vergleich, um unnötige Updates zu vermeiden. Ändert sich der Wert, ruft er trigger(target, key) auf, was alle für diesen Schlüssel registrierten Effekte erneut ausführt. Verschachtelte Objekte werden nicht sofort rekursiv in Proxies gewandelt, sondern erst lazily, wenn eine verschachtelte Eigenschaft tatsächlich gelesen wird. Der get-Trap prüft dafür, ob der zurückgegebene Wert ein Objekt ist, und wickelt ihn bei Bedarf erst dann in reactive() ein. Diese verzögerte Konvertierung spart Rechenzeit bei großen, tief verschachtelten Datenstrukturen, die nie vollständig gelesen werden.


// Simplified excerpt from @vue/reactivity — baseHandlers.ts
function createGetter(isReadonly = false, shallow = false) {
  return function get(target, key, receiver) {
    const res = Reflect.get(target, key, receiver)

    if (!isReadonly) {
      // Track the current active effect as a dependency of target+key
      track(target, TrackOpTypes.GET, key)
    }

    if (shallow) {
      return res
    }

    // Lazy nested reactivity: only wrap objects when actually read
    if (isObject(res)) {
      return isReadonly ? readonly(res) : reactive(res)
    }

    return res
  }
}

function createSetter() {
  return function set(target, key, value, receiver) {
    const oldValue = target[key]
    const result = Reflect.set(target, key, value, receiver)

    // Only trigger effects if the value actually changed
    if (!Object.is(value, oldValue)) {
      trigger(target, TriggerOpTypes.SET, key, value, oldValue)
    }

    return result
  }
}

3. Dependency Tracking mit track und trigger

Das Herzstück der Vue 3 Proxy-Reaktivität ist eine dreistufige Datenstruktur: eine globale WeakMap, die jedes Zielobjekt auf eine Map abbildet, die wiederum jeden Schlüssel auf ein Set von Effekten abbildet. Diese Struktur heißt intern targetMap. Die WeakMap als äußere Ebene ist bewusst gewählt, weil sie Garbage Collection nicht behindert: Sobald ein Zielobjekt anderswo nicht mehr referenziert wird, kann es zusammen mit seinen Abhängigkeiten aus dem Speicher entfernt werden, ohne dass Vue explizit aufräumen muss.

Beim Aufruf von track() während eines aktiven Effekts, etwa innerhalb einer computed-Eigenschaft oder eines watchEffect, wird der Effekt in das passende Set für Objekt und Schlüssel eingetragen. Beim Aufruf von trigger() nach einer Mutation wird dieses Set nachgeschlagen und jeder enthaltene Effekt erneut ausgeführt. Weil Effekte in Sets gespeichert werden, meldet sich ein Effekt, der eine Eigenschaft mehrfach liest, trotzdem nur einmal als Abhängigkeit an. Diese Deduplizierung ist ein wichtiger Grund, warum die Vue 3 Proxy-Reaktivität auch bei komplexen Templates mit vielen Lesezugriffen effizient bleibt.


// Simplified excerpt — the core dependency graph
const targetMap = new WeakMap()

function track(target, type, key) {
  if (!activeEffect) return

  let depsMap = targetMap.get(target)
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()))
  }

  let dep = depsMap.get(key)
  if (!dep) {
    depsMap.set(key, (dep = new Set()))
  }

  dep.add(activeEffect)
  activeEffect.deps.push(dep)
}

function trigger(target, type, key) {
  const depsMap = targetMap.get(target)
  if (!depsMap) return // Never tracked, nothing to do

  const dep = depsMap.get(key)
  if (dep) {
    // Copy into an array before iterating: effects may re-add themselves
    const effects = [...dep]
    effects.forEach((effect) => {
      if (effect.scheduler) {
        effect.scheduler()
      } else {
        effect.run()
      }
    })
  }
}

4. Sonderfälle: Arrays, Map und Set

Arrays brauchen in der Vue 3 Proxy-Reaktivität eine Sonderbehandlung, weil Methoden wie push, pop und splice intern sowohl die length-Eigenschaft als auch mehrere Indizes gleichzeitig lesen und schreiben. Ohne Anpassung würde push() mehrfach track für length auslösen und dabei versehentlich Endlosschleifen bei gleichzeitig aktiven Effekten provozieren. Vue instrumentiert deshalb genau diese Array-Methoden in einem separaten Handler, der während der Ausführung der Methode das Tracking kurz deaktiviert und danach genau einen trigger für die relevanten Änderungen auslöst.

Für Map, Set, WeakMap und WeakSet gilt eine ähnliche Herausforderung, weil deren native Methoden wie get, set, has und size nicht über den normalen Property-Zugriff laufen, sondern interne Slots verwenden, die ein Proxy nicht direkt abfangen kann. Vue liefert dafür ein eigenes collectionHandlers-Modul, das diese Methoden ersetzt und manuell track und trigger aufruft. Wer eine eigene Datenstruktur auf Basis von Map reaktiv machen will, muss dieses Detail kennen, sonst wundert man sich, warum reactiveMap.get('key') in einem Template nicht wie erwartet reagiert, wenn direkt am nativen Objekt vorbeigearbeitet wird.

5. ref() als Proxy-Sonderfall

Anders als reactive() nutzt ref() intern keinen Proxy für primitive Werte wie Zahlen oder Strings, weil ein Proxy immer ein Objekt umschließen muss, primitive Werte in JavaScript aber keine Objekte sind. Stattdessen ist ref() eine Klasse namens RefImpl mit einem get value()- und set value()-Accessor, die intern manuell trackRefValue und triggerRefValue aufrufen, eine Art handgeschriebene Miniatur der Vue 3 Proxy-Reaktivität ohne echten Proxy. Übergibt man ref() jedoch ein Objekt, wandelt Vue dieses Objekt intern mit reactive() um und speichert den resultierenden Proxy in RefImpl.value.

Das erklärt ein häufiges Verwirrungsmoment: const r = ref({ count: 0 }); console.log(r.value === reactive(r.value)) ergibt true, weil beide Zugriffe auf denselben zwischengespeicherten Proxy zeigen. Im <template>-Block entfernt Vue das .value automatisch über eine Compiler-Transformation, genannt Ref-Unwrapping, aber innerhalb von <script setup>-Code muss .value immer explizit geschrieben werden, außer bei verschachtelten Refs innerhalb eines reactive()-Objekts, wo Vue das Unwrapping ebenfalls automatisch übernimmt.

6. readonly() und shallowReadonly()

readonly() erzeugt ebenfalls einen Proxy, verwendet dabei aber einen anderen set-Trap, der jede Schreiboperation abfängt, eine Warnung in die Konsole ausgibt und den Schreibvorgang stillschweigend verwirft. Intern nutzt Vue dafür denselben get-Trap-Generator wie reactive(), aber mit dem Flag isReadonly = true, wodurch track() übersprungen wird, weil ein schreibgeschütztes Objekt sich per Definition nie ändert und deshalb kein Tracking benötigt. Diese Optimierung der Vue 3 Proxy-Reaktivität spart bei großen, unveränderlichen Datenmengen wie Konfigurationsobjekten spürbar Overhead.

shallowReadonly() geht noch einen Schritt weiter und verhindert die rekursive Umwandlung verschachtelter Objekte in weitere Readonly-Proxies. Nur die oberste Ebene ist geschützt, tiefer verschachtelte Objekte bleiben im Originalzustand veränderlich. Dieses Pattern eignet sich besonders für Props, die als externe Schnittstelle nicht mutiert werden sollen, deren interne Struktur aus Performance-Gründen aber nicht komplett in verschachtelte Proxies verpackt werden muss. Der Unterschied zwischen tiefem und flachem Readonly zeigt exemplarisch, wie granular sich die Vue 3 Proxy-Reaktivität konfigurieren lässt, je nachdem wie tief Schutz und Tracking wirklich benötigt werden.

7. Reaktivitätsverlust erkennen und vermeiden

Der häufigste Fehler im Umgang mit der Vue 3 Proxy-Reaktivität ist die Destrukturierung eines reaktiven Objekts: const { count } = reactiveState kopiert den aktuellen Wert von count in eine neue, unabhängige Variable, die keinerlei Verbindung mehr zum Proxy hat. Der Grund liegt in der Natur von JavaScript selbst: Destrukturierung liest den Wert einmalig aus, sie erzeugt keinen Verweis auf die Proxy-Eigenschaft. Die einzige Lösung ist toRefs(), das aus jeder Eigenschaft eines reaktiven Objekts einen einzelnen ref erzeugt, der intern weiterhin über get/set mit dem Original-Proxy verbunden bleibt.

Ein zweiter Stolperstein ist Object.assign(reactiveState, newData) in Kombination mit anschließender Destrukturierung: Das Assign selbst funktioniert reaktiv korrekt, weil es über den Proxy-set-Trap läuft, aber jede danach durchgeführte Destrukturierung bleibt trotzdem eine Momentaufnahme. Auch der Vergleich rawObject === reactiveProxy ist ein häufiger Fehler: Ein reaktiver Proxy ist niemals identisch mit seinem ursprünglichen Zielobjekt, weshalb Referenzvergleiche zwischen roher und reaktiver Version fast immer false zurückgeben, selbst wenn beide dieselben Werte enthalten. Mit toRaw() lässt sich das ursprüngliche, ungewrappte Objekt gezielt zurückholen, etwa um es unverändert an eine externe Bibliothek zu übergeben, die Proxy-Traps nicht erwartet.


import { reactive, toRefs, toRaw, isReactive } from 'vue'

const state = reactive({ count: 0, name: 'Vue' })

// WRONG: destructuring breaks the connection to the proxy
const { count } = state
count // 0, frozen forever, never updates again

// RIGHT: toRefs keeps the live link via individual refs
const { count: countRef } = toRefs(state)
countRef.value // stays in sync with state.count

// Identity check: proxy is never the same object as the target
const raw = { count: 0 }
const wrapped = reactive(raw)
console.log(raw === wrapped) // false

// toRaw() retrieves the original, unwrapped object
console.log(toRaw(wrapped) === raw) // true
console.log(isReactive(toRaw(wrapped))) // false

8. Debugging mit onTrack und onTrigger

Weil die Vue 3 Proxy-Reaktivität vollständig implizit funktioniert, ist sie ohne Werkzeuge schwer zu beobachten. watchEffect und computed akzeptieren dafür optionale Debug-Hooks: onTrack wird immer dann aufgerufen, wenn eine reaktive Abhängigkeit registriert wird, onTrigger immer dann, wenn eine registrierte Abhängigkeit den Effekt erneut auslöst. Beide Callbacks erhalten ein Event-Objekt mit Zielobjekt, Schlüssel und Operationstyp, wodurch sich exakt nachvollziehen lässt, welche Eigenschaft welchen Effekt ausgelöst hat.

In der Praxis sind diese Hooks besonders bei unerwarteten Re-Renders wertvoll: Statt zu raten, welche der zehn reaktiven Properties eine teure Berechnung neu auslöst, setzt man temporär onTrigger(e) { debugger } und inspiziert im Browser-Debugger direkt, welche Mutation verantwortlich ist. Die Vue DevTools nutzen intern dieselben Hooks, um den Reactivity-Tab zu befüllen, der Abhängigkeitsgraphen visuell darstellt. Wer die Vue 3 Proxy-Reaktivität in großen Composables debuggt, sollte diese Hooks kennen, statt sich ausschließlich auf Trial-and-Error mit console.log zu verlassen.

9. Proxy-Reaktivität im Vergleich zur alten Implementierung

Die Umstellung von Object.defineProperty auf Proxy war keine kosmetische Änderung, sondern hat handfeste Konsequenzen für Vollständigkeit, Performance und Browser-Unterstützung. Die folgende Tabelle stellt die wichtigsten Unterschiede zwischen der Vue 2-Implementierung und der Vue 3 Proxy-Reaktivität gegenüber.

Aspekt Vue 2 (Object.defineProperty) Vue 3 (Proxy)
Neue Eigenschaften Nicht reaktiv, Vue.set() nötig Automatisch reaktiv über set-Trap
Array-Indizes Erfordert gepatchte Mutationsmethoden Direkt über Proxy-Trap erfasst
Initialisierungskosten Rekursiv beim Erstellen, alle Ebenen Lazy, nur bei tatsächlichem Lesen
Property löschen Nicht erkannt deleteProperty-Trap erfasst es
Browser-Unterstützung IE9+ Kein IE, Proxy nicht polyfillbar

Der einzige echte Nachteil der Vue 3 Proxy-Reaktivität ist der Verlust der Internet-Explorer-Unterstützung, weil sich der Proxy-Mechanismus aus Sprachdesign-Gründen nicht vollständig polyfillen lässt. Für alle modernen Deployment-Ziele überwiegen die Vorteile aber deutlich: vollständige Erfassung von Mutationen, geringerer Initialisierungsaufwand durch Lazy-Konvertierung und ein einheitliches mentales Modell für Objekte, Arrays und Collections.

Mironsoft

Vue 3 und Nuxt Entwicklung mit Fokus auf saubere Reaktivitätsarchitektur

Reaktivitätsbugs, die niemand im Team erklären kann?

Wir analysieren bestehende Vue-3-Codebasen auf Reaktivitätsverlust, unnötige Re-Renders und fragile Composables, und bauen daraus ein wartbares, gut dokumentiertes Reactivity-Fundament.

Reactivity-Audit

Codebasis auf Reaktivitätsverlust und ineffiziente Proxy-Nutzung prüfen

Composable-Refactoring

toRefs, customRef und EffectScope korrekt in bestehende Composables integrieren

Performance-Tuning

shallowRef und gezieltes Tracking für große, verschachtelte Datenstrukturen

10. Zusammenfassung

Die Vue 3 Proxy-Reaktivität ersetzt die eigenschaftsweise Instrumentierung von Vue 2 durch einen einzigen, vollständigen Wrapper-Mechanismus. reactive() erzeugt einen Proxy mit get- und set-Traps, die intern track() und trigger() aufrufen und dabei einen präzisen, dreistufigen Dependency-Graph in einer WeakMap pflegen. Arrays, Map und Set benötigen dafür jeweils eigene, spezialisierte Handler, weil ihre nativen Methoden nicht über den normalen Property-Zugriff laufen.

ref() nutzt für primitive Werte keinen echten Proxy, sondern eine handgeschriebene RefImpl-Klasse mit denselben Track/Trigger-Prinzipien. Reaktivitätsverlust entsteht fast immer durch Destrukturierung, die toRefs() gezielt verhindert. readonly() und shallowReadonly() zeigen, wie granular sich Schutz und Tracking konfigurieren lassen. Mit onTrack/onTrigger lässt sich jede Abhängigkeit im Detail beobachten, ohne auf Trial-and-Error angewiesen zu sein.

Vue 3 Proxy-Reaktivität — Das Wichtigste auf einen Blick

Kernmechanismus

reactive() erzeugt einen Proxy mit get/set-Traps, die track() und trigger() in einer WeakMap-Struktur aufrufen.

Sonderfälle

Arrays, Map und Set brauchen eigene Handler, weil ihre nativen Methoden interne Slots statt Property-Zugriff nutzen.

Reaktivitätsverlust

Destrukturierung kopiert Werte, keine Referenzen. toRefs() ist die einzige sichere Lösung dafür.

Debugging

onTrack und onTrigger in watchEffect/computed zeigen exakt, welche Eigenschaft welchen Effekt auslöst.

11. FAQ: Vue 3 Proxy-Reaktivität

1Warum Proxy statt Object.defineProperty?
Ein Proxy fängt alle Operationen ab, unabhängig von der einzelnen Eigenschaft. Neue Properties, gelöschte Properties und Array-Indizes werden automatisch erfasst.
2Was macht track() genau?
Registriert den aktiven Effekt als Abhängigkeit von Zielobjekt und Schlüssel, gespeichert in der WeakMap-Struktur targetMap.
3Warum WeakMap statt Map?
Verhindert künstliches Am-Leben-Halten von Objekten. Ungenutzte Zielobjekte werden zusammen mit ihren Abhängigkeiten vom Garbage Collector entfernt.
4Warum bricht Destrukturierung Reaktivität?
Sie kopiert den aktuellen Wert einmalig, ohne Verbindung zum Proxy. toRefs() erzeugt stattdessen echte, live verbundene Refs.
5Warum kein echter Proxy in ref()?
Primitive Werte sind keine Objekte und können nicht von einem Proxy umschlossen werden. RefImpl bildet Track/Trigger deshalb manuell nach.
6Warum eigene Handler für Map und Set?
Native Methoden nutzen interne Slots statt normaler Property-Zugriffe. Vue ersetzt sie gezielt im collectionHandlers-Modul.
7readonly() vs. shallowReadonly()?
readonly() schützt rekursiv alle Ebenen, shallowReadonly() nur die oberste. Tiefer verschachtelte Objekte bleiben bei shallowReadonly() veränderlich.
8Wie beobachte ich Track/Trigger-Events?
Mit den Debug-Hooks onTrack und onTrigger in watchEffect oder computed, die ein detailliertes Event-Objekt liefern.
9Warum ist Proxy !== Original?
Der Proxy ist ein separates Wrapper-Objekt. Ein === Vergleich mit dem Original liefert deshalb immer false.
10Wann toRaw() verwenden?
Wenn ein Objekt unverändert an externe Bibliotheken übergeben werden muss, die kein Proxy-Verhalten erwarten.