# Object的变化侦测

相信很多同学都知道,Vue2的变化侦测实际上是使用了ES5的Object.defineProperty,这也是Vue2为什么不支持IE8及以下浏览器的原因。本节我们就探究Vue2源码中是如何利用Object.defineProperty实现变化侦测的。

# Object.defineProperty

Object.defineProperty 静态方法会直接在一个对象上定义一个新属性,或修改其现有属性,并返回此对象。

用法如下:

/**
 * 在对象上定义新的属性或修改现有属性的特性。
 * @param {object} obj - 要定义属性的对象。
 * @param {string | symbol} prop - 要定义或修改的属性的名称。
 * @param {PropertyDescriptor} descriptor - 定义或修改属性的特性描述符。
 * @returns {object} - 被定义或修改属性的对象。
 */
Object.defineProperty(obj, prop, descriptor)
成功
1
2
3
4
5
6
7
8

其中 descriptor 包含很多可选属性,具体可查看文档 (opens new window), 这里列举常用的几个:

  • configurable(布尔值):表示该属性是否可被删除或修改特性,默认为false
  • enumerable(布尔值):表示该属性是否可通过for...in循环或Object.keys方法遍历,默认为false
  • value:表示该属性的值,默认为undefined
  • writable(布尔值):表示该属性是否可被赋值运算符修改,默认为false
  • get:获取该属性的访问器函数。
  • set:设置该属性的访问器函数。

我们可以写个简单的例子

const obj = {}

let val = 'Default content'

Object.defineProperty(obj, 'str', {
  enumerable: true,
  configurable: true,
  get() {
    console.log('str 被读取');
    return val;
  },
  set(newVal) {
    console.log('str 被设置');
    val = newVal;
  }
})

console.log(obj.str);
obj.str = 'Hello world'
console.log(obj.str);
成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

上述代码运行如下

str 被读取
Default content
str 被设置
str 被读取
Hello world
成功
1
2
3
4
5

在这个例子中,我们将obj中的str属性设置为可枚举、可配置的,并设置了getter和setter函数。每当从obj的str函数读取数据时,get函数被触发。每当obj的str属性被写入值时,set函数被触发。这也就意味着obj的str可以被侦测了。

# 侦测Object所有属性

当我们可以侦测一个属性的时候,也就意味着我们可以侦测到所有的属性。我们看下Vue源码是如何实现这个功能的,源码在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)) {
      // 数组的逻辑,后续分析
    } 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,key,val) {
  // 传入参数没有val,则手动获取
  if (arguments.length === 2) {
    val = obj[key]
  }

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get(){
      console.log(`${key}属性被读取`);
      return val;
    },
    set(newVal){
      // 如果新值和旧值相同,或者都是 NaN,则不进行任何操作
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      console.log(`${key}属性被设置`);
      val = newVal;
    }
  })
}
成功
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
50
51

在上述代码中,我们定义了Observer类,它可以将对象转为可侦测的对象。

首先在对象上定义不可枚举的 __ob__ 属性,表示此对象已经为响应式。并将其值设置为当前 Observer 实例。

然后判断对象是否为数组,如果不是,则执行walk函数,遍历对象的所有属性,将每个属性转换为响应式。

Observer类定义好后,我们就可以将一个对象的所有属性都转化为可侦测的响应式属性。

# 依赖收集

如果只是单独把 Object.defineProperty 进行封装并不会给我们带来帮助,我们还需要将观测中的依赖收集起来,在需要用到的时候触发变更通知。

在Vue2中,在模板中使用数据,当数据发生变更时,模板会被触发重新渲染。

实际上这个流程就是先收集相关属性的依赖,再等属性发生变更的时候把之前收集到的依赖挨个触发一遍。

总结来说,就是在getter中收集依赖,在setter中触发依赖。

那么我们怎么管理收集的依赖呢,最好的方案是每个属性都有各自的依赖管理器,各自独立维护防止冲突。在Vue2中使用了依赖管理器Dep类,源码在src/core/observer/dep.js

export default class Dep {
  static target: ?Watcher; // 当前正在计算的依赖项
  id: number; // 依赖项的唯一标识
  subs: Array<Watcher>; // 订阅该依赖项的 Watcher 实例数组

  constructor() {
    this.id = uid++; // 分配唯一的依赖项标识
    this.subs = []; // 初始化订阅数组
  }

  // 添加 Watcher 实例到订阅数组中
  addSub(sub: Watcher) {
    this.subs.push(sub); 
  }

  // 从订阅数组中移除指定的 Watcher 实例
  removeSub(sub: Watcher) {
    remove(this.subs, sub); 
  }

  depend() {
    if (Dep.target) {
      // 将当前依赖项添加到当前正在计算的 Watcher 实例的依赖项列表中
      Dep.target.addDep(this); 
    }
  }

  notify() {
    // 复制订阅数组
    const subs = this.subs.slice(); 
    // 循环遍历订阅数组,调用每个 Watcher 实例的 update 方法
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update();
    }
  }
}
成功
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

这段代码较为简单,定义了一个Dep类,并实现了添加、删除、触发等操作依赖的方法。

通过Dep类对依赖的管理,我们就可以实现在getter中收集依赖,在setter中触发依赖。defineReactive更新如下

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

  // 初始化依赖管理器
  const dep = new Dep()

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

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get(){
      console.log(`${key}属性被读取`);
      // getter中收集依赖
      if (Dep.target) {
        dep.depend()
        if (childOb) {
          // 为val收集依赖
          childOb.dep.depend()
        }
      }
      return val;
    },
    set(newVal){
      // 如果新值和旧值相同,或者都是 NaN,则不进行任何操作
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      console.log(`${key}属性被设置`);
      val = newVal;
      // setter中触发依赖
      dep.notify()
    }
  })
}
成功
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

在上述代码中,我们在getter中调用了dep.depend()方法收集依赖,在setter中调用dep.notify()方法通知所有依赖更新。

# 谁使用依赖

经过上述操作,我们定义好了依赖,也收集好了依赖,那我们收集到的依赖,谁去使用呢?换句话说,当属性发生变化的时候,依赖应该去通知谁呢?

我们要通知用到数据的地方,而使用这个数据的地方有很多,而且类型还不一样,既有可能是模板,也有可能是用户写的一个 watch

这时我们需要抽象出一个能集中处理这些情况的Watcher类,然后我们在依赖收集阶段只收集这个封装好的Watcher类的实例进来,通知也只通知它一个。接着它再负责通知其他地方。

Vue2中的Watcher类源码位于src/core/observer/watcher.js,简化代码如下

export default class Watcher {
  constructor (vm,expOrFn,cb) {
    this.vm = vm;
    this.cb = cb;
    this.getter = parsePath(expOrFn)
    this.value = this.get()
  }
  get () {
    Dep.target = this;
    const vm = this.vm
    let value = this.getter.call(vm, vm)
    Dep.target = undefined;
    return value
  }
  update () {
    const oldValue = this.value
    this.value = this.get()
    this.cb.call(this.vm, this.value, oldValue)
  }
}

/**
 * Parse simple path.
 * 把一个形如'data.a.b.c'的字符串路径所表示的值,从真实的data对象中取出来
 * 例如:
 * data = {a:{b:{c:2}}}
 * parsePath('a.b.c')(data)  // 2
 */
const bailRE = /[^\w.$]/
export function parsePath (path) {
  if (bailRE.test(path)) {
    return
  }
  const segments = path.split('.')
  return function (obj) {
    for (let i = 0; i < segments.length; i++) {
      if (!obj) return
      obj = obj[segments[i]]
    }
    return obj
  }
}
成功
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
  1. 当实例化Watcher类时,会先执行其构造函数
  2. 在构造函数中调用了this.get()实例方法
  3. get()方法中,首先通过Dep.target = this把实例自身赋给了全局的一个唯一对象Dep.target上,然后通过let value = this.getter.call(vm, vm)获取一下被依赖的数据,获取被依赖数据的目的是触发该数据上面的getter,上文我们说过,在getter里会调用dep.depend()收集依赖,而在dep.depend()中取到挂载Dep.target上的值并将其存入依赖数组中,在get()方法最后将Dep.target释放掉
  4. 而当数据变化时,会触发数据的setter,在setter中调用了dep.notify()方法,在dep.notify()方法中,遍历所有依赖(即watcher实例),执行依赖的update()方法,也就是Watcher类中的update()实例方法,在update()方法中调用数据变化的更新回调函数,从而更新视图。

通过上述代码,就彻底完成了对Object数据的侦测,依赖收集,依赖的更新等所有操作。

# 不足

通过 Object.defineProperty 来将对象的 key 转换成 getter/setter 的形式来追踪变化,但 getter/setter 只能追踪一个数据是否被修改,无法追踪新增属性和删除属性,所以导致当我们对object数据添加或删除值时,无法通知依赖,无法驱动视图进行响应式更新。

但这也是没有办法的事,因为在 ES6 之前,JavaScript 没有提供元编程的能力,无法侦测到个新属性被添加到了对象中,也无法侦测到一个属性从对象中删除了。为了解决这个问题 Vue2 提供了两个 API vm.$setvm.$delete,后续详细介绍它们。

# 总结

变化侦测就是侦测数据的变化。当数据发生变化时,要能侦测到并发出通知。

Object 可以通过 Object.defineProperty 将属性转换成 getter/setter 的形式来追踪变化读取数据时会触发 getter,修改数据时会触发 setter

我们需要在 getter 中收集有哪些依赖使用了数据。当 setter 被触发时,去通知 getter 中收集的依赖数据发生了变化。

收集依赖需要为依赖找一个存储依赖的地方,为此我们创建了 Dep,它用来收集依赖、删除依赖和向依赖发送消息等。

所谓的依赖,其实就是 watcher。只有 watcher 触发的 getter才会收集依赖,哪个 watcher触发了 getter,就把哪个 watcher 收集到 Dep 中。当数据发生变化时,会循环依赖列表,把所有的 watcher 都通知一遍。