Vue响应式原理之「数组方法劫持」

251 阅读4分钟

在之前的文章中介绍过 Vue响应式原理之「对象属性劫持」,对于对象的属性,Vue 是利用 Object.defineProperty 进行属性劫持,把依赖收集到 dep 中,当属性更新的时候会触发 set 方法,set 方法内部会调用 dep.notify 来执行观察者提供的回调函数。

我们看下如果属性值是数组的场景:

const obj = {
  name: 'keliq',
  age: 12,
  students: [
    { name: 'Tom', age: 11 },
    { name: 'Jim', age: 12 },
  ],
}
Observer.observe(obj)
new Watcher(obj, 'students', (newVal, val) =>
  console.log(`students发生变化:${val}->${newVal}`)
)
obj.students = [{ name: 'Leo', age: 13 }]

因为 students 属性被拦截了,修改的时候 set 方法被触发 watcher 回调的执行,一切正常。但是数组大概率会做下面的操作:

obj.students.push({ name: 'Leo', age: 13 })

如果用「对象属性劫持」里面写的逻辑,这个时候就监听不到了。为了实现数组的响应式,我们只需要对 Observer 类做一些改动,Dep 和 Watcher 类保持不变即可。我们先回顾一下这三个类:

Dep

用于收集依赖和通知更新,subs 里面存放的是收集到的 Watcher 实例。

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)) // 通知依赖
  }
}

Watcher

用于创建观察者,需要提供观察哪个对象的哪个属性以及当被观察的属性发生变化时执行的回调函数

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)
    this.cb = cb
    this.value = this.get() // 只要 new Watcher 就会被 dep 收集到
  }

  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 函数
  }
}

Observer

文章开头已经讲到:由于数组是引用类型,用 pushpop 等方法修改数组的时候,数组的变化是无法被监听到的,那怎么办呢?答案就是:使用重写数组原型上的方法来实现劫持。我们对之前写的 Observer 类进行改造:

class Observer {
  constructor(value) {
    this.value = value
    this.dep = new Dep() // ——> 给实例新增 dep 属性
    Object.defineProperty(value, '__ob__', { //  ——> 给 value 增加 __ob__ 不可枚举属性
      enumerable: false,
      configurable: false,
      value: this,
    })
    if (Array.isArray(value)) {
      rewriteArrayMethods(value) //  ——> 重写数组方法(关键在这里)
      this.observeArray(value) //  ——> 对数组内的元素也进行响应式处理
    } else {
      this.walk(value)
    }
  }

  static observe(value) {
    // 如果存在 __ob__ 属性说明已经是响应式
    if (typeof value !== 'object' || value == null || value.__ob__) return
    return new Observer(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) {
    const ob = Observer.observe(val) //  ——> 获取 Observer 实例
    const dep = new Dep()
    Object.defineProperty(data, key, {
      enumerable: true,
      configurable: true,
      get: function () {
        dep.depend()
        ob.dep.depend() //  ——> 数组的依赖收集
        return val
      },
      set: function (newVal) {
        if (val === newVal) return
        dep.notify(newVal, val)
        Observer.observe(newVal)
        val = newVal
      },
    })
  }

  observeArray(arr) {
    arr.forEach((it) => Observer.observe(it)) // ——> 数组递归响应式
  }
}

可以看到,数组的依赖则是放到 ob.dep 里面的,也就是 Observer 实例的 dep 属性中,为什么要这么做呢?因为在拦截数组原型方法的时候,会用到 dep.notify 方法通知更新,所以要在 Observer 实例上也定义 dep 属性。下面是重写数组原型的函数:

// 重写数组原型上的方法
function rewriteArrayMethods(value) {
  const arrayProto = Array.prototype
  const arrayMethods = Object.create(arrayProto)
  const methods = [
    'push',
    'pop',
    'shift',
    'unshift',
    'reverse',
    'sort',
    'splice',
  ]
  methods.forEach((method) => {
    arrayMethods[method] = function (...args) {
      const result = arrayProto[method].apply(this, args)
      let inserted, ob = value.__ob__
      switch (method) {
        case 'push':
        case 'unshift':
          inserted = args
          break
        case 'splice':
          inserted = args.slice(2)
        default:
          break
      }
      if (inserted) ob.observeArray(inserted) // 数组中的元素也要进行响应式处理
      ob.dep.notify(value, value) // 数组被更新,通知观察者回调
      return result
    }
  })
  Object.setPrototypeOf(value, arrayMethods)
}

总结

可以看到,当属性值是数组的时候,虽然属性关联的 dep 还在,但是只能监听属性值的引用变化的情况,即 obj.students = xxx 这种重新赋值的场景。

对于 obj.students.push()obj.students.pop() 等场景,需要给 students 数组本身也关联了一个 dep,这样在拦截数组原型方法的时候,会调用这个 dep.notify 来通知更新。

为了加深理解,出一道题,请问 Vue 对下面的对象进行响应式处理的时候,总共关联了多少个 dep?

const obj = {
  name: 'keliq',
  age: 12,
  students: [
    { name: 'Tom', age: 11 },
    { name: 'Jim', age: 12 },
  ],
}

答案是:11 个 dep,它们分别是:

  • obj 本身关联了1个dep,里面的 nameagestudents 属性各有1个dep,合计4个

  • students 数组本身关联了一个 dep,合计1个

  • { name: 'Tom', age: 11 } 本身关联了1个dep,里面的 nameage 各有1个dep,合计3个

  • { name: 'Tom', age: 11 } 本身关联了1个dep,里面的 nameage 各有1个dep,合计3个