Read OSS

The Reactivity Engine: Observer, Dep, and Watcher

Advanced

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] = newValue doesn't trigger reactivity in Vue 2 — Object.defineProperty can't intercept array index access. You need Vue.set(vm.items, 0, newValue) or vm.items.splice(0, 1, newValue). This is a fundamental limitation that Vue 3 solves with Proxy.

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):

  1. dep.depend() — adds the current watcher as a subscriber of this property's Dep
  2. childOb.dep.depend() — also subscribes to the Observer's Dep of nested objects (needed for Vue.set/Vue.delete to 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:

  1. Components update parent-before-child (parent has lower ID)
  2. User watchers run before render watchers (user watchers are created first)
  3. 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.then can 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:

  1. The getter is not evaluated on creation — this.value starts as undefined
  2. The dirty flag starts as true
  3. When a dependency changes, update() only sets dirty = true — it doesn't re-evaluate
  4. 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.