探讨 Vue 2 源码中响应式属性添加/删除的限制,以及 `Vue.set` 和 `Vue.delete` (或 “/“) 的源码实现。

各位朋友,大家好!欢迎来到今天的“Vue 2 响应式秘籍”讲座。今天咱们就来聊聊 Vue 2 响应式系统里那些“不能说的秘密”,重点攻克响应式属性的添加/删除限制,以及 Vue.setVue.delete 这两把“尚方宝剑”的内部运作机制。准备好了吗? Let’s dive in!

开场白:响应式系统的“阿喀琉斯之踵”

Vue 2 的响应式系统,基于 Object.defineProperty 来实现数据劫持。它能让数据变化自动驱动视图更新,简直是前端开发者的福音。但正如希腊神话中的阿喀琉斯一样,这个系统也有它的弱点——对于某些操作,它并不能完美地响应。

具体来说,Vue 2 无法检测到以下两种类型的变化:

  1. 直接通过索引修改数组,例如: vm.items[indexOfItem] = newValue
  2. 添加或删除对象的属性,例如: vm.myObject.newProperty = 'hello'delete vm.myObject.existingProperty

为什么会这样呢?因为 Vue 在初始化组件时,会遍历 data 对象的所有属性,并用 Object.defineProperty 将它们转化为 getter/setter。但是,对于后续动态添加的属性,或者通过索引修改的数组项,Vue 就“鞭长莫及”了。

限制背后的原因:Object.defineProperty 的局限性

Object.defineProperty 只能劫持对象已存在的属性。它无法监听对象新增的属性,也无法监听数组通过索引直接修改的操作(因为 Object.defineProperty 是针对对象的属性,而不是数组的索引)。

举个栗子:data 初始化后才有的属性

var vm = new Vue({
  data: {
    message: 'Hello'
  },
  mounted: function() {
    this.newProperty = 'World'; // 这样修改,视图不会更新
  }
})

在这个例子中,newProperty 是在 mounted 钩子函数中添加的,而此时 Vue 已经完成了对 datamessage 的响应式处理。因此,newProperty 并不具备响应式能力,修改它不会触发视图更新。

再来一个:数组索引修改

<template>
  <div>
    <ul>
      <li v-for="(item, index) in items" :key="index">{{ item }}</li>
    </ul>
    <button @click="updateItem">Update Item</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      items: ['A', 'B', 'C']
    };
  },
  methods: {
    updateItem() {
      this.items[0] = 'D'; // 这样修改,视图可能不会立即更新
    }
  }
};
</script>

点击按钮,你会发现视图并不会立即更新。这是因为 Vue 无法检测到通过索引直接修改数组元素的操作。

解决方案:Vue.setVue.delete (或 $set / $delete)

为了解决这些问题,Vue 提供了 Vue.setVue.delete 这两个全局 API (以及它们对应的实例方法 $set$delete)。它们的作用就是:

  • Vue.set(object, key, value) / vm.$set(object, key, value) 向响应式对象中添加一个属性,并确保这个新属性也是响应式的,同时触发视图更新。
  • Vue.delete(object, key) / vm.$delete(object, key) 从响应式对象中删除一个属性,并触发视图更新。

用法示例:

// 添加属性
Vue.set(vm.myObject, 'newProperty', 'World');
// 或者
vm.$set(vm.myObject, 'newProperty', 'World');

// 删除属性
Vue.delete(vm.myObject, 'existingProperty');
// 或者
vm.$delete(vm.myObject, 'existingProperty');

// 修改数组
Vue.set(vm.items, 0, 'D');
//或者
vm.$set(vm.items, 0, 'D');

源码剖析:Vue.set 的实现

Vue.set 的核心思想是:

  1. 如果目标对象是数组,使用 splice 方法来触发更新。
  2. 如果目标对象是普通对象,直接设置属性,然后手动触发依赖更新。

让我们来看一下 Vue.set 的简化版源码(为了方便理解,省略了一些边界情况处理):

/**
 * Set a property on an object. Adds the new property
 * if it does not already exist.
 */
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 是有效的数组索引,那么就使用 splice 方法来插入新值。splice 方法会触发数组的 __ob__.dep.notify(),从而通知所有依赖该数组的 watcher 进行更新。

    • target.length = Math.max(target.length, key):这行代码确保数组的长度足够容纳新的索引。 例如,如果数组长度为 5,而 key 为 10,那么数组长度会被设置为 11,从而保证 splice 操作不会出错。
    • target.splice(key, 1, val):这行代码使用 splice 方法在 key 索引处插入新值 valsplice 方法会改变原数组,并且会触发数组的响应式更新。
  2. 已存在属性处理: 如果key已经在target对象中存在,则直接赋值即可,赋值操作会触发响应式getter/setter,并更新视图。

  3. Vue 实例或 $data 处理: 如果 target 是 Vue 实例或者 Vue 实例的 $data 对象,会发出警告,建议在 data 选项中预先声明这些属性。这是因为在 Vue 实例创建后动态添加响应式属性可能会导致一些难以预测的问题。

  4. 非响应式对象处理: 如果 target 不是响应式对象(即没有 __ob__ 属性),那么直接设置属性即可。因为这种情况下,不需要进行响应式处理。

  5. 普通对象处理: 如果 target 是普通对象,并且尚未被观察(没有 __ob__ 属性),那么直接设置属性,然后返回。

  6. 响应式对象处理: 如果 target 是响应式对象(拥有 __ob__ 属性),说明这个对象已经被 Observer 观察了。那么就需要:

    • defineReactive(ob.value, key, val):调用 defineReactive 函数,将新的属性转化为响应式属性。defineReactive 函数会为 key 创建 getter/setter,并创建一个新的 Dep 对象来管理依赖。
    • ob.dep.notify():手动触发 target 对象自身的依赖更新。这是因为添加新属性可能会影响到依赖于整个对象的 watcher。

defineReactive 函数简述

/**
 * Define a reactive property on an Object.
 */
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()

  const property = Object.getOwnPropertyDescriptor(obj, key)
  if (property && property.configurable === false) {
    return
  }

  // cater for pre-defined getter/setters
  const getter = property && property.get
  const setter = property && property.set

  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key]
  }

  let childOb = !shallow && observe(val)
  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) {
      const value = getter ? getter.call(obj) : val
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      /* eslint-enable no-self-compare */
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter()
      }
      // #7981: for accessor properties without setter
      if (getter && !setter) return
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = !shallow && observe(newVal)
      dep.notify()
    }
  })
}

defineReactive 函数的核心就是使用 Object.defineProperty 为对象的属性 key 定义 getter 和 setter。当读取 obj[key] 时,会触发 getter 函数,getter 函数会将当前活跃的 watcher ( Dep.target ) 收集到 dep 中,以及递归收集子对象的依赖。当设置 obj[key] 时,会触发 setter 函数,setter 函数会更新 val 的值,并且通知 dep 中所有的 watcher 进行更新。

源码剖析:Vue.delete 的实现

Vue.delete 的实现与 Vue.set 类似,也是针对数组和对象分别处理:

  1. 如果目标对象是数组,使用 splice 方法来触发更新。
  2. 如果目标对象是普通对象,删除属性,然后手动触发依赖更新。

下面是 Vue.delete 的简化版源码:

/**
 * Delete a property and trigger change if necessary.
 */
export function del (target: Array<any> | Object, key: any) {
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.splice(key, 1)
    return
  }
  const ob = (target: any).__ob__
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid deleting properties on a Vue instance or its root $data ' +
      '- just set it to null.'
    )
    return
  }
  if (!hasOwn(target, key)) {
    return
  }
  delete target[key]
  if (!ob) {
    return
  }
  ob.dep.notify()
}

代码解释:

  1. 数组处理: 如果 target 是数组,并且 key 是有效的数组索引,那么就使用 splice 方法来删除元素。splice 方法会触发数组的 __ob__.dep.notify(),从而通知所有依赖该数组的 watcher 进行更新。
  2. Vue 实例或 $data 处理: 如果 target 是 Vue 实例或者 Vue 实例的 $data 对象,会发出警告,建议将属性设置为 null 而不是直接删除。
  3. 属性不存在处理: 如果 target 对象本身就不包含 key 属性,那么直接返回,不做任何处理。
  4. 非响应式对象处理: 如果 target 不是响应式对象(即没有 __ob__ 属性),那么直接删除属性即可。
  5. 响应式对象处理: 如果 target 是响应式对象,那么:

    • delete target[key]:删除 target 对象的 key 属性。
    • ob.dep.notify():手动触发 target 对象自身的依赖更新。这是因为删除属性可能会影响到依赖于整个对象的 watcher。

$set$delete:实例方法的实现

$set$delete 实际上只是 Vue.setVue.delete 的一个别名,它们在 Vue 实例内部直接调用了这两个全局 API。

Vue.prototype.$set = set
Vue.prototype.$delete = del

总结:Vue.setVue.delete 的威力

操作 是否自动更新视图? 解决方案
vm.items[indexOfItem] = newValue Vue.set(vm.items, indexOfItem, newValue)
vm.myObject.newProperty = 'hello' Vue.set(vm.myObject, 'newProperty', 'hello')
delete vm.myObject.existingProperty Vue.delete(vm.myObject, 'existingProperty')

Vue.setVue.delete 是 Vue 2 响应式系统的重要补充,它们弥补了 Object.defineProperty 的不足,使得我们可以更灵活地操作响应式数据,并确保视图能够及时更新。

最佳实践:避免运行时动态添加响应式属性

虽然 Vue.setVue.delete 提供了动态添加和删除响应式属性的能力,但最佳实践仍然是:尽量在 data 选项中预先声明所有需要的属性。 这样做可以:

  • 提高代码的可读性和可维护性。
  • 让 Vue 的响应式系统更好地进行优化。
  • 避免一些潜在的性能问题。

注意事项

  1. Vue.setVue.delete 只能用于响应式对象。 如果目标对象不是响应式对象,它们不会有任何效果。

  2. 避免过度使用 Vue.setVue.delete 频繁地添加和删除属性可能会影响性能。如果可能,尽量在初始化时就定义好所有需要的属性。

  3. Vue.setVue.delete 的返回值都是被修改的对象。

结束语

希望通过今天的讲解,大家对 Vue 2 响应式系统中属性添加/删除的限制,以及 Vue.setVue.delete 的实现有了更深入的了解。 掌握这些知识,能让你在 Vue 开发中更加游刃有余,写出更健壮、更高效的代码。

谢谢大家!咱们下期再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注