# Array的变化侦测

很多人可能比较疑惑,为什么Array的侦测方式跟Object的不同,毕竟Array也是对象类型,我们举个例子说明下

list.push('hello')
成功
1

在这个例子中我们通过Array原型上的方法来改变数组的内容,并不会触发 getter/setter,所以我们需要针对Array的变化单独处理。

虽然需要单独处理,但基本思想还是不变的,都是在获取数据的时候收集依赖,数据变化的时候通知依赖更新。

# 收集依赖

那么我们应该如何收集Array的依赖呢?其实ArrayObject一样,都是通过getter收集的。

有的同学就很疑惑了,刚不还说不一样,怎么转眼就都用getter了?

我们回想下,我们在日常开发中使用Array的时候,是不是如下的写法:

data(){
  return {
    list:[1, 2, 3]
  }
}
成功
1
2
3
4
5

list永远都处于Object的包裹中,当我们想获取到list的时候,就需要从Object的属性中获取,当我们使用this.list时,就会触发Objectlist属性的getter。从而收集到list的依赖。

所以Array的依赖跟Object一样,都在 defineReactive 中收集

function defineReactive (obj,key,val) {
  // 传入参数没有val,则手动获取
  if (arguments.length === 2) {
    val = obj[key]
  }

  // 如果存在val时对象或者数组创建observer
  let childOb = !shallow && observe(val)

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get(){
      console.log(`${key}属性被读取`);
      if (Dep.target) {
        dep.depend()
        if (childOb) {
          // 为val收集依赖
          childOb.dep.depend()
          if (Array.isArray(value)) {
            // 如果是数组,则跟踪数组依赖
            dependArray(value)
          }
        }
      }
      return val;
    },
    set(newVal){
      // 如果新值和旧值相同,或者都是 NaN,则不进行任何操作
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      console.log(`${key}属性被设置`);
      val = newVal;
    }
  })
}

function dependArray (value: Array<any>) {
  for (let e, i = 0, l = value.length; i < l; i++) {
    e = value[i]
    // 已有observer时,直接收集数组的依赖,后续有讲
    e && e.__ob__ && e.__ob__.dep.depend()
    if (Array.isArray(e)) {
      // 多重数组递归调用
      dependArray(e)
    }
  }
}
成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

当触发Object.definePropertygetter时,我们判断当前val是否是数组,如果是,则调用dependArray以收集依赖项。

所以,Arraygetter中收集依赖。

# 依赖列表存到哪?

既然Arraygetter中收集依赖,而给数组数据添加getter/setter都是在Observer类中完成的,所以我们也应该在Observer类中收集依赖。源码在src/core/observer/index.js

export class Observer {
  value: any; // 保存观察的对象
  dep: Dep; // 依赖实例,用于跟踪依赖项
  vmCount: number; // 作为根$数据拥有此对象的虚拟机数量

  constructor (value: any) {
    this.value = value; 
    this.dep = new Dep(); 
    this.vmCount = 0; // 初始化虚拟机数量为0
    // 在对象上定义不可枚举的 __ob__ 属性,并将其值设置为当前 Observer 实例
    // 表示此对象已经为响应式
    def(value, '__ob__', this); 
    if (Array.isArray(value)) {
      // 支持原型继承(hasProto),使用原型继承的方式(protoAugment)
      // 不支持原型继承(hasProto),使用拷贝属性的方式(copyAugment)
      const augment = hasProto
        ? protoAugment
        : copyAugment
      // 将 arrayMethods 的方法混入数组对象
      augment(value, arrayMethods, arrayKeys)
      // 对数组中的每一项调用 observe
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }

  observeArray (items: Array<any>) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i])
    }
  }
}
成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

可以看到在Observer类中定义了this.dep = new Dep();,将dep(依赖)保存在了Observer的实例中,再将实例挂载到了this.__ob__

这样挂载完毕后,在dependArray中就可以通过__ob__.dep.depend()将数组的依赖保存在Observer的实例中。

为什么数组的依赖要保存在`Observer`的实例中?

数组的依赖,既要保证在getter中能访问到,也要能在后续Array触发的时候(拦截器中)能访问到,Observer就成了最好的保存位置。

# 触发依赖

依赖收集完毕后,我们来研究下如何触发依赖,之前说过Array原型上的方法来改变数组的内容,并不会触发 setter。所以也没办法通过setter去触发依赖。

但既然是通过Array原型上的方法来改变数组内容,那我们就加个拦截器去覆盖原型上的方法,以push为例:

let arr = [1,2,3]
arr.push(4)
Array.prototype.newPush = function(val){
  console.log('arr被修改了')
  this.push(val)
}
arr.newPush(4)
成功
1
2
3
4
5
6
7

在上面这个例子中,我们针对数组的原生push方法定义个一个新的newPush方法,这个newPush方法内部调用了原生push方法,这样就保证了新的newPush方法跟原生push方法具有相同的功能,而且我们还可以在新的newPush方法内部干一些别的事情,比如触发依赖。

其实在 Vue 内部,就是这么处理的,源码在src/core/observer/array.js

const arrayProto = Array.prototype
// 创建一个继承自Array.prototype的对象,后续在此基础上修改。防止污染Array
export const arrayMethods = Object.create(arrayProto)

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

/**
 * Intercept mutating methods and emit events
 */
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
  })
})
成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

上述代码创建了一个数组方法拦截器,它拦截在数组实例与Array.prototype之间,在拦截器内重写了针对pushpopshiftunshiftsplicesortreverse七个数组原型中的方法拦截,当数组实例使用操作数组方法时,其实使用的是拦截器中重写的方法,而不再使用Array.prototype上的原生方法。如下图所示

数组拦截器

在拦截器中,通过this.__ob__获取到对应的Observer实例,并触发其中的依赖。this.__ob__就是之前讲的Observer实例。

Vue拦截数组时,为什么要使用Object.create(Array.prototype)?

使用Object.create继承Array.prototype的所有方法,可以在这个对象上进行修改的时候而不影响原始的Array.prototype

综上所述,Arraygetter中收集依赖,在拦截器中触发依赖。从而实现对Array的变化侦测。

# 不足

通过拦截器,我们实现了对Array的触发依赖,但这种方法仅限于对拦截的pushpopshiftunshiftsplicesortreverse七个方法有效。有一些数组操作是无法拦截的。例如:

this.list[0] = 1;
成功
1

通过下标操作数组,无法侦测到数组的变化

this.list.length = 0;
成功
1

使用.length = 0的方式清空数组,也不侦测到数组的变化

为了解决这个问题 Vue2 提供了两个 API vm.$setvm.$delete,后续详细介绍它们。

# 总结

Array可以通过被Object包裹的方式,在this.list之类的操作中触发getter,从而收集依赖。

Array通过原型上的方法调用时,无法触发setter,我们只能针对原型上的方法封装拦截器,当数组实例使用操作数组方法时,其实使用的是拦截器中重写的方法,从而触发依赖。

为了让getter和拦截器中的依赖都能访问到,Vue将依赖列表放置在Observer的实例中。