Vue 2 中 Object 的变化侦测:从 getter/setter 到 Dep、Watcher、Observer

17 阅读1分钟

很多人第一次看 Vue 响应式原理时,会被几个名字绕晕:ObserverDepWatcherdefineReactive

其实它们解决的是同一个问题:

当一个对象属性发生变化时,Vue 怎么知道谁用过它,并通知这些地方更新?

这就是 Vue 2 中 Object 变化侦测的核心。

1. 什么是变化侦测?

Vue 的渲染是声明式的。我们在模板里写:

<h1>{{ name }}</h1>

Vue 会根据 name 的值生成 DOM。问题是:当 name 改变时,Vue 怎么知道页面应该更新?

这件事就叫变化侦测

变化侦测大体可以分成两类:

一种是“拉”:
状态变了以后,框架只知道“可能有东西变了”,于是重新执行一轮计算,再通过比较找出真正需要更新的地方。React 的虚拟 DOM diff 就属于这个思路。

另一种是“推”:
状态在变化的那一刻,就主动通知依赖它的地方。Vue 2 的响应式系统就是这种思路。

Vue 的优势在于:它不是等到最后再猜哪里变了,而是在数据被读取的时候就记录“谁用过我”,在数据被修改的时候再通知这些使用者。

一句话概括:

读取时收集依赖,修改时触发依赖。

2. Vue 2 如何追踪 Object 的变化?

在 Vue 2 中,对象属性的变化主要依靠 Object.defineProperty 实现。

它可以把一个普通属性改造成带有 gettersetter 的属性:

function defineReactive(data, key, val) {
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,

    get() {
      return val
    },

    set(newVal) {
      if (newVal === val) return
      val = newVal
    }
  })
}

这样,当我们读取 data[key] 时,会触发 get;当我们修改 data[key] 时,会触发 set

但这还不够。

只知道属性被读取、被修改,并不能完成响应式。真正关键的是:

  • 读取时,要知道是谁在读取;

  • 修改时,要通知这些读取者更新。

这就引出了 Vue 响应式系统里的第一个核心角色:Dep

3. Dep:每个属性自己的“订阅列表”

可以把 Dep 理解成一个订阅中心。

每个响应式属性都有自己的 Dep
谁读取了这个属性,就把谁收集进来;这个属性变化时,再通知所有订阅者。

简化版代码如下:

class Dep {
  constructor() {
    this.subs = []
  }

  depend() {
    if (Dep.target) {
      this.subs.push(Dep.target)
    }
  }

  notify() {
    this.subs.forEach(sub => sub.update())
  }
}

再把它放回 defineReactive

function defineReactive(data, key, val) {
  const dep = new Dep()

  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,

    get() {
      dep.depend()
      return val
    },

    set(newVal) {
      if (newVal === val) return
      val = newVal
      dep.notify()
    }
  })
}

这时逻辑就清楚了:

get  -> dep.depend()  -> 收集依赖
set  -> dep.notify()  -> 通知依赖

但问题又来了:
Dep.target 是谁?

答案是:Watcher

4. Watcher:真正需要被通知的人

Vue 不会直接把 DOM、模板或者用户回调塞进 Dep
它会统一抽象成一个对象:Watcher

你可以把 Watcher 理解成“响应式系统里的订阅者”。

比如:

vm.$watch('user.name', function(newVal, oldVal) {
  console.log(newVal, oldVal)
})

这背后会创建一个 Watcher。这个 Watcher 的任务是:

  1. 读取 user.name

  2. 触发 user.name 的 getter;

  3. 在 getter 中把自己收集进 Dep

  4. user.name 变化时,执行自己的 update

  5. 最后调用用户传入的回调函数。

简化后的 Watcher 可以这样理解:

class Watcher {
  constructor(vm, expOrFn, cb) {
    this.vm = vm
    this.getter = parsePath(expOrFn)
    this.cb = cb
    this.value = this.get()
  }

  get() {
    Dep.target = this
    const value = this.getter(this.vm)
    Dep.target = null
    return value
  }

  update() {
    const oldValue = this.value
    this.value = this.get()
    this.cb.call(this.vm, this.value, oldValue)
  }
}

这里最关键的是 get() 方法。

它先把当前 Watcher 放到全局位置:

Dep.target = this

然后再读取数据:

this.getter(this.vm)

读取数据时会触发 getter,而 getter 里会执行:

dep.depend()

于是当前这个 Watcher 就被收集到了对应属性的 Dep 里。

这就是 Vue 2 依赖收集的核心技巧:

Watcher 先把自己挂到全局目标上,再主动读取数据。数据的 getter 被触发后,就能反过来把这个 Watcher 收集起来。

5. parsePath:把字符串路径变成读取函数

当我们写:

vm.$watch('a.b.c', callback)

Vue 需要根据字符串 'a.b.c' 读取到真正的值。

简化实现如下:

function parsePath(path) {
  const segments = path.split('.')

  return function(obj) {
    for (let i = 0; i < segments.length; i++) {
      if (!obj) return
      obj = obj[segments[i]]
    }
    return obj
  }
}

它做的事很简单:

'a.b.c' -> ['a', 'b', 'c']

然后一层一层读取:

obj = obj['a']
obj = obj['b']
obj = obj['c']

注意,这个读取过程会连续触发每一层属性的 getter。
因此,Watcher 不只是订阅了最后的 c,它在读取链路上经过的属性也可能参与依赖收集。

6. Observer:把整个对象变成响应式对象

前面的 defineReactive 只能处理单个属性。
但 Vue 的 data 往往是一个完整对象:

data() {
  return {
    user: {
      name: 'Tom',
      age: 18
    }
  }
}

Vue 需要递归地把每个属性都变成响应式属性。

这就是 Observer 的作用。

class Observer {
  constructor(value) {
    this.value = value

    if (!Array.isArray(value)) {
      this.walk(value)
    }
  }

  walk(obj) {
    Object.keys(obj).forEach(key => {
      defineReactive(obj, key, obj[key])
    })
  }
}

同时,defineReactive 里面还要递归处理子对象:

function defineReactive(data, key, val) {
  if (typeof val === 'object' && val !== null) {
    new Observer(val)
  }

  const dep = new Dep()

  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,

    get() {
      dep.depend()
      return val
    },

    set(newVal) {
      if (newVal === val) return

      if (typeof newVal === 'object' && newVal !== null) {
        new Observer(newVal)
      }

      val = newVal
      dep.notify()
    }
  })
}

这样,一个普通对象经过 Observer 处理后,它内部的每个属性都会被转换成 getter/setter。

所以可以这样理解:

  • Observer:负责把对象加工成响应式对象;

  • defineReactive:负责把单个属性变成 getter/setter;

  • Dep:负责保存这个属性的依赖;

  • Watcher:负责在数据变化后执行更新逻辑。

7. 整个流程串起来

假设模板里用了:

<h1>{{ user.name }}</h1>

Vue 会为这个组件创建一个渲染 Watcher

第一次渲染时,流程是:

Watcher 开始渲染
   ↓
读取 user.name
   ↓
触发 user 和 name 的 gettergetter 调用 dep.depend()
   ↓
当前 Watcher 被收集进 Dep

之后当我们执行:

this.user.name = 'Jerry'

流程变成:

触发 name 的 settersetter 调用 dep.notify()
   ↓
Dep 通知所有 Watcher
   ↓
Watcher.update()
   ↓
组件重新渲染
   ↓
生成新的虚拟 DOM
   ↓
diff 后更新真实 DOM

这就是 Vue 2 对象响应式的主链路。

8. 为什么 Vue 2 检测不到新增属性和删除属性?

Object.defineProperty 的能力有一个天然限制:

它只能拦截已经存在的属性。

比如初始化时是:

data() {
  return {
    user: {}
  }
}

后面再写:

this.user.name = 'Tom'

这个 name 是后来新增的。
初始化阶段 Vue 没有见过它,也就没有机会给它设置 getter/setter。

所以 Vue 2 无法自动侦测这种新增属性。

删除属性也一样:

delete this.user.name

delete 不会触发某个已经定义好的 setter,因此 Vue 2 也无法直接感知。

这就是为什么 Vue 2 需要提供:

Vue.set()
Vue.delete()

或者实例方法:

this.$set()
this.$delete()

它们的本质就是绕过 Object.defineProperty 的限制,手动补上响应式处理和依赖通知。

9. Vue 2 为什么还要引入虚拟 DOM?

既然 Vue 已经能精确知道哪个数据变了,为什么 Vue 2 还要用虚拟 DOM?

因为粒度太细也有成本。

如果每个数据都直接绑定到具体 DOM 节点,依赖关系会非常多,内存和维护成本都会上升。

所以 Vue 2 选择了一个折中方案:

数据变化时通知组件,组件内部再通过虚拟 DOM diff 找出真正需要更新的 DOM。

也就是说,Vue 2 并不是完全放弃“推”,而是把更新粒度从“具体 DOM 节点”提升到了“组件级别”。

这样既保留了响应式依赖追踪的优势,又避免了过细粒度带来的巨大依赖开销。

10. 总结

Vue 2 中 Object 的变化侦测,可以压缩成一句话:

Object.defineProperty 拦截属性读写;读取时收集 Watcher,修改时通知 Watcher。

几个核心角色分别是:

Observer
负责遍历对象,把属性转换成 getter/setter。

defineReactive
负责处理单个属性,让它具备响应式能力。

Dep
负责保存依赖,相当于每个属性自己的订阅列表。

Watcher
是真正的订阅者。它读取数据时被收集,数据变化时被通知。

完整流程是:

初始化 data
   ↓
Observer 遍历对象
   ↓
defineReactive 转换属性
   ↓
Watcher 读取数据
   ↓
getter 收集依赖
   ↓
数据被修改
   ↓
setter 触发通知
   ↓
Dep 通知 Watcher
   ↓
Watcher 执行更新

这就是 Vue 2 响应式系统的基本骨架。

理解了这条链路,再去看 Vue 源码里的 ObserverDepWatcher,就不会觉得它们是几个孤立的类,而是一套完整的订阅发布系统:

数据负责暴露变化,Dep 负责管理依赖,Watcher 负责响应变化,视图更新只是 Watcher 被触发后的结果。