Vue响应式原理之「对象属性劫持」

1,037 阅读2分钟

在 Vue 中,data 对象是响应式的,即对 data 中属性的修改会触发视图更新,今天就来分析一下这是如何做到的。

首先定义一个普通的 JS 对象,例如:{ name: 'keliq' },我们希望当 name 发生变化时,能收到通知。

在 Vue 当中,有三个非常重要的概念:Observer、Dep 和 Watcher,它们的功能是:

  • Observer:把普通对象变成响应式对象,实现原理就是递归地对属性进行劫持,在 get 的时候把依赖收集到 dep.subs 里面,从而在 set 的时候能够逐个通知
  • Dep:用于存放 Observer 收集到的依赖
  • Watcher:创建观察者,即订阅响应式通知,当目标发生变化时执行回调

Dep

Dep 用于收集依赖和通知更新。

class Dep {
  constructor() {
    this.subs = []
  }
  
  depend() {
    if(Dep.target) this.subs.push(Dep.target) // 收集依赖
  }
  
  notify(newVal, val) {
    this.subs.forEach((sub) => sub.update(newVal, val)) // 通知依赖
  }
}

可以看到,依赖被赋值给了 Dep.target ,每次收集的时候只需要把 Dep.target 存入 subs 中即可。

Observer

Observer 会对 obj 中的属性进行拦截,在 get 中收集依赖,在 set 中通知更新。

class Observer {
  static observe(value) {
    if (typeof value !== 'object' || value == null) return
    return new Observer(value)
  }

  constructor(value) {
    this.value = value
    this.walk(value)
  }

  walk(obj) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      this.intercept(obj, keys[i], obj[keys[i]]) // 劫持对象所有属性
    }
  }

  intercept(data, key, val) {
    Observer.observe(val) // 递归响应式
    const dep = new Dep()
    Object.defineProperty(data, key, {
      enumerable: true,
      configurable: true,
      get: function () {
        dep.depend()
        return val
      },
      set: function (newVal) {
        if (val === newVal) return
        dep.notify(newVal, val) // 通知观察者
        val = newVal
      },
    })
  }
}

walk 的目的是对 obj 的所有属性进行递归响应式处理,也就是说如果 obj 的属性还是一个对象的话,这个对象属性的变更也是能检测出来的。

Watcher

Watcher 是观察者,其中 update 方法接收属性变化时的新旧值,然后执行回调函数,get 方法用于把自己放到观察属性的依赖列表中。

class Watcher {
  constructor(obj, expOrFn, cb) {
    this.obj = obj
    if(typeof expOrFn === 'function') this.getter = expOrFn
    else this.getter = obj => expOrFn.split('.').reduce((it, k) => it && it[k], obj) // 取 obj.a.b.c.d 这种深层的属性
    this.cb = cb
    this.value = this.get()
  }

  get() {
    Dep.target = this
    const value = this.getter(this.obj) // 调用 getter 取属性值
    Dep.target = undefined
    return value
  }

  update(newVal, val) {
    this.cb.call(this.obj, newVal, val) // 调用 cb 函数
  }
}

当 new Watcher 的时候,传递三个参数:

  • 观测哪个对象
  • 观测对象的哪个属性(或自定义观测逻辑)
  • 该属性发生变化时的回调函数

到这里代码就写完了,快来试试效果吧,大家把控制台打开,然后把上面的 Dep、Observer 和 Watcher 类复制进去,然后再运行下面代码:

const obj = { name: 'keliq', age: 12 }
Observer.observe(obj)
new Watcher(obj, 'name', (newVal, val) =>
  console.log(`name发生变化:${val}->${newVal}`)
)
obj.name = 'qiao'

控制台能打印出 keliq->qiao 的同学请举爪🙋。如果 Watcher 的第二个参数是个函数,会更灵活一些:

new Watcher(
  obj,
  function () {
    obj.name
    obj.age
  },
  function (newVal, val) {
    console.log(`发生变化:${val}->${newVal}`)
  }
)

obj.name = 'qiao'
obj.age = 15

最后给出 Vue 响应式原理中「对象属性劫持」的整体流程图:

Vue对象属性劫持