vue源码分析(十八) 数据响应系统 —— Observe

10/18/2018 vue源码分析面试

# 1. 概述

​ 上一章我们分析初始化的时候预留了 initState ,我们知道 initState 的作用是初始化 props 属性、data 属性、methods 属性、computed 属性、watch 属性。

​ 而在我们 Vue 响应式系统中,data 是响应式系统的核心。所以我们这一章节,先从 data 的初始化开始分析,最后回过头来分析其他属性的初始化。

​ 在分析之前我们还是以一个案例来进行讲解,如下:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>深入响应式原理-响应式对象</title>
  <script src="../../dist/vue.js"></script>
</head>
<body>
  <div id="app"></div>
    <script>
      new Vue({
        el: '#app',
        template: `<div> {{ name }} </div>`,
        data: {
          name: 'robin'
        }
      })
    </script>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# 2. initData

​ 上一章节我们分析 initState 的定义,下面我们来分析 data 的初始化,首先我们来看 data 初始化的入口,如下:

​ 源码目录:scr/core/instance/state.js

if (opts.data) {
  initData(vm)
} else {
  observe(vm._data = {}, true /* asRootData */)
}
1
2
3
4
5

​ 这段代码首先判断 data 选项是否存在,如果存在则调用 initData 初始化 data 选项,如果不存在则直接调用 observe 函数观测一个空对象:{},即 observe 函数是将 data 转换成响应式数据的核心入口,$data 属性是一个访问器属性,其代理的值就是 _data

​ 接下来我们再来看看 initData 的定义,由于这个函数的代码比较多,我们将分解来看,如下:

​ 源码目录:scr/core/instance/state.js

function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}  
  // 省略...
}
1
2
3
4
5
6
7

​ 这段代码首先定义一个变量 data ,它的值是 vm.$options.data 的引用。我们在 vue源码分析(十六) 选项合并之合并策略 (opens new window) 分析过 vm.$options.data 被合并最终的结果是一个函数,此函数的调用的返回对象才是真正的数据。

​ 接下来判断 data 的类型,如果 data 是函数类型,则通过 getData(data, vm) 获取真正的数据,如果 data 不是函数类型,则直接返回 data 或 空对象,最后将该对象赋值给 vm._data 属性,同时重写了 data 变量,此时 data 变量已经不是函数了,而是最终的数据对象。

getData 是如何获取真实数据的?我们先来看一下它的定义如下:

​ 源码目录:scr/core/instance/state.js

export function getData (data: Function, vm: Component): any {
  // #7573 disable dep collection when invoking data getters
  pushTarget()
  try {
    return data.call(vm, vm)
  } catch (e) {
    handleError(e, vm, `data()`)
    return {}
  } finally {
    popTarget()
  }
}
1
2
3
4
5
6
7
8
9
10
11
12

getData 函数接收两个参数 data 为合并后的函数,vm 为当前 Vue 实例。函数内部首先调用 pushTarget 函数,最后有调用 popTarget 函数,中间是一个 try...catch...finally 语句,其中 pushTargetpopTarget 为了防止使用 props 数据初始化 data 数据时收集冗余的依赖,在这里我们对这两个函数不多做分析,只了解一下它们的作用,知道它们是做什么的就行,等到我们分析 Vue 是如何收集依赖的时候会回头来说明。

try...catch...finally 语句中,为了捕获 data 函数中可能出现的错误。同时如果有错误发生那么则返回一个空对象作为数据对象:return {}。没有错误的情况下,调用 data 函数获取真正的数据对象并返回,即:data.call(vm, vm)

​ 我们回到 initData 函数,继续往下看,代码如下:

​ 源码目录:scr/core/instance/state.js

export function getData (data: Function, vm: Component): any {
  // 省略...
  if (!isPlainObject(data)) {
    data = {}
    process.env.NODE_ENV !== 'production' && warn(
      'data functions should return an object:\n' +
      'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
      vm
    )
  }
  // 省略...
}
1
2
3
4
5
6
7
8
9
10
11
12

​ 上面这段代码是判断 data 是否是一个纯对象,如果不是一个纯对象首先将 data 的值设置成空对象,并且在开发环境中报一个警告。

​ 继续往下看,代码如下:

​ 源码目录:scr/core/instance/state.js

export function getData (data: Function, vm: Component): any {
  // 省略...
  // proxy data on instance
  const keys = Object.keys(data)
  const props = vm.$options.props
  const methods = vm.$options.methods
  let i = keys.length
  while (i--) {
    const key = keys[i]
    if (process.env.NODE_ENV !== 'production') {
      if (methods && hasOwn(methods, key)) {
        warn(
          `Method "${key}" has already been defined as a data property.`,
          vm
        )
      }
    }
    if (props && hasOwn(props, key)) {
      process.env.NODE_ENV !== 'production' && warn(
        `The data property "${key}" is already declared as a prop. ` +
        `Use prop default value instead.`,
        vm
      )
    } else if (!isReserved(key)) { 
      proxy(vm, `_data`, key)
    }
  }
  // 省略...
}
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

​ 这段代码,首先通过 Object.keys 获取到 data 对象中的所有属性的名称,并将由 data 对象的键所组成的数组赋值给 keys 常量。接下来分别用常量 propsmethods 保存 vm.$options.propsvm.$options.methods 的引用。

​ 接下来是一个 while 循环,用来遍历data 对象的键所组成的数组即 keyswhile 循环体中是两个 if 语句块和一个 else if 语句块。

​ 首先我们来分析第一个 if 语句块,首先判断是否在开发环境,如果在开发环境,则继续判断data 数据的 keymethods 对象中定义的函数名称是否相同,如果相同 那么会打印一个警告,提示开发者:你定义在 methods 对象中的函数名称已经被作为 data 对象中某个数据字段的 key 。我们知道定义在 data 中的数据对象,还是定义在 methods 对象中的函数,都可以通过实例对象代理访问,即vm.xxx。所以当 data 数据对象中的 keymethods 对象中的 key 冲突时,就会产生覆盖掉的现象,所以为了避免覆盖 Vue 是不允许在 methods 中定义与 data 字段的 key 重名的函数的。

​ 我们再来看看第二个 if 语句块,首先判断data 数据的 keyprops 对象中定义的数据对象的key是否相同,如果相同则继续判断是否在开发环境,如果在开发环境那么会打印一个警告。同 methods 一样 props 中的数据,也可以通过实例对象代理访问,所以也会产生覆盖掉的现象。

​ 优先级的关系:props优先级 > methods优先级 > data优先级 ,即如果一个 keyprops 中有定义了那么就不能在 datamethods 中出现了;如果一个 keydata 中出现了那么就不能在 methods 中出现了。

​ 最后是执行 else if 语句块,判断定义在 data 中的 key 是否是保留键,即是否是以 $_ 开头的,如果不是将执行 proxy 函数,实现实例对象的代理访问,proxy 的作用是将 _data 的属性值代理到 Vue 实例上的相同属性值上,例如 vm.msg = vm._data.msg

​ 说明:Vue 是不会代理那些键名以 $_ 开头的字段的,因为 Vue 自身的属性和方法都是以 $_ 开头的,所以这么做是为了避免与 Vue 自身的属性和方法相冲突。

proxy 是如何实现代理的?我们先来看一下它的定义如下:

​ 源码目录:scr/core/instance/state.js

// proxy(vm, `_data`, key)
export function proxy (target: Object, sourceKey: string, key: string) {
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}
1
2
3
4
5
6
7
8
9
10

proxy 函数的原理是通过 Object.defineProperty 函数在实例对象 vm 上定义与 data 数据字段同名的访问器属性,并且这些属性代理的值是 vm._data 上对应属性的值。在我们的案例中的 data 选择有个名为 name 的属性,我们可以通过 vm.name 来访问,而实际访问的是 vm._data.name,而这里 vm._data` 才是真正的数据对象。

​ 我们回到 initData 函数,继续往下看,代码如下:

​ 源码目录:scr/core/instance/state.js

// observe data
observe(data, true /* asRootData */)
1
2

​ 最后通过调用 observe 函数将 data 数据对象转换成响应式的。

​ 至此 initData 函数我们已经分析完了,这里我们总结一下 initData 主要的作用:

  • 根据 vm.$options.data 选项获取真正想要的数据(注意:此时 vm.$options.data 是函数)
  • 校验得到的数据是否是一个纯对象
  • 检查数据对象 data 上的键是否与 props 对象上的键冲突
  • 检查 methods 对象上的键是否与 data 对象上的键冲突
  • Vue 实例对象上添加代理访问数据对象的同名属性
  • 最后调用 observe 函数开启响应式之路

# 3. observe

​ 上一小节我们分析了 initData 的整体流程,这一小节我们来分析 observe 工厂函数。

​ 源码目录:scr/core/observer/index.js

/**
 * Attempt to create an observer instance for a value,
 * returns the new observer if successfully observed,
 * or the existing observer if the value already has one.
 */
export function observe (value: any, asRootData: ?boolean): Observer | void {
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob: Observer | void
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    ob = new Observer(value)
  }
  if (asRootData && ob) {
    ob.vmCount++
  }
  return ob
}
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

​ 首先我们看一下 observe 函数的参数列表,总共有两个参数,第一个参数 value 代表要观测的数据,第二个参数 asRootData 代表要被观测的数据是否是根级数据,是一个布尔值。

​ 接下来我们在看看 observe 函数体,首先是一个 if 语句判断要观测的数据如果不是一个纯对象或者要观测的数据类型为 VNode 则直接返回。

​ 我们继续往下看,接下来是定义一个变量 ob 用来保存 Observer 实例,接下来又是一个 if...else if... 语句块。

​ 我们先来看看 if 语句块的判断条件,首先使用 hasOwn 函数检测数据对象 value 自身是否含有 __ob__ 属性,并且 __ob__ 属性应该是 Observer 的实例。如果判断条件为真则直接将数据对象自身的 __ob__ 属性的值作为 ob 的值:ob = value.__ob__。那么 __ob__ 是什么呢?其实当一个数据对象被观测之后将会在该对象上定义 __ob__ 属性,所以 if 分支的作用是用来避免重复观测一个数据对象。

​ 接下来我们在来看看 else if 语句块,同样我们先看看这个分支的判断条件:

  • shouldObserve 必须为 true

​ 我们先来看看shouldObserve 的定义,如下:

​ 源码目录:scr/core/observer/index.js

/**
 * In some cases we may want to disable observation inside a component's
 * update computation.
 */
export let shouldObserve: boolean = true

export function toggleObserving (value: boolean) {
  shouldObserve = value
}
1
2
3
4
5
6
7
8
9

shouldObserve 变量的初始值为 true,在 shouldObserve 变量的下面定义了 toggleObserving 函数,该函数接收一个布尔值参数,用来切换 shouldObserve 变量的真假值,我们可以把 shouldObserve 想象成一个开关,为 true 时说明打开了开关,此时可以对数据进行观测,为 false 时可以理解为关闭了开关,此时数据对象将不会被观测。

  • !isServerRendering() 必须为真,即isServerRendering() 函数的返回值是一个布尔值,用来判断是否是服务端渲染。也就是说只有当不是服务端渲染的时候才会观测数据
  • (Array.isArray(value) || isPlainObject(value)) 必须为真,即只有当数据对象是数组或纯对象的时候,才有必要对其进行观测。
  • Object.isExtensible(value) 必须为真,即要被观测的数据对象必须是可扩展的。一个普通的对象默认就是可扩展的,以下三个方法都可以使得一个对象变得不可扩展:Object.preventExtensions()Object.freeze() 以及 Object.seal()
  • !value._isVue 必须为真,即 Vue 实例对象拥有 _isVue 属性,所以这个条件用来避免 Vue 实例对象被观测。

​ 当一个对象满足了以上五个条件时,就会执行 else...if 语句块的代码,即创建一个 Observer 实例。

# 4. Observe

​ 上面我们讲解了 observe 工厂函数的实现,它内部是通过实例化 Observe 实现的,接下来我们先看一下 Observe 的定义,如下:

​ 源码目录:scr/core/observer/index.js

export class Observer {
  value: any;
  dep: Dep;
  vmCount: number; // number of vms that have this object as root $data

  constructor (value: any) {
    this.value = value
    this.dep = new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
      if (hasProto) {
        protoAugment(value, arrayMethods)
      } else {
        copyAugment(value, arrayMethods, arrayKeys)
      }
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }

  /**
   * Walk through all properties and convert them into
   * getter/setters. This method should only be called when
   * value type is Object.
   */
  walk (obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      defineReactive(obj, keys[i])
    }
  }

  /**
   * Observe a list of Array items.
   */
  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
34
35
36
37
38
39
40
41
42
43

​ 上面代码是我们 Observer 构造器的全部代码,我们可以看到 Observer 类的实例对象将拥有三个实例属性,分别是 valuedepvmCount 以及两个实例方法 walkobserveArrayObserver 类的构造函数接收一个参数,即数据对象。

# 4.1 constructor

​ 首先 我们来分析一下构造方法 constructor,代码如下:

​ 源码目录:scr/core/observer/index.js

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

constructor (opens new window) 是一种用于创建和初始化class创建的对象的特殊方法。在 Observerconstructor 方法的参数就是在实例化 Observer 实例时传递的参数,即数据对象本身,可以发现,实例对象的 value 属性引用了数据对象,即 this.value = value

​ 接下来是实例对象的 dep 属性的值是新创建的一个 Dep 实例。继续实例对象的 vmCount 属性被设置为 0,即 this.vmCount = 0

​ 接下来是使用 def 函数,为数据对象定义了一个 __ob__ 属性,这个属性的值就是当前 Observer 实例对象。其中 def 函数其实就是 Object.defineProperty 函数的简单封装,之所以这里使用 def 函数定义 __ob__ 属性是因为这样可以定义不可枚举的属性,这样后面遍历数据对象的时候就能够防止遍历到 __ob__ 属性。

​ 在我们当前案例中,数据如下:

data = {
  name: 'robin'
}
1
2
3

​ 经过 def 函数处理之后,data 对象应该变成如下这个样子:

data = {
  name: "robin",
  // __ob__ 是不可枚举的属性
  __ob__: {
    dep: Dep {id: 2, subs: Array(0)}, //  new Dep()
    value: { name: "robin", __ob__: Observer }, // value 属性指向 data 数据对象本身,这是一个循环引用
    vmCount: 0
	}
}
1
2
3
4
5
6
7
8
9

​ 我们回到 constructor 继续往下看,接下来是一个 if...else... 语句块,判断条件是数据是不是一个数组,如果是数组执行 if 语句块,如果是对象执行 else 语句块。

​ 我们首先来看 if 语句块,代码如下:

​ 源码目录:scr/core/observer/index.js

if (hasProto) {
  protoAugment(value, arrayMethods)
} else {
  copyAugment(value, arrayMethods, arrayKeys)
}
// 如果value是数组,对数组每一个元素执行observe方法
this.observeArray(value)
1
2
3
4
5
6
7

​ 这段代码,首先判断 hasProto 的真假,如果 hasProto 为真则调用 protoAugment,否则调用 copyAugment。其中 hasProto 是一个布尔值,它用来检测当前环境是否可以使用 __proto__ 属性,如果 hasProto 为真则当前环境支持 __proto__ 属性,否则意味着当前环境不能够使用 __proto__ 属性。

​ 如果当前环境支持使用 __proto__ 属性,那么调用 protoAugment;如果不支持,那么调用 copyAugment 函数。总之无论是 protoAugment 函数还是 copyAugment 函数,他们的目的只有一个:把数组实例与代理原型或与代理原型中定义的函数联系起来,从而拦截数组变异方法

​ 最后通过调用 this.observeArray(value) 递归观测数组元素。

​ 我们再来看看 else 语句块,当数据是纯对象的情况,执行 this.walk(value) 函数。

# 4.2 protoAugment

​ 接下来我们看看 protoAugment 定义如下:

​ 源码目录:scr/core/observer/index.js


/**
 * Augment a target Object or Array by intercepting
 * the prototype chain using __proto__
 */
function protoAugment (target, src: Object) {
  /* eslint-disable no-proto */
  target.__proto__ = src
  /* eslint-enable no-proto */
}

1
2
3
4
5
6
7
8
9
10
11

​ 这个函数的作用是将数组实例的原型指向代理原型(arrayMethods),在构造方法中调用 protoAugment 传递的参数是 valuearrayMethodsvalue 是数组数据,arrayMethods 是代理原型。下面我们看看 arrayMethods 的定义如下:

​ 源码目录:src/core/observer/array.js

/*
 * not type checking this file because flow doesn't play well with
 * dynamically accessing methods on Array prototype
 */

import { def } from '../util/index'

const arrayProto = Array.prototype
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)
    // notify change
    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
41
42
43
44
45

​ 这段代码义开始就是缓存数组原型对象,arrayMethods 对象的原型是真正的数组构造函数的原型。接着定义了 methodsToPatch 常量,methodsToPatch 常量是一个数组,包含了所有需要拦截的数组变异方法的名字。再往下是一个 forEach 循环,用来遍历 methodsToPatch 数组。该循环的主要目的就是使用 def 函数在 arrayMethods 对象上定义与数组变异方法同名的函数,从而做到拦截的目的。

​ 循环体中,首先缓存了数组原本的变异方法,然后使用 def 函数在 arrayMethods 上定义与数组变异方法同名的函数,在函数体内优先调用了缓存下来的数组变异方法。并将数组原本变异方法的返回值赋值给 result 常量,并且我们发现函数体的最后一行代码将 result 作为返回值返回。这就保证了拦截函数的功能与数组原本变异方法的功能是一致的。

​ 接下来定义了 ob 常量,它是 this.__ob__ 的引用,其中 this 其实就是数组实例本身,我们知道无论是数组还是对象,都将会被定义一个 __ob__ 属性,并且 __ob__.dep 中收集了所有该对象(或数组)的依赖(观察者)。所以上面两句代码的目的其实很简单,当调用数组变异方法时,必然修改了数组,所以这个时候需要将该数组的所有依赖(观察者)全部拿出来执行,即:ob.dep.notify()

我们继续往下看,首先定义了 inserted 变量,这个变量用来保存那些被新添加进来的数组元素。接着是一个 switch 语句,在 switch 语句中,当遇到 pushunshift 操作时,那么新增的元素实际上就是传递给这两个方法的参数,所以可以直接将 inserted 的值设置为 argsinserted = args。当遇到 splice 操作时,我们知道 splice 函数从第三个参数开始到最后一个参数都是数组的新增元素,所以直接使用 args.slice(2) 作为 inserted 的值即可。最后 inserted 变量中所保存的就是新增的数组元素,我们只需要调用 observeArray 函数对其进行观测即可。

​ 为什么要特殊处理 pushunshiftsplice 呢?原因很简单,因为新增加的元素是非响应式的,所以我们需要获取到这些新元素,并将其变为响应式数据才行,而这就是switch 代码的目的。

# 4.3 copyAugment

​ 上一小节我们分析了在当前环境支持 __proto__ 属性的情况,这一小节我们继续分析在不支持的情况下,调用 copyAugment 函数,copyAugment 定义如下:

​ 源码目录:scr/core/observer/index.js

/**
 * Augment a target Object or Array by defining
 * hidden properties.
 */
/* istanbul ignore next */
function copyAugment (target: Object, src: Object, keys: Array<string>) {
  for (let i = 0, l = keys.length; i < l; i++) {
    const key = keys[i]
    def(target, key, src[key])
  }
}
1
2
3
4
5
6
7
8
9
10
11

copyAugment 函数的参数总共有三个,前两个和 protoAugment 一样即数组数据和代理原型,第三个参数 keys 就是定义在 arrayMethods 对象上的所有函数的键,即所有要拦截的数组变异方法的名称。函数体中通过 for 循环对 keys 进行遍历,并使用 def 函数在数组实例上定义与数组变异方法同名的且不可枚举的函数,这样就实现了拦截操作。

# 4.4 observeArray

​ 我们知道在观测数据是数组的情况下,最终都是通过 observeArray 方法递归的观测那些类型为数组或对象的数组元素。

​ 下面我们看看 observeArray 的定义,如下:

​ 源码目录:scr/core/observer/index.js

/**
 * Observe a list of Array items.
 */
// 如果要观察的对象时数组, 遍历数组,然后调用observe方法将对象的属性转化为响应式属性
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

observeArray 方法的实现很简单,只需要对数组进行遍历,并对数组元素逐个应用 observe 工厂函数即可,这样就会递归观测数组元素了。

# 4.5 walk

​ 前面几个小节我们分析,当要观测的数据是数组的情况,这一小节我们将继续分析当要观测的数据是纯对象的情况。通过前面的分析我们知道观测数据是纯对象的情况下,会调用 walk 函数。

​ 下面我们看看 walk 的定义,如下:

​ 源码目录:scr/core/observer/index.js

/**
 * Walk through all properties and convert them into
 * getter/setters. This method should only be called when
 * value type is Object.
 */
walk (obj: Object) {
  const keys = Object.keys(obj)
  for (let i = 0; i < keys.length; i++) {
    defineReactive(obj, keys[i])
  }
}
1
2
3
4
5
6
7
8
9
10
11

walk 方法,首先使用 Object.keys(obj) 获取对象所有可枚举的属性,然后使用 for 循环遍历这些属性,同时为每个属性调用了 defineReactive 函数。

# 4.6 defineReactive

​ 我们继续来看看 defineReactive 函数的定义,由于 defineReactive 的代码比较多,我们也是采用分段分析的方法来讲解这个函数,首先看一下如下代码:

​ 源码目录:scr/core/observer/index.js

/**
 * Define a reactive property on an Object.
 */
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // 省略 ...
}
1
2
3
4
5
6
7
8
9
10
11
12

​ 我们先来看看 defineReactive 函数的参数列表,总共有五个参数:

  • obj: 要观测的数据对象
  • key: 属性的键名即 key
  • val: 对象属性对应的值
  • customSetter: 自定 setter
  • shallow: 是否浅观测

defineReactive 函数的核心就是 将数据对象的数据属性转换为访问器属性,即为数据对象的属性设置一对 getter/setter

​ 我们继续往下看,代码如下:

​ 源码目录:scr/core/observer/index.js

/**
 * 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
  }
  // 省略 ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

​ 首先定义一个 dep 常量引用 Dep 实例对象。接着通过 Object.getOwnPropertyDescriptor 函数获取该字段可能已有的属性描述对象,并将该对象保存在 property 常量中,接着是一个 if 语句块,判断该字段是否是可配置的,如果不可配置(property.configurable === false),那么直接 return ,即不会继续执行 defineReactive 函数。这么做也是合理的,因为一个不可配置的属性是不能使用也没必要使用 Object.defineProperty 改变其属性定义的。

​ 我们继续往下看,代码如下:

​ 源码目录:scr/core/observer/index.js

/**
 * Define a reactive property on an Object.
 */
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // 省略 ...
  // 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]
  }
  // 省略 ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

​ 这段代码的前两句定义了 gettersetter 常量,分别保存了来自 property 对象的 getset 函数,我们知道 property 对象是属性的描述对象,一个对象的属性很可能已经是一个访问器属性了,所以该属性很可能已经存在 getset 方法。由于接下来会使用 Object.defineProperty 函数重新定义属性的 setter/getter,这会导致属性原有的 setget 方法被覆盖,所以要将属性原有的 setter/getter 缓存,并在重新定义的 setget 方法中调用缓存的函数,从而做到不影响属性的原有读写操作。

​ 接下来 if 分支语句,当发现调用 defineReactive 函数时传递了两个参数,同时只有在属性没有 get 函数或有 set 函数的情况下才会通过 val = obj[key] 取值。为什么要这么做呢?

​ 因为当属性原本存在 get 拦截器函数时,在初始化的时候不要触发 get 函数,只有当真正的获取该属性的值的时候,再通过调用缓存下来的属性原本的 getter 函数取值即可。所以看到这里我们能够发现,如果数据对象的某个属性原本就拥有自己的 get 函数,那么这个属性就不会被深度观测,因为当属性原本存在 getter 时,是不会触发取值动作的,即 val = obj[key] 不会执行,所以 valundefined,这就导致在后面深度观测的语句中传递给 observe 函数的参数是 undefined

​ 举个例子,如下:

const data = {
  getterProp: {
    a: 1
  }
}

new Vue({
  data,
  watch: {
    'getterProp.a': () => {
      console.log('这句话会输出')
    }
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14

​ 上面的代码中,我们定义了数据 datadata 是一个嵌套的对象,在 watch 选项中观察了属性 getterProp.a,当我们修改 getterProp.a 的值时,以上代码是能够正常输出的,这也是预期行为。再看如下代码:

const data = {}
Object.defineProperty(data, 'getterProp', {
  enumerable: true,
  configurable: true,
  get: () => {
    return {
      a: 1
    }
  }
})

const ins = new Vue({
  data,
  watch: {
    'getterProp.a': () => {
      console.log('这句话不会输出')
    }
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

​ 我们仅仅修改了定义数据对象 data 的方式,此时 data.getterProp 本身已经是一个访问器属性,且拥有 get 方法。此时当我们尝试修改 getterProp.a 的值时,在 watch 中观察 getterProp.a 的函数不会被执行。这是因为属性 getterProp 是一个拥有 get 拦截器函数的访问器属性,而当 Vue 发现该属性拥有原本的 getter 时,是不会深度观测的。

​ 那么为什么当属性拥有自己的 getter 时就不会对其深度观测了呢?有两方面的原因,第一:由于当属性存在原本的 getter 时在深度观测之前不会取值,所以在深度观测语句执行之前取不到属性值从而无法深度观测。第二:之所以在深度观测之前不取值是因为属性原本的 getter 由用户定义,用户可能在 getter 中做任何意想不到的事情,这么做是出于避免引发不可预见行为的考虑。

​ 我们知道当数据对象的某一个属性只拥有 get 拦截器函数而没有 set 拦截器函数时,此时该属性不会被深度观测。但是经过 defineReactive 函数的处理之后,该属性将被重新定义 gettersetter,此时该属性变成了既拥有 get 函数又拥有 set 函数。并且当我们尝试给该属性重新赋值时,那么新的值将会被观测。这时候矛盾就产生了:原本该属性不会被深度观测,但是重新赋值之后,新的值却被观测了

​ 这就是所谓的 定义响应式数据时行为的不一致,为了解决这个问题,采用的办法是当属性拥有原本的 setter 时,即使拥有 getter 也要获取属性值并观测之。

​ 我们继续往下看,代码如下:

​ 源码目录:scr/core/observer/index.js

/**
 * Define a reactive property on an Object.
 */
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // 省略 ...
  let childOb = !shallow && observe(val)
  // 省略 ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

​ 这段代码定义了 childOb 变量,我们知道,在 if 语句块里面,获取到了对象属性的值 val,但是 val 本身有可能也是一个对象,那么此时应该继续调用 observe(val) 函数观测该对象从而深度观测数据对象。但前提是 defineReactive 函数的最后一个参数 shallow 应该是假,即 !shallow 为真时才会继续调用 observe 函数深度观测,由于在 walk 函数中调用 defineReactive 函数时没有传递 shallow 参数,所以该参数是 undefined,那么也就是说默认就是深度观测。其实非深度观测的场景我们早就遇到过了,即 initRender 函数中在 Vue 实例对象上定义 $attrs 属性和 $listeners 属性时就是非深度观测,,如下:

defineReactive(vm, '$attrs', parentData && parentData.attrs || emptyObject, null, true) // 最后一个参数 shallow 为 true
defineReactive(vm, '$listeners', options._parentListeners || emptyObject, null, true)
1
2

​ 大家要注意一个问题,即使用 observe(val) 深度观测数据对象时,这里的 val 未必有值,因为必须在满足条件 (!getter || setter) && arguments.length === 2 时,才会触发取值的动作:val = obj[key],所以一旦不满足条件即使属性是有值的但是由于没有触发取值的动作,所以 val 依然是 undefined。这就会导致深度观测无效。

​ 我们继续往下看,代码如下:

​ 源码目录:scr/core/observer/index.js

/**
 * Define a reactive property on an Object.
 */
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // 省略 ...
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      // 省略 ...
    },
    set: function reactiveSetter (newVal) {
      // 省略 ...
    }
  })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

​ 当执行完以上代码实际上 defineReactive 函数就执行完毕了,对于访问器属性的 getset 函数是不会执行的,因为此时没有触发属性的读取和设置操作。

​ 但是我们还是来分析一下在 getset 函数中都做了哪些事情。首先我们先来看看 get 的实现,源码如下:

​ 源码目录:scr/core/observer/index.js

/**
 * Define a reactive property on an Object.
 */
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // 省略 ...
  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) {
      // 省略 ...
    }
  })
}
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

​ 首先判断是否存在 getter,我们知道 getter 常量中保存的是属性原有的 get 函数,如果 getter 存在那么直接调用该函数,并以该函数的返回值作为属性的值,保证属性的原有读取操作正常运作。如果 getter 不存在则使用 val 作为属性的值。可以发现 get 函数的最后一句将 value 常量返回,这样 get 函数需要做的第一件事就完成了,即正确地返回属性值。

​ 除了正确地返回属性值,还要收集依赖,而处于 get 函数第一行和最后一行代码中间的所有代码都是用来完成收集依赖这件事儿的,下面我们就看一下它是如何收集依赖的。

​ 首先判断 Dep.target 是否存在,那么 Dep.target 是什么呢? Dep.target 中保存的值就是要被收集的依赖(观察者)。所以如果 Dep.target 存在的话说明有依赖需要被收集,这个时候才需要执行 if 语句块内的代码,如果 Dep.target 不存在就意味着没有需要被收集的依赖,所以当然就不需要执行 if 语句块内的代码了。

​ 在 if 语句块内第一句执行的代码就是:dep.depend(),执行 dep 对象的 depend 方法将依赖收集到 dep 这个“筐”中,这里的 dep 对象就是属性的 getter/setter 通过闭包引用的“筐”。

​ 接着又判断了 childOb 是否存在,如果存在那么就执行 childOb.dep.depend(),这段代码是什么意思呢?要想搞清楚这段代码的作用,你需要知道 childOb 是什么,前面我们分析过,假设有如下数据对象:

const data = {
  a: {
    b: 1
  }
}
1
2
3
4
5

​ 该数据对象经过观测处理之后,将被添加 __ob__ 属性,如下:

const data = {
  a: {
    b: 1,
    __ob__: {value, dep, vmCount}
  },
  __ob__: {value, dep, vmCount}
}
1
2
3
4
5
6
7

​ 对于属性 a 来讲,访问器属性 asetter/getter 通过闭包引用了一个 Dep 实例对象,即属性 a 用来收集依赖的“筐”。除此之外访问器属性 asetter/getter 还通过闭包引用着 childOb,且 childOb === data.a.__ob__ 所以 childOb.dep === data.a.__ob__.dep。也就是说 childOb.dep.depend() 这句话的执行说明除了要将依赖收集到属性 a 自己的“筐”里之外,还要将同样的依赖收集到 data.a.__ob__.dep 这里”筐“里,为什么要将同样的依赖分别收集到这两个不同的”筐“里呢?其实答案就在于这两个”筐“里收集的依赖的触发时机是不同的,即作用不同,两个”筐“如下:

  • 第一个”筐“是 dep
  • 第二个”筐“是 childOb.dep

​ 第一个”筐“里收集的依赖的触发时机是当属性值被修改时触发,即在 set 函数中触发:dep.notify()。而第二个”筐“里收集的依赖的触发时机是在使用 $setVue.set 给数据对象添加新属性时触发,我们知道由于 js 语言的限制,在没有 Proxy 之前 Vue 没办法拦截到给对象添加属性的操作。所以 Vue 才提供了 $setVue.set 等方法让我们有能力给对象添加新属性的同时触发依赖,那么触发依赖是怎么做到的呢?就是通过数据对象的 __ob__ 属性做到的。因为 __ob__.dep 这个”筐“里收集了与 dep 这个”筐“同样的依赖。

​ 假设 Vue.set 函数代码如下:

Vue.set = function (obj, key, val) {
  defineReactive(obj, key, val)
  obj.__ob__.dep.notify()
}
1
2
3
4

​ 如上代码所示,当我们使用上面的代码给 data.a 对象添加新的属性:

Vue.set(data.a, 'c', 1)
1

​ 上面的代码之所以能够触发依赖,就是因为 Vue.set 函数中触发了收集在 data.a.__ob__.dep 这个”筐“中的依赖:

Vue.set = function (obj, key, val) {
  defineReactive(obj, key, val)
  obj.__ob__.dep.notify() // 相当于 data.a.__ob__.dep.notify()
}

Vue.set(data.a, 'c', 1)
1
2
3
4
5
6

​ 所以 __ob__ 属性以及 __ob__.dep 的主要作用是为了添加、删除属性时有能力触发依赖,而这就是 Vue.setVue.delete 的原理。

​ 在 childOb.dep.depend() 这句话的下面还有一个 if 条件语句,如果读取的属性值是数组,那么需要调用 dependArray 函数逐个触发数组每个元素的依赖收集,为什么这么做呢?那是因为 Observer 类在定义响应式属性时对于纯对象和数组的处理方式是不同。

​ 为了弄清楚这个问题,假设我们有如下代码:

<div id="demo">
  {{arr}}
</div>

const vm = new Vue({
  el: '#demo',
  data: {
    arr: [
      { a: 1 }
    ]
  }
})
1
2
3
4
5
6
7
8
9
10
11
12

​ 首先我们观察一下数据对象:

{
  arr: [
    { a: 1 }
  ]
}
1
2
3
4
5

​ 数据对象中的 arr 属性是一个数组,并且数组的一个元素是另外一个对象。上面的对象在经过观测后将变成如下这个样子:

{
  arr: [
    { a: 1, __ob__ /* 我们将该 __ob__ 称为 ob2 */ },
    __ob__ /* 我们将该 __ob__ 称为 ob1 */
  ]
}
1
2
3
4
5
6

​ 如上代码的注释所示,为了便于区别和讲解,我们分别称这两个 __ob__ 属性为 ob1ob2,然后我们再来观察一下模板:

<div id="demo">
  {{arr}}
</div>
1
2
3

​ 在模板里使用了数据 arr,这将会触发数据对象的 arr 属性的 get 函数,我们知道 arr 属性的 get 函数通过闭包引用了两个用来收集依赖的”筐“,一个是属于 arr 属性自身的 dep 对象,另一个是 childOb.dep 对象,其中 childOb 就是 ob1。这时依赖会被收集到这两个”筐“中,但大家要注意的是 ob2.dep 这个”筐“中,是没有收集到依赖的。有的同学会说:”模板中依赖的数据是 arr,并不是 arr 数组的第一个对象元素,所以 ob2 没有收集到依赖很正常啊“,这是一个错误的想法,因为依赖了数组 arr 就等价于依赖了数组内的所有元素,数组内所有元素的改变都可以看做是数组的改变。但由于 ob2 没有收集到依赖,所以现在就导致如下代码触发不了响应:

vm.$set(ins.$data.arr[0], 'b', 2)
1

​ 我们使用 $set 函数为 arr 数组的第一对象元素添加了一个属性 b,这是触发不了响应的。为了能够使得这段代码可以触发响应,就必须让 ob2 收集到依赖,而这就是 dependArray 函数的作用。如下是 dependArray 函数的代码:

​ 源码目录:scr/core/observer/index.js

function dependArray (value: Array<any>) {
  for (let e, i = 0, l = value.length; i < l; i++) {
    e = value[i]
    e && e.__ob__ && e.__ob__.dep.depend()
    if (Array.isArray(e)) {
      dependArray(e)
    }
  }
}
1
2
3
4
5
6
7
8
9

​ 当被读取的数据对象的属性值是数组时,会调用 dependArray 函数,该函数将通过 for 循环遍历数组,并取得数组每一个元素的值,如果该元素的值拥有 __ob__ 对象和 __ob__.dep 对象,那说明该元素也是一个对象或数组,此时只需要手动执行 __ob__.dep.depend() 即可达到收集依赖的目的。同时如果发现数组的元素仍然是一个数组,那么需要递归调用 dependArray 继续收集依赖。

​ 那么为什么数组需要这样处理,而纯对象不需要呢?那是因为 数组的索引是非响应式的。现在我们已经知道了数据响应系统对纯对象和数组的处理方式是不同,对于纯对象只需要逐个将对象的属性重新定义为访问器属性,并且当属性的值同样为纯对象时进行递归定义即可,而对于数组的处理则是通过拦截数组变异方法的方式,也就是说如下代码是触发不了响应的:

const vm = new Vue({
  data: {
    arr: [1, 2]
  }
})

ins.arr[0] = 3  // 不能触发响应
1
2
3
4
5
6
7

​ 上面的代码中我们试图修改 arr 数组的第一个元素,但这么做是触发不了响应的,因为对于数组来讲,其索引并不是“访问器属性”。正是因为数组的索引不是”访问器属性“,所以当有观察者依赖数组的某一个元素时是触发不了这个元素的 get 函数的,当然也就收集不到依赖。这个时候就是 dependArray 函数发挥作用的时候了。

​ 我们回到 defineReactive 再来看看 set 的实现,如下:

​ 源码目录:scr/core/observer/index.js

/**
 * Define a reactive property on an Object.
 */
export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  // 省略 ...
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      // 省略 ...
    },
    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()
    }
  })
}
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

​ 我们知道 get 函数主要完成了两部分重要的工作,一个是返回正确的属性值,另一个是收集依赖。与 get 函数类似, set 函数也要完成两个重要的事情,第一正确地为属性设置新值,第二是能够触发相应的依赖。

​ 首先 set 函数接收一个参数 newVal,即该属性被设置的新值。接下来取得属性原有的值,然后做新旧值的对比,即新旧值全等或者新值与新值自身都不全等,同时旧值与旧值自身也不全等情况下直接 return

​ 在 js 中出现一个值与自身都不全等的情况是 ,如下:

NaN === NaN // false
1

​ 所以在这个条件语句中,首先 value !== value 成立那说明该属性的原有值就是 NaN,同时 newVal !== newVal 说明为该属性设置的新值也是 NaN,所以这个时候新旧值都是 NaN,等价于属性的值没有变化,所以自然不需要做额外的处理了,set 函数直接 return

​ 我们继续往下看,接下来又是一个 if 语句块,在开发环境中如果 customSetter 函数存在,则执行 customSetter 函数。其中 customSetter 函数是 defineReactive 函数的第四个参数。

​ 关于 customSetter 其实我们在讲解 initRender 函数的时候就讲解过 customSetter 的作用,如下是 initRender 函数中的一段代码:

defineReactive(vm, '$attrs', parentData && parentData.attrs || emptyObject, () => {
  !isUpdatingChildComponent && warn(`$attrs is readonly.`, vm)
}, true)
1
2
3

​ 上面的代码中使用 defineReactiveVue 实例对象 vm 上定义了 $attrs 属性,可以看到传递给 defineReactive 函数的第四个参数是一个箭头函数,这个函数就是 customSetter,这个箭头函数的作用是当你尝试修改 vm.$attrs 属性的值时,打印一段信息:$attrs 属性是只读的。这就是 customSetter 函数的作用,用来打印辅助信息,当然除此之外你可以将 customSetter 用在任何适合使用它的地方。

​ 我们回到 defineReactive 继续往下分析,继续判断在有 getter 函数或没有 setter 函数的情况下直接退出。

​ 继续判断 setter 是否存在,我们知道 setter 常量存储的是属性原有的 set 函数。即如果属性原来拥有自身的 set 函数,那么应该继续使用该函数来设置属性的值,从而保证属性原有的设置操作不受影响。如果属性原本就没有 set 函数,那么就设置 val 的值:val = newVal

​ 最后两句的作用是,由于属性被设置了新的值,那么假如我们为属性设置的新值是一个数组或者纯对象,那么该数组或纯对象是未被观测的,所以需要对新值进行观测,这就是第一句代码的作用,同时使用新的观测对象重写 childOb 的值。当然了,这些操作都是在 !shallow 为真的情况下,即需要深度观测的时候才会执行。最后是时候触发依赖了,我们知道 dep 是属性用来收集依赖的”筐“,现在我们需要把”筐“里的依赖都执行一下,而这就是 dep.notify() 的作用。

# 5. set & delete

​ 正如官方文档中介绍的那样,Vue 是没有能力拦截到为一个对象(或数组)添加属性(或元素)的,而 Vue.setVue.delete 就是为了解决这个问题而诞生的。同时为了方便使用, Vue 还在实例对象上定义了 $set$delete 方法,实际上 $set$delete 方法仅仅是 Vue.setVue.delete 的别名。

​ 下面我们看看 Vue.setVue.delete 定义,如下:

​ 源码目录: src/core/global-api/index.js

export function initGlobalAPI (Vue: GlobalAPI) {
   // 省略 ...
  
  Vue.set = set
  Vue.delete = del
  
   // 省略 ...
}
1
2
3
4
5
6
7
8

​ 可以发现 Vue.set 函数和 Vue.delete 函数的值是来自 src/core/observer/index.js 文件中定义的 set 函数和 del 函数。

​ 接着我们再来看看 $set$delete 函数的定义,如下:

​ 源码目录: src/core/instance/state.js

export function stateMixin (Vue: Class<Component>) {
  // 省略 ...
  
  // 添加一个数组数据或者对象数据
  Vue.prototype.$set = set
  // 删除一个数组数据或者对象数据
  Vue.prototype.$delete = del
  
  // 省略 ...
}
1
2
3
4
5
6
7
8
9
10

​ 可以看到 $set$delete 的值也是来自 src/core/observer/index.js 文件中定义的 set 函数和 del 函数。所以 Vue.set 其实就是 $set,而 Vue.delete 就是 $delete

# 5.1 . Vue.set($set)

​ 下面我们看看 set 函数的定义,如下:

​ 源码目录:scr/core/observer/index.js

export function set (target: Array<any> | Object, key: any, val: any): any {
  if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
  ) {
    warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: 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
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

set 函数接收三个参数,分别为:

  • target :将要被添加属性的对象
  • key :要添加属性的键名
  • val :要添加属性的值

set 函数的首先是判断在开发环境,如果 set 函数的第一个参数是 undefinednull 或者是原始类型值,则警告信息

​ 接着对 targetkey 这两个参数做了校验,如果 target 是一个数组,并且 key 是一个有效的数组索引,那么就会执行 if 语句块的内容。也就是说当我们尝试使用 Vue.set/$set 为数组设置某个元素值的时候就会执行 if 语句块的内容。

if语句块中将数组的长度修改为 target.lengthkey 中的较大者,否则如果当要设置的元素的索引大于数组长度时 splice 无效。 我们知道 splice 变异方法能够完成数组元素的删除、添加、替换等操作。接下来而 target.splice(key, 1, val) 就利用了替换元素的能力,将指定位置元素的值替换为新值,同时由于 splice 方法本身是能够触发响应的。

​ 再往下依然是一个 if 语句块,keytarget 对象上,或在 target 的原型链上,同时必须不能在 Object.prototype 上,那么将要被添加属性的对象必然就是纯对象了,当给一个纯对象设置属性的时候,假设该属性已经在对象上有定义了,那么只需要直接设置该属性的值即可,这将自动触发响应,因为已存在的属性是响应式的。

​ 接下来定义了 ob 常量,它是数据对象 __ob__ 属性的引用。

​ 在往下又是一个 if 语句块, 这个 if 语句块有两个条件,只要有一个条件成立,就会执行 if 语句块内的代码。我们来看第一个条件 target._isVue,我们知道 Vue 实例对象拥有 _isVue 属性,所以当第一个条件成立时,那么说明你正在使用 Vue.set/$set 函数为 Vue 实例对象添加属性,为了避免属性覆盖的情况出现,Vue.set/$set 函数不允许这么做,在非生产环境下会打印警告信息。第二个条件是:(ob && ob.vmCount),我们知道 ob 就是 target.__ob__ 那么 ob.vmCount 是什么呢?为了搞清这个问题,我们回到 observe 工厂函数,如下代码:

export function observe (value: any, asRootData: ?boolean): Observer | void {
  // 省略...
  if (asRootData && ob) {    
    ob.vmCount++  
  }  
  return ob
}
1
2
3
4
5
6
7

observe 函数接收两个参数,第二个参数指示着被观测的数据对象是否是根数据对象,什么叫根数据对象呢?那就看 asRootData 什么时候为 true 即可,我们找到 initData 函数中,如下:

function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}
  
  // 省略...

  // observe data
  observe(data, true /* asRootData */)}
1
2
3
4
5
6
7
8
9
10

​ 可以看到在调用 observe 观测 data 对象的时候 asRootData 参数为 true。而在后续的递归观测中调用 observe 的时候省略了 asRootData 参数。所以所谓的根数据对象就是 data 对象。这时候我们再来看如下代码:

export function observe (value: any, asRootData: ?boolean): Observer | void {
  // 省略...
  if (asRootData && ob) {    
    ob.vmCount++  
  }  
  return ob
}
1
2
3
4
5
6
7

​ 可以发现,根数据对象将拥有一个特质,即 target.__ob__.vmCount > 0,这样条件 (ob && ob.vmCount) 是成立的,也就是说:当使用 Vue.set/$set 函数为根数据对象添加属性时,是不被允许的

​ 那么为什么不允许在根数据对象上添加属性呢?因为这样做是永远触发不了依赖的。原因就是根数据对象的 Observer 实例收集不到依赖(观察者),如下:

const data = {
  obj: {
    a: 1
    __ob__ // ob2  
  },
  __ob__ // ob1
}
new Vue({
  data
})
1
2
3
4
5
6
7
8
9
10

​ 如上代码所示,ob1 就是属于根数据的 Observer 实例对象,如果想要在根数据上使用 Vue.set/$set 并触发响应:

Vue.set(data, 'someProperty', 'someVal')
1

​ 那么 data 字段必须是响应式数据才行,这样当 data 字段被依赖时,才能够收集依赖(观察者)到两个“筐”中(data属性自身的 dep以及data.__ob__)。这样在 Vue.set/$set 函数中才有机会触发根数据的响应。但 data 本身并不是响应的,这就是问题所在。

​ 在往看是一个 if 语句块,我们知道 target 也许原本就是非响应的,这个时候 target.__ob__ 是不存在的,所以当发现 target.__ob__ 不存在时,就简单的赋值即可。

​ 最后使用 defineReactive 函数设置属性值,这是为了保证新添加的属性是响应式的。调用了 __ob__.dep.notify() 从而触发响应。这就是添加全新属性触发响应的原理。

# 5.2 . Vue.delete($delete)

​ 下面我们看看 delete 函数的定义,如下:

​ 源码目录:scr/core/observer/index.js

export function del (target: Array<any> | Object, key: any) {
  if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
  ) {
    warn(`Cannot delete reactive property on undefined, null, or primitive value: ${(target: 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
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

​ 首先delete 函数检测 target 是否是 undefinednull 或者是原始类型值,如果是的话那么在非生产环境下会打印警告信息。

​ 接着对 targetkey 这两个参数做了校验,如果 target 是一个数组,并且 key 是一个有效的数组索引,那么就会执行 if 语句块的内容。也就是使用 Vue.delete/$delete 去删除一个数组的索引。与为数组添加元素类似,移除数组元素同样使用了数组的 splice 方法,大家知道这样是能够触发响应的。

​ 与不能使用 Vue.set/$set 函数为根数据或 Vue 实例对象添加属性一样,同样不能使用 Vue.delete/$delete 删除 Vue 实例对象或根数据的属性。不允许删除 Vue 实例对象的属性,是出于安全因素的考虑。而不允许删除根数据对象的属性,是因为这样做也是触发不了响应的,关于触发不了响应的原因,我们在讲解 Vue.set/$set 时已经分析过了。

​ 最后使用 hasOwn 函数检测 key 是否是 target 对象自身拥有的属性,如果不是那么直接返回(return)。很好理解,如果你将要删除的属性原本就不在该对象上,那么自然什么都不需要做。

​ 如果 key 存在于 target 对象上,那么代码将继续运行,此时将使用 delete 语句从 target 上删除属性 key。最后判断 ob 对象是否存在,如果不存在说明 target 对象原本就不是响应的,所以直接返回(return)即可。如果 ob 对象存在,说明 target 对象是响应的,需要触发响应才行,即执行 ob.dep.notify()