剖析Vue.set及数组的响应式实现

223 阅读2分钟

通过前面对响应式原理的分析,我们已经知道了依赖收集和派发更新的整个流程。但还有一部分场景没分析到,比如你是否想过数组是如何实现依赖收集和派发更新的,对于 Object.defineProperty 实现的响应式对象,当我们去给这个对象添加一个新的属性的时候,界面是不能重新渲染的,但是添加新属性的场景我们在平时开发中会经常遇到。为了解决这个问题,Vue提供了一个全局API Vue.set 方法,那你有没有思考过它的实现原理,接下来我们就从源码的角度来看 Vue 是如何处理这些特殊情况的。

让我们从一个简单的demo入手:

对象

` <div id="app">
    <button @click="change">{{msg}}</button> // { a: 'a', b: 'b' }
  </div>
  <script>
    new Vue({
      el: '#app',
      data() {
        return {
          msg: {
            a: 'a'
          }
        }
      },
      methods: {
        change() {
          // this.msg.b = 'b' 无效
          this.$set(this.msg, 'b', 'b') // 
        }
      }
    })
  </script> `

可以看到,只有通过$set才能真正的修改数据,触发视图重新渲染。由于我们是 基于对象的健值 实现的 getset 拦截函数,所以对象上不存在的属性是不会添加拦截函数的,让我们看一下之前的实现逻辑: 数据的

function observe (value: any): Observer | void { // observe返回Observer实例
  if (!isObject(value)) {
    return
  }
  let ob: Observer | void
  ob = new Observer(value)
  return ob
}

 class Observer {
  value: any;
  dep: Dep;

  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    def(value, '__ob__', this) // value.__ob__ = observer实例
    if (Array.isArray(value)) { 
      if (hasProto) {
        protoAugment(value, arrayMethods) // value.__proto__ = arrayMethods
      } 
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }
  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }
 }

function defineReactive (
  obj: Object,
  key: string, // 'a'
) {
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        dep.depend()
        if (childOb) {
          childOb.dep.depend()
          if (Array.isArray(value)) {
            dependArray(value)
          }
        }
      }
      return value
    },
    set: function reactiveSetter (newVal) {
    }
  })
}

$set 函数内部又有什么黑魔法,让我们可以成功的修改数据呢,让我们探究一下它的实现:

function set (target: Array<any> | Object, key: any, val: any): any {
  if ((isUndef(target) || isPrimitive(target))) {
    warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
  }
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    return val
  }
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  const ob = (target: any).__ob__
  if (!ob) {
    target[key] = val
    return val
  }
  defineReactive(ob.value, key, val)
  ob.dep.notify()
  return val
}

Vue.prototype.$set = set
  1. set 方法接收 3 个参数,target 可能是数组或者是普通对象,key 代表的是数组的下标或者是对象的键值,val 代表添加的值。
  2. 首先判断如果 target 是数组且 key 是一个合法的下标,则通过 splice 去添加进数组然后返回,这里的 splice 其实已经不仅仅是原生数组的 splice 了,稍后我会详细介绍数组的实现逻辑。
  3. 接着又判断 key 已经存在于 target 中(已经被代理过),则直接赋值返回,因为这样的变化是可以观测到了。
  4. 接着再获取到 target.__ob__ 并赋值给 ob,之前分析过它是在实例化 Observer 的构造函数的时候赋值的,表示是 Observer 的一个实例,如果它不存在,则说明 target 不是一个响应式的对象(没被代理过,不是),则直接赋值并返回。
  5. 最后通过调用defineReactive(ob.value, key, val) 把新添加的属性变成响应式对象,然后通过 ob.dep.notify() 手动的触发依赖更新。

数组

接着说一下数组的情况,Vue 也是不能检测到以下数组的变动:

1.当你利用索引直接设置一个项时,例如:vm.items[index] = newValue

2.当你修改数组的长度时,例如:vm.items.length = newLength

对于第一种情况,可以使用:Vue.set(vm.items, index, newValue);而对于第二种情况,可以使用 vm.items.splice(newLength)

我们刚才也分析到,对于 Vue.set 的实现,当 target 是数组的时候,也是通过 target.splice(key, 1, val) 来添加的,那么这里的 splice 到底有什么黑魔法,能让添加的对象变成响应式的呢。

其实之前我们也分析过,在通过 observe 方法去观察对象的时候会实例化 Observer,在它的构造函数中是专门对数组做了处理:

class Observer {
  value: any;
  dep: Dep;

  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    def(value, '__ob__', this) 
    if (Array.isArray(value)) { // 对数组的处理
      if (hasProto) {
        protoAugment(value, arrayMethods) // value.__proto__ = arrayMethods
      }
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }
}
 
function protoAugment (target, src: Object) {
  target.__proto__ = src
}

protoAugment 方法是直接把 target.__proto__ 原型直接修改为 src,实际上就把 value 的原型指向了 arrayMethods,以下是arrayMethods的定义:

const arrayProto = Array.prototype
const arrayMethods = Object.create(arrayProto)

const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

methodsToPatch.forEach(function (method) {
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__ // Observer
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    ob.dep.notify()
    return result
  })
})

function def (obj: Object, key: string, val: any, enumerable?: boolean) {
  Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable, // false
    writable: true,
    configurable: true
  })

可以看到,arrayMethods 首先继承了 Array.prototype,然后对数组中所有能改变数组自身的方法,如 push、pop、shift、unshift、splice、sort、reverse 等这些方法进行重写。重写后的方法会先执行它们本身原有的逻辑,并对能增加数组长度的 3 个方法 push、unshift、splice 方法做了判断,获取到插入的值,然后把新添加的值变成一个响应式对象,并且再调用 ob.dep.notify() 手动触发依赖通知,这就很好地解释了之前的示例中调用 vm.items.splice(newLength) 方法可以检测到变化。

手绘流程图如下:

40.png

Vue 同样提供了 Vue.del 的全局 API,它的实现和 Vue.set 大同小异,代码如下:

function del (target: Array<any> | Object, key: any) {
  if ((isUndef(target) || isPrimitive(target))) {
    warn(`Cannot delete reactive property on undefined, null, or primitive value: ${(target: any)}`)
  }
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.splice(key, 1)
    return
  }
  const ob = (target: any).__ob__
  if (!hasOwn(target, key)) {
    return
  }
  delete target[key]
  if (!ob) {
    return
  }
  ob.dep.notify()
}

Vue.prototype.$delete = del
  1. del 方法接收 2 个参数,target 可能是数组或者是普通对象,key 代表的是数组的下标或者是对象的键值。
  2. 首先判断如果 target 是数组且 key 是一个合法的下标,则通过 splice 去删除数组某一项。
  3. 接着又判断 key 是否已经存在于 target 中了,因为我们不能去删除一个对象上不存在的属性,只有存在才会删除该属性。
  4. 接着再获取到 target.__ob__ 并赋值给 ob,如果它不存在,则说明 target 不是一个响应式的对象,不需要触发视图更新。
  5. 如果ob存在,则需要通过 ob.dep.notify() 手动的触发更新。

总结

通过这一节的分析,我们对响应式对象又有了更全面的认识,如果在实际工作中遇到了这些特殊情况,我们就可以知道如何把它们也变成响应式的对象。