Reactivity Scheduler und nextTick in Vue 3 wirklich verstehen
AI generated
<v/>
{ }
Vue.js · Reaktivität · Scheduler · Event Loop
Reactivity Scheduler und nextTick
in Vue 3 wirklich verstehen

Ändert man zehn Refs hintereinander, rendert Vue nicht zehnmal, sondern einmal. Der Reactivity Scheduler bündelt DOM-Updates über die Microtask-Queue, und nextTick() ist das Werkzeug, um zuverlässig auf genau diesen Zeitpunkt zu warten, statt sich auf zufällige Reihenfolge zu verlassen.

18 Min. Lesezeit Scheduler · nextTick · Microtask Queue · Flush Timing Vue 3 · Vitest · Vue Test Utils

1. Warum synchrones Rendern bei jeder Ref-Änderung ein Problem wäre

Würde Vue bei jeder einzelnen Ref-Zuweisung sofort synchron einen kompletten Re-Render und DOM-Update auslösen, hätte das gravierende Performance-Konsequenzen. Eine Funktion, die zehn reaktive Werte hintereinander ändert, etwa beim Zurücksetzen eines Formulars, würde zehn separate DOM-Updates auslösen, obwohl nur das Endergebnis für den Nutzer sichtbar sein muss. Der Reactivity Scheduler verhindert genau das.

Statt synchron zu rendern, sammelt der Reactivity Scheduler alle ausgelösten Update-Jobs in einer Warteschlange und führt sie gebündelt in der nächsten Microtask aus, nachdem der gesamte synchrone JavaScript-Code, der die Änderungen verursacht hat, komplett durchgelaufen ist. Für zehn Ref-Änderungen in derselben Funktion bedeutet das ein einziges DOM-Update statt zehn.

Diese Bündelung ist keine Optimierung, die man optional aktivieren muss, sie ist fest im Kern von Vues Reaktivitätssystem verankert. Wer verstehen will, warum eine Komponente sich anders verhält als erwartet, etwa wenn direkt nach einer Zuweisung auf ein DOM-Element zugegriffen wird, muss den Reactivity Scheduler und sein Timing verstehen, weil genau dieses Timing die Ursache der meisten Überraschungen ist.

2. Der Scheduler im Detail: Microtask-Queue und Job-Dedupe

Intern verwaltet der Reactivity Scheduler eine Job-Queue, in die jede Komponente, deren reaktive Abhängigkeiten sich geändert haben, einen Render-Job einreiht. Ändert sich innerhalb desselben synchronen Ausführungsabschnitts eine weitere Abhängigkeit derselben Komponente, wird kein zweiter Job hinzugefügt, sondern der bestehende Job als bereits geplant erkannt und dedupliziert. Genau dieser Job-Dedupe-Mechanismus sorgt dafür, dass eine Komponente pro Flush-Zyklus höchstens einmal rendert, unabhängig davon, wie viele ihrer reaktiven Abhängigkeiten sich geändert haben.

Die Queue wird über Promise.resolve().then() als Microtask geplant, nicht über setTimeout(). Microtasks werden vom JavaScript-Event-Loop noch vor dem nächsten Rendering-Frame und vor allen Macrotasks wie setTimeout abgearbeitet, was bedeutet, dass DOM-Updates so früh wie möglich nach dem aktuellen synchronen Code passieren, ohne auf den nächsten Browser-Frame warten zu müssen.


import { ref, watchEffect } from 'vue'

const a = ref(0)
const b = ref(0)

watchEffect(() => {
  console.log('effect ran, a + b =', a.value + b.value)
})

function updateBoth() {
  a.value = 1 // schedules a job for this effect
  b.value = 2 // same effect, job already queued — deduplicated
  console.log('synchronous code finished')
}

updateBoth()
// Console output order:
// "synchronous code finished"
// "effect ran, a + b = 3"   <- runs once, in the microtask queue, not twice

3. nextTick() richtig einsetzen

nextTick() gibt ein Promise zurück, das genau dann aufgelöst wird, wenn der aktuelle Scheduler-Flush abgeschlossen ist, also nachdem alle ausstehenden DOM-Updates angewendet wurden. Das macht nextTick() zum richtigen Werkzeug, wenn man nach einer reaktiven Zustandsänderung auf das tatsächlich aktualisierte DOM zugreifen muss, etwa um die Höhe eines neu eingefügten Elements zu messen oder den Fokus auf ein neu gerendertes Input-Feld zu setzen.

Ohne nextTick() würde ein direkter Zugriff auf ref.value eines Template-Refs unmittelbar nach einer Zustandsänderung noch den alten DOM-Zustand liefern, weil der Scheduler das Update noch nicht ausgeführt hat. Die Lösung ist, entweder await nextTick() in einer async-Funktion zu nutzen oder nextTick(callback) mit einer Callback-Funktion aufzurufen, die erst nach dem DOM-Update ausgeführt wird.


import { ref, nextTick } from 'vue'

const showInput = ref(false)
const inputRef = ref(null)

async function revealAndFocus() {
  showInput.value = true // triggers a scheduler job, DOM not updated yet here

  // WITHOUT nextTick, inputRef.value would still be null at this point
  await nextTick() // waits until the DOM update has actually been applied

  inputRef.value?.focus() // now the element exists and can be focused
}

4. Warum mehrere Mutationen nur ein Update auslösen

Der praktische Effekt des Batchings zeigt sich am deutlichsten in einer Funktion, die mehrere Felder eines reaktiven Objekts nacheinander setzt, etwa beim Zurücksetzen eines Formulars auf Standardwerte. Ohne Scheduler-Batching würde jede einzelne Feldänderung ein eigenes Re-Render der betroffenen Komponente auslösen, mit spürbaren Performance-Kosten bei größeren Komponentenbäumen und potenziell sichtbarem Flackern zwischen Zwischenzuständen.

Mit dem Reactivity Scheduler sieht der Nutzer nur den finalen Zustand nach Abschluss aller synchronen Zuweisungen, niemals einen der Zwischenzustände. Das ist ein wichtiges mentales Modell: Innerhalb einer synchronen Funktion kann man reaktive Werte beliebig oft ändern, ohne sich um Zwischenrender-Zustände sorgen zu müssen, weil der Scheduler garantiert erst nach Abschluss des synchronen Codes rendert.


import { reactive } from 'vue'

const form = reactive({
  name: '',
  email: '',
  agreedToTerms: false
})

function resetForm() {
  // All three mutations happen synchronously —
  // the component re-renders only ONCE after this function returns,
  // never showing an intermediate state to the user
  form.name = ''
  form.email = ''
  form.agreedToTerms = false
}

5. Flush-Timing verstehen: pre, post und sync

watch() und watchEffect() akzeptieren eine flush-Option, die bestimmt, wann relativ zum Komponenten-Update-Zyklus der Callback ausgeführt wird. flush: 'pre', der Standardwert, führt den Callback vor dem Komponenten-Update aus. flush: 'post' verschiebt die Ausführung auf nach dem DOM-Update, sodass innerhalb des Callbacks sicher auf das aktualisierte DOM zugegriffen werden kann, ohne zusätzlich nextTick() aufzurufen. flush: 'sync' führt den Callback vollständig synchron aus, sofort bei jeder Änderung, ohne jede Bündelung durch den Scheduler.

flush: 'sync' sollte mit Bedacht eingesetzt werden, da es das Batching-Verhalten komplett umgeht und bei häufigen Änderungen zu spürbaren Performance-Einbußen führen kann. Der Hauptanwendungsfall für flush: 'post' ist genau das Szenario aus Abschnitt 3: DOM-Zugriff nach einer Zustandsänderung, aber ohne den zusätzlichen Umweg über nextTick() in der Watch-Callback-Funktion selbst.


import { ref, watch } from 'vue'

const items = ref([])
const listRef = ref(null)

// flush: 'post' guarantees the DOM is already updated when this runs
watch(items, () => {
  // Safe to read listRef's updated scrollHeight here, no nextTick() needed
  if (listRef.value) {
    listRef.value.scrollTop = listRef.value.scrollHeight
  }
}, { flush: 'post' })

// flush: 'sync' — runs immediately, bypasses the scheduler entirely
watch(items, () => {
  console.log('runs synchronously, before the scheduler batches anything')
}, { flush: 'sync' })

6. Reihenfolge von watch-Callbacks und DOM-Updates debuggen

Ein häufiges Debugging-Szenario: Ein watch()-Callback greift auf ein Template-Ref zu und erhält scheinbar veraltete Werte, obwohl die zugrunde liegende Ref-Änderung längst passiert ist. Die Ursache ist fast immer falsches Flush-Timing. Mit dem Standard flush: 'pre' läuft der Callback vor dem DOM-Update, ein Zugriff auf aktualisierte Layout-Werte wie offsetHeight liefert dann noch die alten Werte.

Die systematische Debugging-Strategie: Zuerst prüfen, ob der Callback tatsächlich vor oder nach dem DOM-Update laufen soll, und das Flush-Timing entsprechend explizit setzen, statt sich auf den Standardwert zu verlassen. Bei komplexeren Ketten aus mehreren watch()-Aufrufen mit unterschiedlichem Flush-Timing kann die Reihenfolge zusätzlich unübersichtlich werden. Hier hilft es, jeden Callback mit einem eindeutigen console.log()-Präfix zu versehen und die tatsächliche Ausführungsreihenfolge im Browser zu beobachten, statt sie anzunehmen.

7. nextTick() in Tests mit Vitest und Vue Test Utils

In Komponententests mit Vue Test Utils ist await nextTick() nach jeder simulierten Nutzerinteraktion, die eine reaktive Zustandsänderung auslöst, praktisch Pflicht. Ohne dieses await prüft eine nachfolgende Assertion den DOM-Zustand, bevor der Scheduler ihn tatsächlich aktualisiert hat, was zu einem Test führt, der scheinbar zufällig fehlschlägt, je nachdem wie schnell die Test-Umgebung die Microtask-Queue abarbeitet.

wrapper.trigger('click') in Vue Test Utils gibt bereits ein Promise zurück, das intern nextTick() abwartet, was diesen speziellen Fall bereits absichert. Bei manuellen Zustandsänderungen direkt über die Komponenten-Instanz oder ein exportiertes Composable ist ein explizites await nextTick() aber weiterhin notwendig, bevor Assertions gegen den DOM-Zustand geprüft werden.


import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import Counter from './Counter.vue'

describe('Counter', () => {
  it('updates the DOM after state change', async () => {
    const wrapper = mount(Counter)

    // Directly mutating exposed reactive state, not via a user event
    wrapper.vm.count = 5

    // WITHOUT this, the assertion below could read stale DOM content
    await nextTick()

    expect(wrapper.find('[data-testid="count"]').text()).toBe('5')
  })
})

8. Häufige Fehler mit Scheduler und nextTick

Der häufigste Fehler ist, direkt nach einer reaktiven Zustandsänderung synchron auf DOM-Eigenschaften zuzugreifen, ohne nextTick() abzuwarten, in der Annahme, Vue würde das DOM sofort aktualisieren. Das Ergebnis ist ein subtiler Bug, der in Entwicklungsumgebungen mit langsameren Geräten oder in CI-Pipelines mit anderer Timing-Charakteristik anders auftritt als lokal, was die Fehlersuche erschwert.

Ein zweiter Fehler ist der übermäßige Einsatz von flush: 'sync' als vermeintlich einfache Lösung für Timing-Probleme. Das umgeht zwar das Problem oberflächlich, verursacht aber bei häufigen Änderungen genau die Performance-Kosten, die der Reactivity Scheduler eigentlich vermeiden soll. In den allermeisten Fällen ist flush: 'post' oder ein sauberes await nextTick() die richtige, performantere Lösung.

9. Synchrones Update im Vergleich zu Scheduler-Batching

Die folgende Übersicht zeigt den praktischen Unterschied zwischen unbeabsichtigt synchronem Verhalten und korrektem Umgang mit dem Scheduler.

Szenario Falscher Ansatz Richtiger Ansatz Ergebnis
DOM-Zugriff nach Zustandsänderung Sofortiger Zugriff ohne nextTick() await nextTick() abwarten Immer aktueller DOM-Zustand
watch() mit DOM-Zugriff Standard flush: 'pre' plus manuelles nextTick() flush: 'post' direkt setzen Weniger Code, klarere Absicht
Mehrere Feldänderungen Einzelne setTimeout(0) pro Feld Alle Änderungen synchron im selben Tick Scheduler bündelt automatisch
Test nach simulierter Mutation Assertion sofort nach Zuweisung await nextTick() vor Assertion Stabile, deterministische Tests
Timing-Problem schnell lösen flush: 'sync' überall einsetzen Gezielt flush: 'post' oder nextTick() Batching-Vorteile bleiben erhalten

Die Grundregel: Der Reactivity Scheduler bündelt Updates automatisch, und nextTick() oder flush: 'post' sind die vorgesehenen Werkzeuge, um zuverlässig nach diesem Bündelungszeitpunkt auf das DOM zuzugreifen. flush: 'sync' bleibt Sonderfällen vorbehalten, in denen synchrones Verhalten explizit gebraucht wird, etwa bei sehr feingranularen Interaktionen mit externen, nicht-reaktiven Bibliotheken.

Mironsoft

Vue 3 Debugging für Timing- und Reaktivitätsprobleme

Kämpft ihr mit seltsamen Render-Reihenfolgen in Vue 3?

Wir analysieren Scheduler-Timing-Probleme in euren Komponenten, bringen Flush-Optionen und nextTick()-Einsatz auf ein sauberes Fundament und stabilisieren eure Tests.

Timing-Audit

Fehlerhafte DOM-Zugriffe und Race Conditions identifizieren

Flush-Timing-Refactoring

watch()-Aufrufe mit passendem flush statt Sonderlösungen

Test-Stabilisierung

Flaky Tests durch korrekten nextTick()-Einsatz beheben

10. Zusammenfassung

Der Reactivity Scheduler bündelt mehrere synchrone Ref-Änderungen über die Microtask-Queue zu einem einzigen DOM-Update pro Komponente, statt bei jeder einzelnen Änderung sofort zu rendern. Job-Dedupe stellt sicher, dass eine Komponente pro Flush-Zyklus höchstens einmal aktualisiert wird, egal wie viele ihrer reaktiven Abhängigkeiten sich geändert haben. nextTick() ist das Werkzeug, um zuverlässig nach diesem Bündelungszeitpunkt auf das tatsächlich aktualisierte DOM zuzugreifen.

Flush-Timing mit pre, post und sync steuert, wann relativ zum DOM-Update ein watch()-Callback läuft, wobei post für DOM-Zugriffe die sauberere Alternative zu manuellem nextTick() innerhalb des Callbacks ist. In Tests ist await nextTick() nach jeder direkten Zustandsmutation unverzichtbar, um Race Conditions zwischen Assertion und Scheduler-Flush zu vermeiden. flush: 'sync' bleibt bewusst Sonderfällen vorbehalten, weil es das gesamte Batching umgeht.

Reactivity Scheduler und nextTick in Vue 3 — Das Wichtigste auf einen Blick

Grundprinzip

Updates werden über die Microtask-Queue gebündelt, pro Komponente höchstens ein Render pro Tick.

nextTick()

Promise, das nach Abschluss des aktuellen Scheduler-Flushs aufgelöst wird.

Flush-Optionen

pre (Standard), post (nach DOM-Update), sync (sofort, ohne Bündelung).

Häufigster Fehler

DOM-Zugriff direkt nach Zustandsänderung ohne nextTick() oder falsches Flush-Timing.

11. FAQ: Reactivity Scheduler und nextTick

1Was ist der Reactivity Scheduler?
Bündelt mehrere Ref-Änderungen über die Microtask-Queue zu einem einzigen DOM-Update pro Komponente.
2DOM aktualisiert sich nicht sofort?
Scheduler wartet auf nächste Microtask, mit nextTick() zuverlässig auf diesen Zeitpunkt warten.
3Was macht nextTick()?
Promise, das nach Abschluss des Scheduler-Flushs und aktualisiertem DOM aufgelöst wird.
4Warum nur ein Update bei mehreren Änderungen?
Job-Dedupe pro Komponente, nur ein Render-Job pro Flush-Zyklus, egal wie viele Abhängigkeiten sich ändern.
5pre vs. post vs. sync?
pre vor Update (Standard), post nach DOM-Update, sync sofort ohne Bündelung.
6flush: post statt nextTick()?
Wenn watch()-Callback direkt DOM-Zugriff braucht, erspart zusätzliches await nextTick().
7Warum flush: sync vermeiden?
Umgeht komplettes Batching, kostet Performance bei häufigen Änderungen.
8Test schlägt nach Mutation fehl?
Wahrscheinlich fehlendes await nextTick(), Assertion prüft veralteten DOM-Zustand.
9Microtasks oder setTimeout?
Microtasks über Promise.resolve().then(), laufen vor allen Macrotasks wie setTimeout.
10trigger() braucht extra nextTick()?
Nein, trigger() wartet intern bereits, direkte State-Mutation braucht aber explizites await nextTick().