1

概况

在 Vue 开发的过程中,多少都会遇到数据更新后,页面没有更新渲染这类问题。而在上两篇文章《Vue 响应式原理剖析 —— 从实例化、渲染到数据更新(上)》《Vue 响应式原理剖析 —— 从实例化、渲染到数据更新(下)》中,从「实例化」、「渲染」、「数据更新」三条线完整地讲述了 Vue「响应式」的工作原理,本文正是基于这些原理去解决一些常见的数据更新相关问题。

对象数据的某些修改无法被检听?

如下的一个场景,obj.message 赋值时能否被监听响应呢?

var vm = new Vue({
    data: {
        obj: {
            a: 1
        },
    },
    template: '<div>{{ obj.message }}</div>'
});

vm.obj.message = 'modified';

答案是不能被监听的。原因:对象属性的添加和删除无法被 Object.defineProperty 监听,正如前文所述,Vue 的数据响应式基于 Object.defineProperty 实现,因此也受限。

解决办法: Vue 提供了特定的方法 vm.$set(obj, propertyName, newValue) 来处理这种情况,至于该方法的具体逻辑,后面会详细展开说明。

数组数据的某些修改无法被监听?

如下的一个场景,三个赋值语句里面,哪个能被监听响应呢?

const vm = new Vue({
    data: {
        items: [1, 2, 3, 4, 5],
    },
});
vm.items[1] = 8;
vm.items[5] = 6;
vm.items.length = 2;

答案是三个操作都不能被监听到。原因:

  • 第二个操作 vm.items[5] = 6 应该是比较明显的,与上面对象添加和删除属性类似,数组新添加的元素和删除元素无法被 Object.defineProperty 监听。
  • 第三个操作 vm.items.length = 2 也是由于 Object.defineProperty 的限制,数组的长度直接修改也无法被监听。
  • 最容易误判的可能是第一个操作 vm.items[1] = 8,一种较为常规的说法是 Object.defineProperty 不支持监听数组元素的变化,要验证这个说法可以直接用一个例子说明真实的情况。

如下的一个例子:

function defineReactive(obj, key, val) {
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get() {
            console.log('读取 index ' + key, '当前值是 ' + val)
            return val;
        },
        set(newVal) {
            if (val === newVal) {
                return;
            }
            val = newVal
            console.log('修改 index ' + key, '新值是 ' + val)
        }
    })
}

const testArray = [1, 2, 3, 4, 5]

testArray.forEach((c, index) => {
    defineReactive(testArray, index, c)
});

testArray[0] = 100;
testArray[5] = 600;

数组监听示例结果

也可以点击这里打开示例查看控制台输出。可以看到,超出范围的数组元素操作 Object.defineProperty 确实无法监听,但范围内的元素重新赋值是可以被监听的。那么为什么在 Vue 中对数组类型的 data,直接使用下标赋值无法被监听呢?

答案是出于性能考虑,从上面的基础例子中可以看到,对象和数组如果需要监听每个属性和元素,实际上是对每个属性或者元素进行 Object.defineProperty 劫持,对象是监听 key 而数组则是以数字下标作为 key,数组的数据量可能会很大,因此 Vue 出于性能考虑,并没有对元素下标进行响应式处理。作为补充,Vue 对数组原型链上的几个方法进行劫持,对于会导致元素新增的3个方法 pushpopunshift 会在内部获取新增的元素,执行响应式的处理:

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

methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__
    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
  })
})

与对象类似,如果需要为数字元素重新赋值,可以使用 vm.$set(arr, indexOfItem, newValue) 方法,这里展示一下 $set 的具体实现:

// 精简了非 production 逻辑
export function set (target: Array<any> | Object, key: any, val: any): 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 (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid adding reactive properties to a Vue instance or its root $data ' +
      'at runtime - declare it upfront in the data option.'
    )
    return val
  }
  if (!ob) {
    target[key] = val
    return val
  }
  defineReactive(ob.value, key, val)
  ob.dep.notify()
  return val
}

主要的逻辑包括以下操作:

  1. 这个方法同时用于对象和数组,因此第一步会检验传入的 target 是否为数组,并且传入的 key(即数组的数字下标)是否符合数组的长度范围(如上面所述,Object.defineProperty 不支持劫持新添加的元素),符合的元素会调用 splice 插入到数组中,由于 splice 已经被劫持,新增加的元素会进行「响应式」处理。
  2. 判断如果 key 原先已存在,则无需再监听。
  3. 判断是根节点 Vue,即最外层的 Vue,或者已经有 __ob__ 属性(表示已经进行响应式处理,详情可以浏览前文),则无需再进行监听。
  4. 如果不符合前面的条件,则表明该属性需要执行「响应式」处理,会调用 defineReactive 方法(响应式数据封装的入口方法,详情可以浏览前文)。

kayo5994
41 声望9 粉丝