The Reactivity Engine: Observer, Dep, and Watcher
Prerequisites
- ›Article 1: Architecture and Entry Points
- ›Solid understanding of Object.defineProperty and JavaScript getters/setters
- ›Familiarity with the Observer design pattern
- ›Understanding of JavaScript's event loop and microtask queue
The Reactivity Engine: Observer, Dep, and Watcher
Every time you set this.count++ in a Vue component and the DOM updates automatically, you're witnessing a dependency-tracking publish-subscribe system built on Object.defineProperty. This system — the Observer-Dep-Watcher triad — is the single most important piece of Vue 2's architecture. It's the reason Vue "just works" without explicit setState calls, and understanding it is the key to understanding everything else in the framework.
As we saw in Part 1, the reactivity system lives in src/core/observer/. In this article, we'll trace every step: from how objects become reactive, through how dependencies are dynamically collected during getter evaluation, to how updates are batched and flushed asynchronously.
The Observer Class: Making Objects Reactive
The Observer class is responsible for converting a plain JavaScript object into a reactive one. Every observed object gets an __ob__ property pointing to its Observer instance.
src/core/observer/index.ts#L48-L95
classDiagram
class Observer {
+dep: Dep
+vmCount: number
+value: any
+shallow: boolean
+mock: boolean
+observeArray(value)
}
class Dep {
+static target: DepTarget
+id: number
+subs: Array
+addSub(sub)
+removeSub(sub)
+depend()
+notify()
}
class Watcher {
+vm: Component
+id: number
+deps: Array~Dep~
+newDeps: Array~Dep~
+getter: Function
+lazy: boolean
+dirty: boolean
+get()
+addDep(dep)
+cleanupDeps()
+update()
+run()
+teardown()
}
Observer --> Dep : "owns one"
Dep --> Watcher : "notifies many"
Watcher --> Dep : "subscribes to many"
When the Observer constructor receives a plain object, it walks all properties and calls defineReactive on each. For arrays, it takes a different approach — it patches the array's prototype to intercept mutating methods.
The array patching is a clever workaround for Object.defineProperty's inability to detect index-based mutations:
src/core/observer/array.ts#L1-L54
const methodsToPatch = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
Each patched method calls the original array method, then observes any newly inserted elements (from push, unshift, or splice), and finally triggers ob.dep.notify(). The prototype is patched via __proto__ assignment where supported, or by defining methods directly on the array instance in older environments.
Tip: This is why
vm.items[0] = newValuedoesn't trigger reactivity in Vue 2 —Object.definePropertycan't intercept array index access. You needVue.set(vm.items, 0, newValue)orvm.items.splice(0, 1, newValue). This is a fundamental limitation that Vue 3 solves withProxy.
defineReactive: The Core Mechanism
defineReactive is the single most important function in Vue 2. It replaces a property with a getter/setter pair that intercepts reads and writes, enabling automatic dependency tracking and change notification.
src/core/observer/index.ts#L128-L214
flowchart TD
A["defineReactive(obj, key)"] --> B["Create closure-scoped Dep"]
B --> C["Get existing getter/setter"]
C --> D["Recursively observe(val)"]
D --> E["Object.defineProperty"]
E --> F["getter: reactiveGetter"]
E --> G["setter: reactiveSetter"]
F --> F1{"Dep.target exists?"}
F1 -->|Yes| F2["dep.depend()"]
F2 --> F3["childOb.dep.depend()"]
F1 -->|No| F4["Return value"]
G --> G1["Compare old/new values"]
G1 --> G2["observe(newVal)"]
G2 --> G3["dep.notify()"]
The critical insight is the closure-scoped Dep. Each reactive property gets its own Dep instance, created in the closure of defineReactive. This Dep is invisible from outside — it only exists in the getter/setter closures.
The getter does two things when Dep.target exists (meaning a watcher is currently evaluating):
dep.depend()— adds the current watcher as a subscriber of this property's DepchildOb.dep.depend()— also subscribes to the Observer's Dep of nested objects (needed forVue.set/Vue.deleteto notify on property additions)
The setter compares old and new values using hasChanged (which handles NaN !== NaN), recursively observes the new value, and calls dep.notify() to trigger all subscribers.
There's also a Ref unwrapping feature added in Vue 2.7 — if the current value is a Ref and the property is not shallow, the getter returns value.value transparently, and the setter assigns to value.value instead of replacing the ref.
Dep: The Dependency Broker
The Dep class is the bridge between reactive properties and watchers. It's a simple pub-sub channel:
src/core/observer/dep.ts#L31-L108
Two key design decisions stand out:
The static Dep.target singleton. Only one watcher can be evaluated at a time. Dep.target points to that watcher, and a targetStack array handles nesting (when a computed watcher triggers a render watcher, for example). pushTarget and popTarget manage this stack:
Dep.target = null
const targetStack: Array<DepTarget | null | undefined> = []
export function pushTarget(target?: DepTarget | null) {
targetStack.push(target)
Dep.target = target
}
The lazy cleanup optimization. When a watcher unsubscribes from a Dep, the framework doesn't splice the subscriber out of the array immediately. Instead, it nullifies the entry:
removeSub(sub: DepTarget) {
this.subs[this.subs.indexOf(sub)] = null
if (!this._pending) {
this._pending = true
pendingCleanupDeps.push(this)
}
}
Nullified entries are filtered out lazily during the next scheduler flush. The comment on line 48 explains this was done to work around Chromium issue #12696 where deps with massive subscriber lists were extremely slow to clean up due to array splicing in V8.
Watcher: The Three Faces of Reactivity
The Watcher class serves three distinct purposes — render watchers, user watchers, and computed watchers — unified under a single implementation:
src/core/observer/watcher.ts#L41-L278
| Watcher Type | Created By | Key Flags | Behavior |
|---|---|---|---|
| Render | mountComponent() |
isRenderWatcher: true |
Evaluates updateComponent(), re-renders on change |
| User | $watch(), watch option |
user: true |
Calls user callback with old/new values |
| Computed | initComputed() |
lazy: true |
Defers evaluation until value is read, dirty-checks |
The get() method is where dependency collection happens:
src/core/observer/watcher.ts#L133-L155
sequenceDiagram
participant W as Watcher
participant D as Dep (static)
participant P as Reactive Property
W->>D: pushTarget(this)
W->>P: getter.call(vm) — reads reactive properties
P->>D: dep.depend() inside getter
D->>W: Dep.target.addDep(dep)
W->>D: popTarget()
W->>W: cleanupDeps()
After evaluation, cleanupDeps() performs a crucial swap: it compares deps (dependencies from the previous run) with newDeps (dependencies from this run). Any dep in the old set but not in the new set gets unsubscribed via dep.removeSub(this). Then the two sets are swapped — newDeps becomes deps, and the old array is reused.
This dynamic dependency tracking is what allows Vue to handle conditional rendering efficiently. If a template renders {{ showA ? a : b }}, the watcher only subscribes to a when showA is true, and automatically unsubscribes from a and subscribes to b when showA becomes false.
The update() method demonstrates the three watcher behaviors:
update() {
if (this.lazy) {
this.dirty = true // Computed: just mark dirty
} else if (this.sync) {
this.run() // Sync: run immediately
} else {
queueWatcher(this) // Normal: queue for async flush
}
}
The Scheduler: Batching and Deduplication
The scheduler ensures that even if you change 100 reactive properties in a single synchronous block, the DOM is only updated once. It uses three key mechanisms:
src/core/observer/scheduler.ts#L62-L199
Deduplication via the has map. Each watcher has a unique id. When queueWatcher is called, it checks has[id]. If the watcher is already queued, it's skipped:
export function queueWatcher(watcher: Watcher) {
const id = watcher.id
if (has[id] != null) return
has[id] = true
// ...
}
Sorted flush. Before flushing, the queue is sorted by watcher ID (with post watchers moved to the end). The sorting guarantees three invariants, as the comments explain:
- Components update parent-before-child (parent has lower ID)
- User watchers run before render watchers (user watchers are created first)
- Destroyed components' watchers are skipped
Mid-flush insertion. If a watcher triggers during the flush (e.g., a user watcher modifies data that triggers another watcher), the new watcher is binary-inserted into the correct position in the already-sorted queue:
if (!flushing) {
queue.push(watcher)
} else {
let i = queue.length - 1
while (i > index && queue[i].id > watcher.id) {
i--
}
queue.splice(i + 1, 0, watcher)
}
Circular update detection. In development mode, the scheduler counts how many times each watcher is flushed per cycle. If a watcher exceeds MAX_UPDATE_COUNT (100), it warns about a likely infinite loop.
sequenceDiagram
participant Code as User Code
participant S as Scheduler
participant NT as nextTick
participant DOM as DOM
Code->>S: this.a = 1 → queueWatcher(renderWatcher)
Code->>S: this.b = 2 → queueWatcher(renderWatcher) — DEDUP
Code->>S: this.c = 3 → queueWatcher(userWatcher)
S->>NT: nextTick(flushSchedulerQueue)
Note over NT: microtask queued
Note over Code: synchronous code finishes
NT->>S: flushSchedulerQueue()
S->>S: sort queue by ID
S->>DOM: run each watcher → _render() → _update() → __patch__()
S->>S: resetSchedulerState()
nextTick: The Microtask Strategy
The scheduler schedules its flush via nextTick, which needs to run a callback in the next microtask (or macrotask as a fallback). The implementation in src/core/util/next-tick.ts#L1-L117 is a masterclass in browser compatibility:
flowchart TD
A{"Promise available<br/>& native?"} -->|Yes| B["Promise.resolve().then(flush)"]
A -->|No| C{"MutationObserver<br/>available & not IE?"}
C -->|Yes| D["MutationObserver on text node"]
C -->|No| E{"setImmediate<br/>available & native?"}
E -->|Yes| F["setImmediate(flush)"]
E -->|No| G["setTimeout(flush, 0)"]
B --> H["isUsingMicroTask = true"]
D --> H
The code comments document years of browser bug workarounds:
- iOS UIWebView (lines 45-50):
Promise.thencan get stuck in a weird state where callbacks are queued but not flushed. The workaround:if (isIOS) setTimeout(noop)— an empty timer forces the microtask queue to flush. - IE11 (line 54): MutationObserver is unreliable in IE11, so it's excluded.
- PhantomJS / iOS 7 (line 57-58): MutationObserver's
toString()returns a non-standard string, requiring a special check. - Macro vs micro tasks (lines 21-31): Vue 2.5 tried using macrotasks for some scenarios, but this caused subtle bugs with state changes before repaint and in event handlers. Vue reverted to microtasks everywhere.
The nextTick function itself is simple: it pushes callbacks to an array and schedules a single flush. If called without a callback, it returns a Promise:
export function nextTick(cb?: (...args: any[]) => any, ctx?: object) {
let _resolve
callbacks.push(() => {
if (cb) {
try { cb.call(ctx) } catch (e: any) { handleError(e, ctx, 'nextTick') }
} else if (_resolve) {
_resolve(ctx)
}
})
if (!pending) {
pending = true
timerFunc()
}
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => { _resolve = resolve })
}
}
Tip: When you call
await this.$nextTick()in a component, you're using this exact Promise path. The DOM is guaranteed to be updated because the scheduler's flush runs in the same microtask batch as your callback.
Computed Properties: Lazy Evaluation and Dirty-Checking
Computed watchers deserve special attention. They're created in src/core/instance/state.ts#L181-L277 with { lazy: true }, which means:
- The getter is not evaluated on creation —
this.valuestarts asundefined - The
dirtyflag starts astrue - When a dependency changes,
update()only setsdirty = true— it doesn't re-evaluate - The actual value is computed on-demand when read, via
evaluate()
The createComputedGetter function returns a getter that checks watcher.dirty, evaluates if needed, and then calls watcher.depend() to forward the computed watcher's dependencies to the current parent watcher (typically the render watcher):
function createComputedGetter(key) {
return function computedGetter() {
const watcher = this._computedWatchers && this._computedWatchers[key]
if (watcher) {
if (watcher.dirty) {
watcher.evaluate()
}
if (Dep.target) {
watcher.depend()
}
return watcher.value
}
}
}
This is how computed properties achieve their two guarantees: they're cached (not re-evaluated unless dependencies change), and they're reactive (the render watcher re-renders when the computed value changes).
What's Next
The reactivity system tells Vue what changed. The virtual DOM system — which we'll explore in the next article — translates those changes into efficient DOM mutations. We'll examine the VNode data structure, trace through the Snabbdom-derived double-ended diff algorithm, and see how the render watcher connects _render() → _update() → __patch__() to close the loop from data change to DOM update.