$set() 实时更新

对象添加属性

对于使用 Object.defineProperty 实现响应式的对象,当我们去给这个对象添加一个新的属性的时候,是不能够触发它的 setter 的,比如:

1const vm = new Vue({
2  data: {
3    a: 1,
4  },
5})
6// vm.b 是非响应的
7vm.b = 2

但是添加新属性的场景我们在平时开发中会经常遇到,那么 Vue 为了解决这个问题,定义了一个全局 API Vue.set 方法

1/**
2 * Set a property on an object. Adds the new property and
3 * triggers change notification if the property doesn't
4 * already exist.
5 */
6export function set(target: Array<any> | Object, key: any, val: any): any {
7  if (process.env.NODE_ENV !== 'production' && (isUndef(target) || isPrimitive(target))) {
8    warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`);
9  }
10  if (Array.isArray(target) && isValidArrayIndex(key)) {
11    target.length = Math.max(target.length, key);
12    target.splice(key, 1, val);
13    return val;
14  }
15  if (key in target && !(key in Object.prototype)) {
16    target[key] = val;
17    return val;
18  }
19  const ob = (target: any).__ob__;
20  if (target._isVue || (ob && ob.vmCount)) {
21    process.env.NODE_ENV !== 'production' &&
22      warn(
23        'Avoid adding reactive properties to a Vue instance or its root $data ' +
24          'at runtime - declare it upfront in the data option.'
25      );
26    return val;
27  }
28  if (!ob) {
29    target[key] = val;
30    return val;
31  }
32  defineReactive(ob.value, key, val);
33  ob.dep.notify();
34  return val;
35}

set 方法接收 3 个参数,

  • target 可能是数组或者是普通对象
  • key 代表的是数组的下标或者是对象的键值
  • val 代表添加的值

首先判断如果 target 是数组且 key 是一个合法的下标,则之前通过 splice 去添加进数组然后返回,这里的 splice 其实已经不仅仅是原生数组的 splice 了,稍后我会详细介绍数组的逻辑。

接着又判断 key 已经存在于 target 中,则直接赋值返回,因为这样的变化是可以观测到了。

接着再获取到 target.__ob__ 并赋值给 ob,之前分析过它是在 Observer 的构造函数执行的时候初始化的,表示 Observer 的一个实例,如果它不存在,则说明 target 不是一个响应式的对象,则直接赋值并返回。

最后通过 defineReactive(ob.value, key, val) 把新添加的属性变成响应式对象,然后再通过 ob.dep.notify() 手动的触发依赖通知,还记得我们在给对象添加 getter 的时候有这么一段逻辑:

1export function defineReactive(obj: Object, key: string, val: any, customSetter?: ?Function, shallow?: boolean) {
2  // ...
3  let childOb = !shallow && observe(val);
4  Object.defineProperty(obj, key, {
5    enumerable: true,
6    configurable: true,
7    get: function reactiveGetter() {
8      const value = getter ? getter.call(obj) : val;
9      if (Dep.target) {
10        dep.depend();
11        if (childOb) {
12          childOb.dep.depend();
13          if (Array.isArray(value)) {
14            dependArray(value);
15          }
16        }
17      }
18      return value;
19    },
20    // ...
21  });
22}

getter 过程中判断了 childOb,并调用了 childOb.dep.depend() 收集了依赖,这就是为什么执行 Vue.set 的时候通过 ob.dep.notify() 能够通知到 watcher,从而让添加新的属性到对象也可以检测到变化。这里如果 value 是个数组,那么就通过 dependArray 把数组每个元素也去做依赖收集。

数组

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

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

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

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

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

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

1export class Observer {
2  constructor(value: any) {
3    this.value = value;
4    this.dep = new Dep();
5    this.vmCount = 0;
6    def(value, '__ob__', this);
7    if (Array.isArray(value)) {
8      const augment = hasProto ? protoAugment : copyAugment;
9      augment(value, arrayMethods, arrayKeys);
10      this.observeArray(value);
11    } else {
12      // ...
13    }
14  }
15}

这里我们只需要关注 valueArray 的情况,首先获取 augment,这里的 hasProto 实际上就是判断对象中是否存在 __proto__,如果存在则 augment 指向 protoAugment, 否则指向 copyAugment,来看一下这两个函数的定义:

1/**
2 * Augment an target Object or Array by intercepting
3 * the prototype chain using __proto__
4 */
5function protoAugment(target, src: Object, keys: any) {
6  /* eslint-disable no-proto */
7  target.__proto__ = src;
8  /* eslint-enable no-proto */
9}
10
11/**
12 * Augment an target Object or Array by defining
13 * hidden properties.
14 */
15/* istanbul ignore next */
16function copyAugment(target: Object, src: Object, keys: Array<string>) {
17  for (let i = 0, l = keys.length; i < l; i++) {
18    const key = keys[i];
19    def(target, key, src[key]);
20  }
21}

protoAugment 方法是直接把 target.__proto__ 原型直接修改为 src,而 copyAugment 方法是遍历 keys,通过 def,也就是 Object.defineProperty 去定义它自身的属性值。对于大部分现代浏览器都会走到 protoAugment,那么它实际上就把 value 的原型指向了 arrayMethods

1import { def } from '../util/index'
2
3const arrayProto = Array.prototype
4export const arrayMethods = Object.create(arrayProto)
5
6const methodsToPatch = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
7
8/**
9 * Intercept mutating methods and emit events
10 */
11methodsToPatch.forEach((method) => {
12  // cache original method
13  const original = arrayProto[method]
14  def(arrayMethods, method, function mutator(...args) {
15    const result = original.apply(this, args)
16    const ob = this.__ob__
17    let inserted
18    switch (method) {
19      case 'push':
20      case 'unshift':
21        inserted = args
22        break
23      case 'splice':
24        inserted = args.slice(2)
25        break
26    }
27    if (inserted)
28      ob.observeArray(inserted)
29    // notify change
30    ob.dep.notify()
31    return result
32  })
33})

可以看到,arrayMethods 首先继承了 Array,然后对数组中所有能改变数组自身的方法,如 push、pop 等这些方法进行重写。

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