The Reactivity System: Observer, Dep, and Watcher
Prerequisites
- ›Article 2: Component Initialization and Lifecycle
- ›Object.defineProperty and JavaScript getters/setters
- ›Observer pattern / pub-sub concepts
- ›JavaScript event loop: microtasks vs macrotasks
The Reactivity System: Observer, Dep, and Watcher
Vue 2's reactivity system is the engine that makes declarative rendering possible. When you write {{ message }} in a template, Vue knows to re-render when message changes — not through dirty checking or explicit setState calls, but through intercepted property access. Three classes work together to make this happen: Observer converts objects into reactive versions, Dep acts as the pub-sub hub connecting data to its subscribers, and Watcher evaluates expressions while collecting dependencies. This article dissects all three.
The Observer Class: Making Objects Reactive
The Observer class is the entry point for making data reactive. When initData calls observe(data) during component initialization (as we saw in Part 2), it creates an Observer instance that attaches to the object:
flowchart TD
OBS["new Observer(value)"] --> DEF["def(value, '__ob__', this)<br/>Non-enumerable back-reference"]
OBS --> CHECK{Is Array?}
CHECK -->|Yes| PATCH["Patch array prototype<br/>with intercepted methods"]
PATCH --> EACH["observeArray(value)<br/>Observe each element"]
CHECK -->|No| WALK["Walk all keys<br/>defineReactive(value, key)"]
The Observer stores a Dep instance on itself (this.dep). This dep is used for tracking changes to the object as a whole — which matters for Vue.set, array mutations, and when nested objects are replaced. The __ob__ property is defined as non-enumerable using def(), so it won't appear in JSON.stringify or for...in loops.
For arrays, Vue can't use Object.defineProperty on indices (it would be impractical for large arrays), so it takes a different approach: it patches the array's prototype with intercepted versions of the seven mutating methods.
The array.ts file creates a prototype object that inherits from Array.prototype and overrides push, pop, shift, unshift, splice, sort, and reverse. Each interceptor calls the original method, then triggers reactivity: for methods that add new elements (push, unshift, splice), it observes the inserted items. All methods call ob.dep.notify() to alert subscribers.
If the browser supports __proto__, the array's prototype is swapped directly. Otherwise, the methods are copied onto the array instance as own properties (a fallback for older engines).
defineReactive: The Closure-Based Getter/Setter Pattern
defineReactive is the most important function in Vue 2. It converts a single property into a reactive getter/setter pair:
sequenceDiagram
participant Code as Component Code
participant Getter as reactiveGetter
participant Dep as Dep instance
participant Watcher as Current Watcher
participant Setter as reactiveSetter
Note over Getter,Setter: Closure holds: dep, childOb, val
Code->>Getter: Read this.message
Getter->>Dep: if Dep.target exists
Dep->>Watcher: dep.depend() → target.addDep(dep)
Getter-->>Code: return val
Code->>Setter: this.message = 'new'
Setter->>Setter: val = newVal
Setter->>Setter: childOb = observe(newVal)
Setter->>Dep: dep.notify()
Dep->>Watcher: watcher.update()
The key insight is the closure. Each call to defineReactive creates a new scope containing two variables invisible from outside: dep (a fresh Dep instance) and childOb (the Observer on the value, if it's an object). These are captured in the getter and setter closures and persist for the lifetime of the property.
The getter does two things: returns the value, and — if there's a watcher currently evaluating (Dep.target) — calls dep.depend() to register the watcher as a subscriber. If the value is itself an object, it also registers with the child Observer's dep, which enables Vue.set to notify watchers of the parent property.
The setter checks if the value actually changed (using Object.is semantics via hasChanged), updates the closure's val, creates a new Observer for the new value if it's an object, and calls dep.notify() to trigger all subscribers.
Tip: The
customSetterparameter is used in development mode to warn when you mutate props directly or modify$attrs/$listeners. It's a no-op in production builds.
The Dep Class: Dependency Hub
Dep is deceptively simple — under 100 lines — but it's the linchpin of the entire system:
classDiagram
class Dep {
+static target: DepTarget | null
+id: number
+subs: Array~DepTarget | null~
+addSub(sub: DepTarget)
+removeSub(sub: DepTarget)
+depend(info?)
+notify(info?)
}
class DepTarget {
<<interface>>
+id: number
+addDep(dep: Dep)
+update()
}
Dep --> DepTarget : notifies
note for Dep "Dep.target is globally unique:<br/>only one watcher evaluates at a time"
The static Dep.target is the critical mechanism. It's a global variable that points to the currently evaluating watcher. When a reactive getter fires, it calls dep.depend(), which in turn calls Dep.target.addDep(this) — the watcher adds this dep to its own dependency list. This is the "automatic" part of automatic dependency tracking: the watcher doesn't need to declare what data it uses; it discovers dependencies by simply running.
The removeSub method uses a clever optimization from issue #12696: instead of splicing the subscriber out of the array (which is O(n) and causes problems with Chromium's GC when subs arrays are very large), it sets the slot to null. The actual cleanup happens during the next scheduler flush via cleanupDeps(), which filters out null entries from all pending deps. This batched approach was introduced to fix severe performance degradation in apps with many reactive dependencies.
The pushTarget/popTarget functions maintain a stack, enabling nested watcher evaluations. When a computed property is read during a render watcher's evaluation, the computed watcher pushes itself as the target, evaluates, pops, and the render watcher resumes collecting its own dependencies.
The Watcher Class: Three Types of Watchers
The Watcher class serves three roles in Vue 2, configured via constructor options:
Render Watchers (one per component): Created by mountComponent with isRenderWatcher = true. Their getter is updateComponent, which calls _render() then _update(). The callback is noop — render watchers don't need a callback because re-running the getter is the side effect.
User Watchers ($watch / watch option): Created with user = true. Their getter parses the watch expression (e.g., 'a.b.c' becomes a function that traverses this.a.b.c). Their callback is the user's handler, invoked with (newVal, oldVal).
Lazy Watchers (computed properties): Created with lazy = true. They don't evaluate immediately — instead, this.value is left as undefined and this.dirty = true. When a computed property is accessed, the getter calls watcher.evaluate(), which runs this.get() and sets this.dirty = false. On subsequent accesses, if dirty is still false, the cached value is returned.
The get() method is where dependency collection happens:
get() {
pushTarget(this) // Set Dep.target = this watcher
let value
try {
value = this.getter.call(vm, vm) // Read reactive properties → collect deps
} finally {
if (this.deep) traverse(value) // Touch all nested properties
popTarget() // Restore previous Dep.target
this.cleanupDeps() // Swap dep sets, unsubscribe stale deps
}
return value
}
The dep-swapping in cleanupDeps prevents stale subscriptions. The watcher maintains two sets: deps/depIds (previous evaluation) and newDeps/newDepIds (current evaluation). After each evaluation, deps that are in deps but not in newDeps are unsubscribed. Then the sets are swapped and the new set is cleared. This handles conditional rendering: if a v-if branch stops reading a property, the watcher stops subscribing to it.
The update() method takes one of three paths: if lazy, just set dirty = true (defer evaluation); if sync, call run() immediately; otherwise, call queueWatcher(this) to schedule async evaluation.
End-to-End: A Data Change to DOM Update
Let's trace the full cycle when you set this.count = 42:
sequenceDiagram
participant Code as this.count = 42
participant Setter as reactiveSetter
participant Dep as count's Dep
participant W as Render Watcher
participant Q as Scheduler Queue
participant NT as nextTick
participant Flush as flushSchedulerQueue
participant Render as _render() + _update()
Code->>Setter: Set count = 42
Setter->>Dep: dep.notify()
Dep->>W: watcher.update()
W->>Q: queueWatcher(this)
Q->>NT: nextTick(flushSchedulerQueue)
Note over NT: Deferred to microtask
NT->>Flush: Execute on next microtask
Flush->>Flush: Sort queue by watcher.id
Flush->>W: watcher.run()
W->>Render: this.get() → updateComponent()
Render->>Render: _render() → new VNode tree
Render->>Render: _update() → __patch__ → DOM
The batching via queueWatcher is critical. If you set 10 properties in a single synchronous block, the watcher is only queued once (deduplication by watcher id). The flush happens on the next microtask, and the component re-renders once with all 10 changes applied.
The Async Scheduler and nextTick
The queueWatcher function deduplicates watchers by ID using a has hash map. If the queue is already flushing and a new watcher is pushed (e.g., a user watcher triggers a data change during the flush), it's inserted at the correct position based on ID — maintaining the sorted order.
The flushSchedulerQueue function sorts the queue by watcher ID (with post watchers deferred) for three reasons documented in the source: parent components update before children, user watchers run before render watchers, and destroyed components' watchers can be skipped.
The nextTick implementation uses a priority-ordered fallback chain for scheduling microtasks:
Promise.then— preferred, with a workaround for iOS UIWebView where an emptysetTimeoutforces the microtask queue to flushMutationObserver— for environments without native Promise (PhantomJS, iOS 7), but excluded in IE11 due to bug #6466setImmediate— a macrotask, but faster than setTimeoutsetTimeout(fn, 0)— the last resort
Tip: When you need to access the DOM after a data change, use
await this.$nextTick()orVue.nextTick(). This waits for the scheduler flush to complete. If called without a callback,nextTickreturns a Promise.
Vue.set, Vue.delete, and Reactivity Limitations
Object.defineProperty can only intercept access to existing properties. It cannot detect property addition or deletion. This is the fundamental limitation of Vue 2's reactivity:
| Operation | Detected? | Workaround |
|---|---|---|
| Modify existing property | ✅ Yes | — |
| Add new property | ❌ No | Vue.set(obj, key, value) |
| Delete property | ❌ No | Vue.delete(obj, key) |
Array index set arr[0] = x |
❌ No | Vue.set(arr, 0, x) or arr.splice(0, 1, x) |
| Array length change | ❌ No | arr.splice(newLength) |
| Map/Set mutations | ❌ No | Not supported in Vue 2 |
Vue.set works by calling defineReactive on the object to set up the getter/setter for the new key, then manually calling ob.dep.notify() to alert watchers that the object has changed. For arrays, it delegates to splice, which is already intercepted.
Vue.delete removes the property with delete target[key] and then calls ob.dep.notify(). Without this explicit notification, the deletion would be invisible to the reactivity system.
These limitations were the primary motivation for Vue 3's switch to Proxy, which can intercept property addition, deletion, has checks, and even Map/Set operations. As we'll see in Part 5, the Composition API backport in Vue 2.7 inherits these same limitations because it's built on the same defineReactive foundation.
Next, we'll explore what happens after the reactivity system triggers a re-render — how Vue's virtual DOM creates, diffs, and patches the real DOM.