Read OSS

The Reactivity System: Observer, Dep, and Watcher

Advanced

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 customSetter parameter 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:

  1. Promise.then — preferred, with a workaround for iOS UIWebView where an empty setTimeout forces the microtask queue to flush
  2. MutationObserver — for environments without native Promise (PhantomJS, iOS 7), but excluded in IE11 due to bug #6466
  3. setImmediate — a macrotask, but faster than setTimeout
  4. setTimeout(fn, 0) — the last resort

Tip: When you need to access the DOM after a data change, use await this.$nextTick() or Vue.nextTick(). This waits for the scheduler flush to complete. If called without a callback, nextTick returns 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.