奔三路 - 你30岁前的学习路!

奔三路学习网

当前位置: 主页 > vue面试通 > 面试题 >

请详细描述Vue的数据响应系统

时间:2018-06-11 16:49来源: 作者:小邱
相信很多同学都对 Vue 的数据响应系统有或多或少的了解,本章将完整的覆盖 Vue 响应系统的边边角角,让你对其拥有一个完善的认识。

内容来自:http://hcysun.me/vue-design/

感谢大神的辛苦劳作!

相信很多同学都对 Vue 的数据响应系统有或多或少的了解,本章将完整的覆盖 Vue 响应系统的边边角角,让你对其拥有一个完善的认识。接下来我们还是接着上一章的话题,从 initState 函数开始。我们知道 initState 函数是很多选项初始化的汇总,在 initState 函数内部使用 initProps 函数初始化 props 属性;使用 initMethods 函数初始化 methods 属性;使用 initData 函数初始化 data 选项;使用 initComputed 函数和 initWatch 函数初始化 computed  watch 选项。那么我们从哪里开始讲起呢?这里我们决定以 initData 为切入点为大家讲解 Vue 的响应系统,因为 initData 几乎涉及了全部的数据响应相关的内容,这样将会让大家在理解 propscomputedwatch 等选项时不费吹灰之力,且会有一种水到渠成的感觉。

话不多说,如下是 initState 函数中用于初始化 data 选项的代码:

if (opts.data) {
  initData(vm)
} else {
  observe(vm._data = {}, true /* asRootData */)
}

首先判断 opts.data 是否存在,即 data 选项是否存在,如果存在则调用 initData(vm) 函数初始化 data 选项,否则通过 observe 函数观测一个空的对象,并且 vm._data 引用了该空对象。其中 observe 函数是将 data 转换成响应式数据的核心入口,另外实例对象上的 _data 属性我们在前面的章节中讲解 $data 属性的时候讲到过,$data 属性是一个访问器属性,其代理的值就是 _data

下面我们就从 initData(vm) 开始开启数据响应系统的探索之旅。

#实例对象代理访问数据 data

我们找到 initData 函数,该函数与 initState 函数定义在同一个文件中,即 core/instance/state.js 文件,initData 函数的一开始是这样一段代码:

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

首先定义 data 变量,它是 vm.$options.data 的引用。在 Vue选项的合并 一节中我们知道 vm.$options.data 其实最终被处理成了一个函数,且该函数的执行结果才是真正的数据。在上面的代码中我们发现其中依然存在一个使用 typeof 语句判断 data 数据类型的操作,实际上这个判断是完全没有必要的,原因是当 data 选项存在的时候,那么经过 mergeOptions 函数处理后,data 选项必然是一个函数,只有当 data 选项不存在的时候它的值是 undefined,而在 initState 函数中如果 opts.data 不存在则根本不会执行 initData 函数,所以既然执行了 initData 函数那么 vm.$options.data 必然是一个函数,所以这里的判断是没有必要的。可以直接写成:

data = vm._data = getData(data, vm)

关于这个问题,我提交了一个 PR,详情可以查看这里:https://github.com/vuejs/vue/pull/7875

回到上面那句代码,这句话调用了 getData 函数,getData 函数就定义在 initData 函数的下面,我们看看其作用是什么:

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()
  }
}

getData 函数接收两个参数:第一个参数是 data 选项,我们知道 data 选项是一个函数,第二个参数是 Vue 实例对象。getData 函数的作用其实就是通过调用 data 函数获取真正的数据对象并返回,即:data.call(vm, vm),而且我们注意到 data.call(vm, vm) 被包裹在 try...catch 语句块中,这是为了捕获 data 函数中可能出现的错误。同时如果有错误发生那么则返回一个空对象作为数据对象:return {}

另外我们注意到在 getData 函数的开头调用了 pushTarget() 函数,并且在 finally 语句块中调用了 popTarget(),这么做的目的是什么呢?这么做是为了防止使用 props 数据初始化 data 数据时收集冗余依赖的,等到我们分析 Vue 是如何收集依赖的时候会回头来说明。总之 getData 函数的作用就是:“通过调用 data 选项从而获取数据对象”

我们再回到 initData 函数中:

data = vm._data = getData(data, vm)

当通过 getData 拿到最终的数据对象后,将该对象赋值给 vm._data 属性,同时重写了 data 变量,此时 data 变量已经不是函数了,而是最终的数据对象。

紧接着是一个 if 语句块:

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
  )
}

上面的代码中使用 isPlainObject 函数判断变量 data 是不是一个纯对象,如果不是纯对象那么在非生产环境会打印警告信息。我们知道,如果一切都按照预期进行,那么此时 data 已经是一个最终的数据对象了,但这仅仅是我们的期望而已,毕竟 data 选项是开发者编写的,如下:

new Vue({
  data () {
    return '我就是不返回对象'
  }
})

上面的代码中 data 函数返回了一个字符串而不是对象,所以我们需要判断一下 data 函数返回值的类型。

再往下是这样一段代码:

// 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)
  }
}

上面的代码中首先使用 Object.keys 函数获取 data 对象的所有键,并将由 data 对象的键所组成的数组赋值给 keys 常量。接着分别用 props 常量和 methods 常量引用 vm.$options.props  vm.$options.methods。然后开启一个 while 循环,该循环的用来遍历 keys 数组,那么遍历 keys数组的目的是什么呢?我们来看循环体内的第一段 if 语句:

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
    )
  }
}

上面这段代码的意思是在非生产环境下如果发现在 methods 对象上定义了同样的 key,也就是说 data 数据的 key  methods 对象中定义的函数名称相同,那么会打印一个警告,提示开发者:你定义在 methods 对象中的函数名称已经被作为 data 对象中某个数据字段的 key 了,你应该换一个函数名字。为什么要这么做呢?如下:

const ins = new Vue({
  data: {
    a: 1
  },
  methods: {
    b () {}
  }
})

ins.a // 1
ins.b // function

在这个例子中无论是定义在 data 数据对象,还是定义在 methods 对象中的函数,都可以通过实例对象代理访问。所以当 data 数据对象中的 key  methods 对象中的 key 冲突时,岂不就会产生覆盖掉的现象,所以为了避免覆盖 Vue 是不允许在 methods 中定义与 data 字段的 key 重名的函数的。而这个工作就是在 while 循环中第一个语句块中的代码去完成的。

接着我们看 while 循环中的第二个 if 语句块:

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)
}

同样的 Vue 实例对象除了代理访问 data 数据和 methods 中的方法之外,还代理访问了 props 中的数据,所以上面这段代码的作用是如果发现 data 数据字段的 key 已经在 props 中有定义了,那么就会打印警告。另外这里有一个优先级的关系:props优先级 > data优先级 > methods优先级。即如果一个 key  props 中有定义了那么就不能在 data 中出现;如果一个 key  data 中出现了那么就不能在 methods 中出现了。

另外上面的代码中当 if 语句的条件不成立,则会判断 else if 语句中的条件:!isReserved(key),该条件的意思是判断定义在 data 中的 key 是否是保留键,大家可以在 core/util 目录下的工具方法全解 中查看对于 isReserved 函数的讲解。isReserved 函数通过判断一个字符串的第一个字符是不是 $  _ 来决定其是否是保留的,Vue 是不会代理那些键名以 $  _开头的字段的,因为 Vue 自身的属性和方法都是以 $  _ 开头的,所以这么做是为了避免与 Vue自身的属性和方法相冲突。

如果 key 既不是以 $ 开头,又不是以 _ 开头,那么将执行 proxy 函数,实现实例对象的代理访问:

proxy(vm, `_data`, key)

其中关键点在于 proxy 函数,该函数同样定义在 core/instance/state.js 文件中,其内容如下:

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)
}

proxy 函数的原理是通过 Object.defineProperty 函数在实例对象 vm 上定义与 data 数据字段同名的访问器属性,并且这些属性代理的值是 vm._data 上对应属性的值。举个例子,比如 data 数据如下:

const ins = new Vue ({
  data: {
    a: 1
  }
})

当我们访问 ins.a 时实际访问的是 ins._data.a。而 ins._data 才是真正的数据对象。

最后经过一系列的处理,initData 函数来到了最后一句代码:

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

调用 observe 函数将 data 数据对象转换成响应式的,可以说这句代码才是响应系统的开始,不过在我们讲解 observe 函数之前我们有必要总结一下 initData 函数所做的事情,通过前面分析 initData 函数主要完成如下工作:

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

#数据响应系统的基本思路

接下来我们将重点讲解数据响应系统的实现,在具体到源码之前我们有必要了解一下数据响应系统实现的基本思路,这有助于我们更好的理解源码的目的,毕竟每一行代码都有它存在的意义。

 Vue 中,我们可以使用 $watch 观测一个字段,当字段的值发生变化的时候执行指定的观察者,如下:

const ins = new Vue({
  data: {
    a: 1
  }
})

ins.$watch('a', () => {
  console.log('修改了 a')
})

这样当我们试图修改 a 的值时:ins.a = 2,在控制台将会打印 '修改了 a'。现在我们将这个问题抽象一下,假设我们有数据对象 data,如下:

const data = {
  a: 1
}

我们还有一个叫做 $watch 的函数:

function $watch () {...}

$watch 函数接收两个参数,第一个参数是要观测的字段,第二个参数是当该字段的值发生变化后要执行的函数,如下:

$watch('a', () => {
  console.log('修改了 a')
})

要实现这个功能,说复杂也复杂说简单也简单,复杂在于我们需要考虑的内容比较多,比如如何避免收集重复的依赖,如何深度观测,如何处理数组以及其他边界条件等等。简单在于如果不考虑那么多边界条件的话,要实现这样一个功能还是很容易的,这一小节我们就从简入手,致力于让大家思路清晰,至于各种复杂情况的处理我们会在真正讲解源码的部分依依为大家解答。

要实现上文的功能,我们面临的第一个问题是,如何才能知道属性被修改了(或被设置了)。这时候我们就要依赖 Object.defineProperty 函数,通过该函数为对象的每个属性设置一对 getter/setter 从而得知属性被读取和被设置,如下:

Object.defineProperty(data, 'a', {
  set () {
    console.log('设置了属性 a')
  },
  get () {
    console.log('读取了属性 a')
  }
})

这样我们就实现了对属性 a 的设置和获取操作的拦截,有了它我们就可以大胆的思考一些事情,比如: 能不能在获取属性 a 的时候收集依赖,然后在设置属性 a 的时候触发之前收集的依赖呢? 嗯,这是一个好思路,不过既然要收集依赖,我们起码需要一个”筐“,然后将所有收集到的依赖通通放到这个”筐”里,当属性被设置的时候将“筐”里所有的依赖都拿出来执行就可以了,落实到代码如下:

// dep 数组就是我们所谓的“筐”
const dep = []
Object.defineProperty(data, 'a', {
  set () {
    // 当属性被设置的时候,将“筐”里的依赖都执行一次
    dep.forEach(fn => fn())
  },
  get () {
    // 当属性被获取的时候,把依赖放到“筐”里
    dep.push(fn)
  }
})

如上代码所示,我们定义了常量 dep,它是一个数组,这个数组就是我们所说的“筐”,当获取属性 a的值时将触发 get 函数,在 get 函数中,我们将收集到的依赖放入“筐”内,当设置属性 a 的值时将触发 set 函数,在 set 函数内我们将“筐”里的依赖全部拿出来执行。

但是新的问题出现了,上面的代码中我们假设 fn 函数就是我们需要收集的依赖(观察者),但 fn 从何而来呢? 也就是说如何在获取属性 a 的值时收集依赖呢? 为了解决这个问题我们需要思考一下我们现在都掌握哪些条件,这个时候我们就需要在 $watch 函数中做文章了,我们知道 $watch 函数接收两个参数,第一个参数是一个字符串,即数据字段名,比如 'a',第二个参数是依赖该字段的函数:

$watch('a', () => {
  console.log('设置了 a')
})

重点在于 $watch 函数是知道当前正在观测的是哪一个字段的,所以一个思路是我们在 $watch 函数中读取该字段的值,从而触发字段的 get 函数,同时将依赖收集,如下代码:

const data = {
  a: 1
}

const dep = []
Object.defineProperty(data, 'a', {
  set () {
    dep.forEach(fn => fn())
  },
  get () {
    // 此时 Target 变量中保存的就是依赖函数
    dep.push(Target)
  }
})

// Target 是全局变量
let Target = null
function $watch (exp, fn) {
  // 将 Target 的值设置为 fn
  Target = fn
  // 读取字段值,触发 set 函数
  data[exp]
}

上面的代码中,首先我们定义了全局变量 Target,然后在 $watch 中将 Target 的值设置为 fn 也就是依赖,接着读取字段的值 data[exp] 从而触发被设置的属性的 get 函数,在 get 函数中,由于此时 Target 变量就是我们要收集的依赖,所以将 Target 添加到 dep 数组。现在我们添加如下测试代码:

$watch('a', () => {
  console.log('第一个依赖')
})
$watch('a', () => {
  console.log('第二个依赖')
})

此时当你尝试设置 data.a = 3 时,在控制台将分别打印字符串 '第一个依赖'  '第二个依赖'。我们仅仅用十几行代码就实现了这样一个最进本的功能,但其实现在的实现存在很多缺陷,比如目前的代码仅仅能够实现对字段 a 的观测,如果添加一个字段 b 呢?所以最起码我们应该使用一个循环将定义访问器属性的代码包裹起来,如下:

const data = {
  a: 1,
  b: 1
}

for (const key in data) {
  const dep = []
  Object.defineProperty(data, key, {
    set () {
      dep.forEach(fn => fn())
    },
    get () {
      dep.push(Target)
    }
  })
}

这样我们就可以使用 $watch 函数观测任意一个 data 对象下的字段了,但是细心的同学可能早已发现上面代码的坑,即:

console.log(data.a) // undefined

直接在控制台打印 data.a 输出的值为 undefined,这是因为 get 函数没有任何返回值,所以获取任何属性的值都将是 undefined,其实这个问题很好解决,如下:

for (let key in data) {
  const dep = []
  let val = data[key] // 缓存字段原有的值
  Object.defineProperty(data, key, {
    set (newVal) {
      // 如果值没有变什么都不做
      if (newVal === val) return
      // 使用新值替换旧值
      val = newVal
      dep.forEach(fn => fn())
    },
    get () {
      dep.push(Target)
      return val  // 将该值返回
    }
  })
}

只需要在使用 Object.defineProperty 函数定义访问器属性之前缓存一下原来的值即 val,然后在 get 函数中将 val 返回即可,除此之外还要记得在 set 函数中使用新值(newVal)重写旧值(val)。

但这样就完美了吗?当然没有,这距离完美可以说还相差十万八千里,比如当数据 data 是嵌套的对象时,我们的程序只能检测到第一层对象的属性,如果数据对象如下:

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

对于以上对象结构,我们的程序只能把 data.a 字段转换成响应式属性,而 data.a.b 依然不是响应式属性,但是这个问题还是比较容易解决的,只需要递归定义即可:

function walk (data) {
  for (let key in data) {
    const dep = []
    let val = data[key]
    // 如果 val 是对象,递归调用 walk 函数将其转为访问器属性
    const nativeString = Object.prototype.toString.call(val)
    if (nativeString === '[object Object]') {
      walk(val)
    }
    Object.defineProperty(data, key, {
      set (newVal) {
        if (newVal === val) return
        val = newVal
        dep.forEach(fn => fn())
      },
      get () {
        dep.push(Target)
        return val
      }
    })
  }
}

walk(data)

如上代码我们将定义访问器属性的逻辑放到了函数 walk 中,并增加了一段判断逻辑如果某个属性的值仍然是对象,则递归调用 walk 函数。这样我们就实现了深度定义访问器属性。

但是虽然经过上面的改造 data.a.b 已经是访问器属性了,但是如下代码依然不能正确执行:

$watch('a.b', () => {
  console.log('修改了字段 a.b')
})

来看看目前 $watch 函数的代码:

function $watch (exp, fn) {
  Target = fn
  // 读取字段值,触发 set 函数
  data[exp]
}

读取字段值的时候我们直接使用 data[exp],如果按照 $watch('a.b', fn) 这样调用 $watch 函数,那么 data[exp] 等价于 data['a.b'],这显然是不正确的,正确的读取字段值的方式应该是 data['a']['b']。所以我们需要稍微做一点小小的改造:

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

function $watch (exp, fn) {
  Target = fn
  let pathArr,
      obj = data
  // 检查 exp 中是否包含 .
  if (/\./.test(exp)) {
    // 将字符串转为数组,例:'a.b' => ['a', 'b']
    pathArr = exp.split('.')
    // 使用循环读取到 data.a.b
    pathArr.forEach(p => {
      obj = obj[p]
    })
    return
  }
  data[exp]
}

我们对 $watch 函数做了一些改造,首先检查要读取的字段是否包含 .,如果包含 . 说明读取嵌套对象的字段,这时候我们使用字符串的 split('.') 函数将字符串转为数组,所以如果访问的路径是 a.b那么转换后的数组就是 ['a', 'b'],然后使用一个循环从而读取到嵌套对象的属性值,不过需要注意的是读取到嵌套对象的属性值之后应该立即 return,不需要再执行后面的代码。

下面我们再进一步,我们思考一下 $watch 函数的原理的是什么?其实 $watch 函数所做的事情就是想方设法的访问到你要观测的字段,从而触发该字段的 get 函数,进而收集依赖(观察者)。现在我们传递给 $watch 函数的第一个参数是一个字符串,代表要访问数据的哪一个字段属性,那么除了字符串之外可不可以是一个函数呢?假设我们有一个函数叫做 render,如下

const data = {
  name: '霍春阳',
  age: 24
}

function render () {
  return document.write(`姓名:${data.name}; 年龄:${data.age}`)
}

可以看到 render 函数依赖了数据对象 data,那么 render 函数的执行是不是会触发 data.name  data.age 这两个字段的 get 拦截器呢?答案是肯定的,当然会!所以我们可以将 render 函数作为 $watch 函数的第一个参数:

$watch(render, render)

为了能够保证 $watch 函数正常执行,我们需要对 $watch 函数做如下修改:

function $watch (exp, fn) {
  Target = fn
  let pathArr,
      obj = data
  // 如果 exp 是函数,直接执行该函数
  if (typeof exp === 'function') {
    exp()
    return
  }
  if (/\./.test(exp)) {
    pathArr = exp.split('.')
    pathArr.forEach(p => {
      obj = obj[p]
    })
    return
  }
  data[exp]
}

在上面的代码中,我们检测了 exp 的类型,如果是函数则直接执行之,由于 render 函数的执行会触发数据字段的 get 拦截器,所以依赖会被收集。同时我们要注意传递给 $watch 函数的第二个参数:

$watch(render, render)

第二个参数依然是 render 函数,也就是说当依赖发生变化时,会重新执行 render 函数,这样我们就实现了数据变化,并将变化自动应用到 DOM。其实这大概就是 Vue 的原理,但我们做的还远远不够,比如上面这句代码,第一个参数中 render 函数的执行使得我们能够收集依赖,当依赖变化时会重新执行第二个参数中的 render 函数,但不要忘了这又会触发一次数据字段的 get 拦截器,所以此时已经收集了两遍重复的依赖,那么我们是不是要想办法避免收集冗余的依赖呢?除此之外我么也没有对数组做处理,我们将这些问题留到后面,看看在 Vue 中它是如何处理的。

现在我们这个不严谨的实现暂时就到这里,意图在于让大家明白数据响应系统的整体思路,为接下来真正进入 Vue 源码做必要的铺垫。

#observe 工厂函数

了解了数据响应系统的基本思路,我们是时候回过头来深入研究 Vue 的数据响应系统是如何实现的了,我们回到 initData 函数的最后一句代码:

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

调用了 observe 函数观测数据,observe 函数来自于 core/observer/index.js 文件,打开该文件找到 observe 函数:

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
}

如上是 observe 函数的全部代码, observe 函数接收两个参数,第一个参数是要观测的数据,第二个参数是一个布尔值,代表将要被观测的数据是否是根级数据。在 observe 函数的一开始是一段 if 判断语句:

if (!isObject(value) || value instanceof VNode) {
  return
}

用来判断如果要观测的数据不是一个对象或者是 VNode 实例,则直接 return 。接着定义变量 ob,该变量用来保存 Observer 实例,可以发现 observe 函数的返回值就是 ob。紧接着又是一个 if...else 分支:

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

接着我们再来看看 else...if 分支,如果数据对象上没有定义 __ob__ 属性,那么说明该对象没有被观测过,进而会判断 else...if 分支,如果 else...if 分支的条件为真,那么会执行 ob = new Observer(value) 对数据对象进行观测。也就是说只有当数据对象满足所有 else...if 分支的条件才会被观测,我们看看需要满足什么条件:

  • 第一个条件是 shouldObserve 必须为 true

shouldObserve 变量也定义在 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
}

该变量的初始值为 true,在 shouldObserve 变量的下面定义了 toggleObserving 函数,该函数接收一个布尔值参数,用来切换 shouldObserve 变量的真假值,我们可以把 shouldObserve 想象成一个开关,为 true 时说明打开了开关,此时可以对数据进行观测,为 false 时可以理解为关闭了开关,此时数据对象将不会被观测。为什么这么设计呢?原因是有一些场景下确实需要这个开关从而达到一些目的,后面我们遇到的时候再仔细来说。

  • 第二个条件是 !isServerRendering() 必须为真

isServerRendering() 函数的返回值是一个布尔值,用来判断是否是服务端渲染。也就是说只有当不是服务端渲染的时候才会观测数据,关于这一点 Vue 的服务端渲染文档中有相关介绍,我们不做过多说明。

  • 第三个条件是 (Array.isArray(value) || isPlainObject(value)) 必须为真

这个条件很好理解,只有当数据对象是数组或纯对象的时候,才有必要对其进行观测。

  • 第四个条件是 Object.isExtensible(value) 必须为真

也就是说要被观测的数据对象必须是可扩展的。一个普通的对象默认就是可扩展的,以下三个方法都可以使得一个对象变得不可扩展:Object.preventExtensions()Object.freeze() 以及 Object.seal()

  • 第五个条件是 !value._isVue 必须为真

我们知道 Vue 实例对象拥有 _isVue 属性,所以这个条件用来避免 Vue 实例对象被观测。

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

ob = new Observer(value)

#Observer 构造函数

其实真正将数据对象转换成响应式数据的是 Observer 函数,它是一个构造函数,同样定义在 core/observer/index.js 文件下,如下是简化后的代码:

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

  constructor (value: any) {
    // 省略...
  }

  walk (obj: Object) {
    // 省略...
  }
  
  observeArray (items: Array<any>) {
    // 省略...
  }
}

可以清晰的看到 Observer 类的实例对象将拥有三个实例属性,分别是 valuedep  vmCount 以及两个实例方法 walk  observeArrayObserver 类的构造函数接收一个参数,即数据对象。下面我们就从 constructor 方法开始,研究实例化一个 Observer 类时都做了哪些事情。

#数据对象的 __ob__ 属性

如下是 constructor 方法的全部代码:

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

constructor 方法的参数就是在实例化 Observer 实例时传递的参数,即数据对象本身,可以发现,实例对象的 value 属性引用了数据对象:

this.value = value

实例对象的 dep 属性,保存了一个新创建的 Dep 实例对象:

this.dep = new Dep()

那么这里的 Dep 是什么呢?就像我们在了解数据响应系统基本思路中所讲到的,它就是一个收集依赖的“筐”。但这个“筐”并不属于某一个字段,后面我们会发现,这个框是属于某一个对象或数组的。

实例对象的 vmCount 属性被设置为 0this.vmCount = 0

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

假设我们的数据对象如下:

const data = {
  a: 1
}

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

const data = {
  a: 1,
  // __ob__ 是不可枚举的属性
  __ob__: {
    value: data, // value 属性指向 data 数据对象本身,这是一个循环引用
    dep: dep实例对象, // new Dep()
    vmCount: 0
  }
}

#响应式数据之纯对象的处理

接着进入一个 if...else 判断分支:

if (Array.isArray(value)) {
  const augment = hasProto
    ? protoAugment
    : copyAugment
  augment(value, arrayMethods, arrayKeys)
  this.observeArray(value)
} else {
  this.walk(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])
  }
}

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

#defineReactive 函数

那我们就看一看 defineReactive 函数都做了什么,该函数也定义在 core/observer/index.js 文件,内容如下:

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 () {
      // 省略...
    },
    set: function reactiveSetter (newVal) {
      // 省略...
    }
  })
}

defineReactive 函数的核心就是将数据对象的数据属性转换为访问器属性,即为数据对象的属性设置一对 getter/setter,但其中做了很多处理边界条件的工作。defineReactive 接收五个参数,但是在 walk 方法中调用 defineReactive 函数时只传递了前两个参数,即数据对象和属性的键名。我们看一下 defineReactive 的函数体,首先定义了 dep 常量,它是一个 Dep 实例对象:

const dep = new Dep()

我们在讲解 Observer  constructor 方法时看到过,在 constructor 方法中为数据对象定义了一个 __ob__ 属性,该属性是一个 Observer 实例对象,且该对象包含一个 Dep 实例对象:

const data = {
  a: 1,
  __ob__: {
    value: data,
    dep: dep实例对象, // new Dep() , 包含 Dep 实例对象
    vmCount: 0
  }
}

当时我们说过 __ob__.dep 这个 Dep 实例对象的作用与我们在讲解数据响应系统基本思路一节中所说的“筐”的作用不同。至于他的作用是什么我们后面会讲到。其实与我们前面所说过的“筐”的作用相同的 Dep 实例对象是在 defineReactive 函数一开始定义的 dep 常量,即:

const dep = new Dep()

这个 dep 常量所引用的 Dep 实例对象才与我们前面讲过的“筐”的作用相同。细心的同学可能已经注意到了 dep 在访问器属性的 getter/setter 中被闭包引用,如下:

export function defineReactive (
  obj: Object,
  key: string,
  val: any,
  customSetter?: ?Function,
  shallow?: boolean
) {
  const dep = new Dep()

  // 省略...

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      const value = getter ? getter.call(obj) : val
      if (Dep.target) {
        // 这里闭包引用了上面的 dep 常量
        dep.depend()
        // 省略...
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      // 省略...

      // 这里闭包引用了上面的 dep 常量
      dep.notify()
    }
  })
}

如上面的代码中注释所写的那样,在访问器属性的 getter/setter 中,通过闭包引用了前面定义的“筐”,即 dep 常量。这里大家要明确一件事情,即每一个数据字段都通过闭包引用着属于自己的 dep 常量。因为在 walk 函数中通过循环遍历了所有数据对象的属性,并调用 defineReactive 函数,所以每次调用 defineReactive 定义访问器属性时,该属性的 setter/getter 都闭包引用了一个属于自己的“筐”。假设我们有如下数据字段:

const data = {
  a: 1,
  b: 2
}

那么字段 data.a  data.b 都将通过闭包引用属于自己的 Dep 实例对象,如下图所示:

每个字段的 Dep 对象都被用来收集那些属于对应字段的依赖。

在定义 dep 常量之后,是这样一段代码:

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

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

再往下是这样一段代码:

// 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)

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

上面这段代码中比较难理解的是 if 条件语句:

(!getter || setter) && arguments.length === 2

其中 arguments.length === 2 这个条件好理解,当只传递两个参数时,说明没有传递第三个参数 val,那么此时需要根据 key 主动去对象上获取相应的值,即执行 if 语句块内的代码:val = obj[key]。那么 (!getter || setter) 这个条件的意思是什么呢?要理解这个条件我们需要思考一些实际应用的场景,或者说边界条件,但是现在还不适合给大家讲解,我们等到讲解完整个 defineReactive 函数之后,再回头来说。

 if 语句块的下面,是这句代码:

let childOb = !shallow && observe(val)

定义了 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)

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

#被观测后的数据对象的样子

现在我们需要明确一件事情,那就是一个数据对象经过了 observe 函数处理之后变成了什么样子,假设我们有如下数据对象:

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

observe(data)

数据对象 data 拥有一个叫做 a 的属性,且属性 a 的值是另外一个对象,该对象拥有一个叫做 b的属性。那么经过 observe 处理之后, data  data.a 这两个对象都被定义了 __ob__ 属性,并且访问器属性 a  b  setter/getter 都通过闭包引用着属于自己的 Dep 实例对象和 childOb对象:

const data = {
  // 属性 a 通过 setter/getter 通过闭包引用着 dep 和 childOb
  a: {
    // 属性 b 通过 setter/getter 通过闭包引用着 dep 和 childOb
    b: 1
    __ob__: {a, dep, vmCount}
  }
  __ob__: {data, dep, vmCount}
}

如下图所示:

需要注意的是,属性 a 闭包引用的 childOb 实际上就是 data.a.__ob__。而属性 b 闭包引用的 childOb  undefined,因为属性 b 是基本类型值,并不是对象也不是数组。

#在 get 函数中如何收集依赖

我们回过头来继续查看 defineReactive 函数的代码,接下来是 defineReactive 函数的关键代码,即使用 Object.defineProperty 函数定义访问器属性:

Object.defineProperty(obj, key, {
  enumerable: true,
  configurable: true,
  get: function reactiveGetter () {
    // 省略...
  },
  set: function reactiveSetter (newVal) {
    // 省略...
})

当执行完以上代码实际上 defineReactive 函数就执行完毕了,对于访问器属性的 get  set 函数是不会执行的,因为此时没有触发属性的读取和设置操作。不过这不妨碍我们研究一下在 get  set函数中都做了哪些事情,这里面就包含了我们在前面埋下伏笔的 if 条件语句的答案。我们先从 get函数开始,看一看当属性被读取的时候都做了哪些事情,get 函数如下:

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
}

既然是 getter,那么当然要能够正确的返回属性的值才能,我们知道依赖的收集时机就是属性被读取的时候,所以 get 函数做了两件事:正确的返回属性值以及收集依赖,我们具体看一下代码,get 函数的第一句代码如下:

const value = getter ? getter.call(obj) : val

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

除了正确的返回属性值,还要收集依赖,而处于 get 函数第一行和最后一行代码中间的所有代码都是用来完成收集依赖这件事儿的,下面我们就看一下它是如何收集依赖的,由于我们还没有讲解过 Dep 这个类,所以现在大家可以简单的认为 dep.depend() 这句代码的执行就意味着依赖被收集了。接下来我们仔细看一下代码:

if (Dep.target) {
  dep.depend()
  if (childOb) {
    childOb.dep.depend()
    if (Array.isArray(value)) {
      dependArray(value)
    }
  }
}

首先判断 Dep.target 是否存在,那么 Dep.target 是什么呢?其实 Dep.target 与我们在数据响应系统基本思路一节中所讲的 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
  }
}

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

const data = {
  a: {
    b: 1,
    __ob__: {value, dep, vmCount}
  },
  __ob__: {value, dep, vmCount}
}

对于属性 a 来讲,访问器属性 a  setter/getter 通过闭包引用了一个 Dep 实例对象,即属性 a 用来收集依赖的“筐”。除此之外访问器属性 a  setter/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()。而第二个”筐“里收集的依赖的触发时机是在使用 $set  Vue.set 给数据对象添加新属性时触发,我们知道由于 js 语言的限制,在没有 Proxy 之前 Vue 没办法拦截到给对象添加属性的操作。所以 Vue 才提供了 $set  Vue.set 等方法让我们有能力给对象添加新属性的同时触发依赖,那么触发依赖是怎么做到的呢?就是通过数据对象的 __ob__ 属性做到的。因为 __ob__.dep这个”筐“里收集了与 dep 这个”筐“同样的依赖。假设 Vue.set 函数代码如下:

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

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

Vue.set(data.a, 'c', 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)

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

 childOb.dep.depend() 这句话的下面还有一个 if 条件语句,如下:

if (Array.isArray(value)) {
  dependArray(value)
}

如果读取的属性值是数组,那么需要调用 dependArray 函数逐个触发数组每个元素的依赖收集,为什么这么做呢?那是因为 Observer 类在定义响应式属性时对于纯对象和数组的处理方式是不同,对于上面这段 if 语句的目的等到我们讲解到对于数组的处理时,会详细说明。

#在 set 函数中如何触发依赖

 get 函数中收集了依赖之后,接下来我们就要看一下在 set 函数中是如何触发依赖的,即当属性被修改的时候如何触发依赖。set 函数如下:

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()
  }
  if (setter) {
    setter.call(obj, newVal)
  } else {
    val = newVal
  }
  childOb = !shallow && observe(newVal)
  dep.notify()
}

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

首先 set 函数接收一个参数 newVal,即该属性被设置的新值。在函数体内,先执行了这样一句话:

const value = getter ? getter.call(obj) : val

这句话与 get 函数体的第一句话相同,即取得属性原有的值,为什么要取得属性原来的值呢?很简单,因为我们需要拿到原有的值与新的值作比较,并且只有在原有值与新设置的值不相等的情况下才需要触发依赖和重新设置属性值,否则意味着属性值并没有改变,当然不需要做额外的处理。如下代码:

/* eslint-disable no-self-compare */
if (newVal === value || (newVal !== newVal && value !== value)) {
  return
}

这里就对比了新值和旧值:newVal === value。如果新旧值全等,那么函数直接 return,不做任何处理。但是除了对比新旧值之外,我们还注意到,另外一个条件:

(newVal !== newVal && value !== value)

如果满足该条件,同样不做任何处理,那么这个条件什么意思呢?newVal !== newVal 说明新值与新值自身都不全等,同时旧值与旧值自身也不全等,大家想一下在 js 中什么时候会出现一个值与自身都不全等的?答案就是 NaN

NaN === NaN // false

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

再往下又是一个 if 语句块:

/* eslint-enable no-self-compare */
if (process.env.NODE_ENV !== 'production' && customSetter) {
  customSetter()
}

上面这段代码的作用是,如果 customSetter 函数存在,那么在非生产环境下执行 customSetter 函数。其中 customSetter 函数是 defineReactive 函数的第四个参数。那么 customSetter 函数的作用是什么呢?其实我们在讲解 initRender 函数的时候就讲解过 customSetter 的作用,如下是 initRender 函数中的一段代码:

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

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

我们回到 set 函数,再往下是这样一段代码:

if (setter) {
  setter.call(obj, newVal)
} else {
  val = newVal
}

上面这段代码的意图很明显,即正确的设置属性值,首先判断 setter 是否存在,我们知道 setter 常量存储的是属性原有的 set 函数。即如果属性原来拥有自身的 set 函数,那么应该继续使用该函数来设置属性的值,从而保证属性原有的设置操作不受影响。如果属性原本就没有 set 函数,那么就设置 val 的值:val = newVal

接下来就是 set 函数的最后两句代码,如下:

childOb = !shallow && observe(newVal)
dep.notify()

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

至此 set 函数我们就讲解完毕了。

#保证定义响应式数据行为的一致性

本节我们主要讲解 defineReactive 函数中的一段代码,即:

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

在之前的讲解中,我们没有详细的讲解如上代码所示的这段 if 语句块。该 if 语句有两个条件:

  • 第一:(!getter || setter)
  • 第二:arguments.length === 2

并且这两个条件要同时满足才能会根据 key 去对象 obj 上取值:val = obj[key],否则就不会触发取值的动作,触发不了取值的动作就意味着 val 的值为 undefined,这会导致 if 语句块后面的那句深度观测的代码无效,即不会深度观测:

// val 是 undefined,不会深度观测
let childOb = !shallow && observe(val)

对于第二个条件,很好理解,当传递参数的数量为 2 时,说明没有传递第三个参数 val,那么当然需要通过执行 val = obj[key] 去获取属性值。比较难理解的是第一个条件,即 (!getter || setter),要理解这个问题你需要知道 Vue 代码的变更,以及为什么变更。其实在最初并没有上面这段 if 语句块,在 walk 函数中是这样调用 defineReactive 函数的:

walk (obj: Object) {
  const keys = Object.keys(obj)
  for (let i = 0; i < keys.length; i++) {
    // 这里传递了第三个参数
    defineReactive(obj, keys[i], obj[keys[i]])
  }
}

可以发现在调用 defineReactive 函数的时候传递了第三个参数,即属性值。这是最初的实现,后来变成了如下这样:

walk (obj: Object) {
  const keys = Object.keys(obj)
  for (let i = 0; i < keys.length; i++) {
    // 在 walk 函数中调用 defineReactive 函数时暂时不获取属性值
    defineReactive(obj, keys[i])
  }
}

// ================= 分割线 =================

// 在 defineReactive 函数内获取属性值
if (!getter && arguments.length === 2) {
  val = obj[key]
}

 walk 函数中调用 defineReactive 函数时去掉了第三个参数,而是在 defineReactive 函数体内增加了一段 if 分支语句,当发现调用 defineReactive 函数时传递了两个参数,同时只有在属性没有 get 函数的情况下才会通过 val = obj[key] 取值。

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

举个例子,如下:

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

new Vue({
  data,
  watch: {
    'getterProp.a': () => {
      console.log('这句话会输出')
    }
  }
})

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

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

const ins = new Vue({
  data,
  watch: {
    'getterProp.a': () => {
      console.log('这句话不会输出')
    }
  }
})

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

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

我们回过头来再看这段 if 语句块:

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

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

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

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

#响应式数据之数组的处理

以上就是响应式数据对于纯对象的处理方式,接下来我们将会对数组展开详细的讨论。回到 Observer类的 constructor 函数,找到如下代码:

if (Array.isArray(value)) {
  const augment = hasProto
    ? protoAugment
    : copyAugment
  augment(value, arrayMethods, arrayKeys)
  this.observeArray(value)
} else {
  this.walk(value)
}

 if 条件语句中,使用 Array.isArray 函数检测被观测的值 value 是否是数组,如果是数组则会执行 if 语句块内的代码,从而实现对数组的观测。处理数组的方式与纯对象不同,我们知道数组是一个特殊的数据结构,它有很多实例方法,并且有些方法会改变数组自身的值,我们称其为变异方法,这些方法有:pushpopshiftunshiftsplicesort 以及 reverse 等。这个时候我们就要考虑一件事,即当用户调用这些变异方法改变数组时需要触发依赖。换句话说我们需要知道开发者何时调用了这些变异方法,只有这样我们才有可能在这些方法被调用时做出反应。

#拦截数组变异方法的思路

那么怎么样才能知道开发者何时调用了数组的变异方法呢?其实很简单,我们来思考这样一个问题,如下代码中 sayHello 函数用来打印字符串 'hello'

function sayHello () {
  console.log('hello')
}

但是我们有这样一个需求,在不改动 sayHello 函数源码的情况下,在打印字符串 'hello' 之前先输出字符串 'Hi'。这时候我们可以这样做:

const originalSayHello = sayHello
sayHello = function () {
  console.log('Hi')
  originalSayHello()
}

看,这样就完美的实现了我们的需求,首先使用 originalSayHello 变量缓存原来的 sayHello 函数,然后重新定义 sayHello 函数,并在新定义的 sayHello 函数中调用缓存下来的 originalSayHello。这样我们就保证了在不改变 sayHello 函数行为的前提现对其进行了功能扩展。

这其实是一个很通用也很常见的技巧,而 Vue 正是通过这个技巧实现了对数据变异方法的拦截,即保持数组变异方法原有功能不变的前提下对其进行功能扩展。我们知道数组实例的变异方法是来自于数组构造函数的原型,如下图:

/uploads/allimg/180611/1649331928-2.jpg

数组本身也是一个对象,所以它实例的 __proto__ 属性指向的就是数组构造函数的原型,即 arr.__proto__ === Array.prototype 为真。我们的一个思路是通过设置 __proto__ 属性的值为一个新的对象,且该新对象的原型是数组构造函数原来的原型对象,如下图所示:

/uploads/allimg/180611/1649336125-3.jpg

我们知道数组本身也是一个对象,既然是对象那么当然可以访问其 __proto__ 属性,上图中数组实例的 __proto__ 属性指向了 arrayMethods 对象,同时 arrayMethods 对象的 __proto__ 属性指向了真正的数组原型对象。并且 arrayMethods 对象上定义了与数组变异方法同名的函数,这样当通过数组实例调用变异方法时,首先执行的是 arrayMethods 上的同名函数,这样就能够实现对数组变异方法的拦截。用代码实现上图所示内容很简单,如下:

// 要拦截的数组变异方法
const mutationMethods = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

const arrayMethods = Object.create(Array.prototype) // 实现 arrayMethods.__proto__ === Array.prototype
const arrayProto = Array.prototype  // 缓存 Array.prototype

mutationMethods.forEach(method => {
  arrayMethods[method] = function (...args) {
    const result = arrayProto[method].apply(this, args)

    console.log(`执行了代理原型的 ${method} 函数`)

    return result
  }
})

如上代码所示,我们通过 Object.create(Array.prototype) 创建了 arrayMethods 对象,这样就保证了 arrayMethods.__proto__ === Array.prototype。然后通过一个循环在 arrayMethods 对象上定义了与数组变异方法同名的函数,并在这些函数内调用了真正数组原型上的相应方法。我们可以测试一下,如下代码:

const arr = []
arr.__proto__ = arrayMethods

arr.push(1)

可以发现控制台中打印了一句话:执行了代理原型的 push 函数。很完美,但是这实际上是存在问题的,因为 __proto__ 属性是在 IE11+ 才开始支持,所以如果是低版本的 IE 怎么办?比如 IE9/10,所以出于兼容考虑,我们需要做能力检测,如果当前环境支持 __proto__ 时我们就采用上述方式来实现对数组变异方法的拦截,如果当前环境不支持 __proto__ 那我们就需要另想办法了,接下来我们就介绍一下兼容的处理方案。

实际上兼容的方案有很多,其中一个比较好的方案是直接在数组实例上定义与变异方法同名的函数,如下代码:

const arr = []
const arrayKeys = Object.getOwnPropertyNames(arrayMethods)

arrayKeys.forEach(method => {
  arr[method] = arrayMethods[method]
})

上面代码中,我们通过 Object.getOwnPropertyNames 函数获取所有属于 arrayMethods 对象自身的键,然后通过一个循环在数组实例上定义与变异方法同名的函数,这样当我们尝试调用 arr.push()时,首先执行的是定义在数组实例上的 push 函数,也就是 arrayMethods.push 函数。这样我们就实现了兼容版本的拦截。不过细心的同学可能已经注意到了,上面这种直接在数组实例上定义的属性是可枚举的,所以更好的做法是使用 Object.defineProperty

arrayKeys.forEach(method => {
  Object.defineProperty(arr, method, {
    enumerable: false,
    writable: true,
    configurable: true,
    value: arrayMethods[method]
  })
})

这样就完美了。

#拦截数组变异方法在 Vue 中的实现

我们已经了解了拦截数组变异方法的思路,接下来我们就可以具体的看一下 Vue 源码是如何实现的。在这个过程中我们会讲解数组是如何通过变异方法触发依赖(观察者)的。

我们回到 Observer 类的 constructor 函数:

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

首先大家注意一点:无论是对象还是数组,都将通过 def 函数为其定义 __ob__ 属性。接着我们来看一下 if 语句块的内容,如果被观测的值是一个数组,那么 if 语句块内的代码将被执行,即如下代码:

const augment = hasProto
  ? protoAugment
  : copyAugment
augment(value, arrayMethods, arrayKeys)
this.observeArray(value)

首先定义了 augment 常量,这个常量的值根据 hasProto 的真假而定,如果 hasProto 为真则 augment 的值为 protoAugment,否则值为 copyAugment。那么 hasProto 是什么呢?大家可以在附录 core/util 目录下的工具方法全解 中查看其讲解,其实 hasProto 是一个布尔值,它用来检测当前环境是否可以使用 __proto__ 属性,如果 hasProto 为真则当前环境支持 __proto__ 属性,否则意味着当前环境不能够使用 __proto__ 属性。

如果当前环境支持使用 __proto__ 属性,那么 augment 的值是 protoAugment,其中 protoAugment 就定义在 Observer 类的下方。源码如下:

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

那么 protoAugment 函数的作用是什么呢?相信大家已经猜到了,正如我们在讲解拦截数据变异方法的思路中所说的那样,可以通过设置数组实例的 __proto__ 属性,让其指向一个代理原型,从而做到拦截。我们看下一 protoAugment 函数是如何被调用的:

const augment = hasProto
  ? protoAugment
  : copyAugment
augment(value, arrayMethods, arrayKeys)

 hasProto 为真时,augment 引用的就是 protoAugment 函数,所以调用 augment 函数等价于调用 protoAugment 函数,可以看到传递给 protoAugment 函数的参数有三个。第一个参数是 value,其实就是数组实例本身;第二个参数是 arrayMethods,这里的 arrayMethods 与我们在拦截数据变异方法的思路中所讲解的 arrayMethods 是一样的,它就是代理原型;第三个参数是 arrayKeys,我们可以在 src/core/observer/array.js 文件中找到这样一行代码:

const arrayKeys = Object.getOwnPropertyNames(arrayMethods)

其实 arrayKeys 是一个包含了所有定义在 arrayMethods 对象上的 key,其实也就是所有我们要拦截的数组变异方法的名字:

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

但实际上 protoAugment 函数虽然接收三个参数,但它并没有使用第三个参数。可能有的同学会问为什么 protoAugment 函数没有使用第三个参数却依然声明了第三个参数呢?原因是为了让 flow 更好的工作。

我们回到 protoAugment 函数,如下:

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

该函数的函数体只有一行代码:target.__proto__ = src。这行代码用来将数组实例的原型指向代理原型(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
  })
})

如上是 src/core/observer/array.js 文件的全部代码,该文件只做了一件事情,那就是导出 arrayMethods 对象:

const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)

可以发现,arrayMethods 对象的原型是真正的数组构造函数的原型。接着定义了 methodsToPatch 常量:

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

methodsToPatch 常量是一个数组,包含了所有需要拦截的数组变异方法的名字。再往下是一个 forEach 循环,用来遍历 methodsToPatch 数组。该循环的主要目的就是使用 def 函数在 arrayMethods 对象上定义与数组变异方法同名的函数,从而做到拦截的目的,如下是简化后的代码:

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__
    
    // 省略中间部分...

    // notify change
    ob.dep.notify()
    return result
  })
})

上面的代码中,首先缓存了数组原本的变异方法:

const original = arrayProto[method]

然后使用 def 函数在 arrayMethods 上定义与数组变异方法同名的函数,在函数体内优先调用了缓存下来的数组变异方法:

const result = original.apply(this, args)

并将数组原本变异方法的返回值赋值给 result 常量,并且我们发现函数体的最后一行代码将 result作为返回值返回。这就保证了拦截函数的功能与数组原本变异方法的功能是一致的。

关键要注意这两句代码:

const ob = this.__ob__
    
// 省略中间部分...

// notify change
ob.dep.notify()

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

注意上面的讲解中我们省略了中间部分,那么这部分代码的作用是什么呢?如下:

def(arrayMethods, method, function mutator (...args) {
  // 省略...
  let inserted
  switch (method) {
    case 'push':
    case 'unshift':
      inserted = args
      break
    case 'splice':
      inserted = args.slice(2)
      break
  }
  if (inserted) ob.observeArray(inserted)
  // 省略...
})

首先我们需要思考一下数组变异方法对数组的影响是什么?无非是增加元素删除元素以及变更元素顺序。有的同学可能会说还有替换元素,实际上替换可以理解为删除和增加的复合操作。那么在这些变更中,我们需要重点关注的是增加元素的操作,即 pushunshift  splice,这三个变异方法都可以为数组添加新的元素,那么为什么要重点关注呢?原因很简单,因为新增加的元素是非响应式的,所以我们需要获取到这些新元素,并将其变为响应式数据才行,而这就是上面代码的目的。下面我们看下一具体实现,首先定义了 inserted 变量,这个变量用来保存那些被新添加进来的数组元素:let inserted。接着是一个 switch 语句,在 switch 语句中,当遇到 push  unshift 操作时,那么新增的元素实际上就是传递给这两个方法的参数,所以可以直接将 inserted 的值设置为 argsinserted = args。当遇到 splice 操作时,我们知道 splice 函数从第三个参数开始到最后一个参数都是数组的新增元素,所以直接使用 args.slice(2) 作为 inserted 的值即可。最后 inserted 变量中所保存的就是新增的数组元素,我们只需要调用 observeArray 函数对其进行观测即可:

if (inserted) ob.observeArray(inserted)

以上是在当前环境支持 __proto__ 属性的情况,如果不支持则 augment 的值为 copyAugment 函数,copyAugment 定义在 protoAugment 函数的下方:

/**
 * Augment an 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])
  }
}

copyAugment 函数接收的参数与 protoAugment 函数相同,不同的是 copyAugment 使用到了全部三个参数。在拦截数组变异方法的思路一节中我么讲解了在当前环境不支持 __proto__ 属性的时候如何做兼容处理,实际上这就是 copyAugment 函数的作用。

我们知道 copyAugment 函数的第三个参数 keys 就是定义在 arrayMethods 对象上的所有函数的键,即所有要拦截的数组变异方法的名称。这样通过 for 循环对其进行遍历,并使用 def 函数在数组实例上定义与数组变异方法同名的且不可枚举的函数,这样就实现了拦截操作。

总之无论是 protoAugment 函数还是 copyAugment 函数,他们的目的只有一个:把数组实例与代理原型或与代理原型中定义的函数联系起来,从而拦截数组变异方法。下面我们在回到 Observer 类的 constructor 函数中,看如下代码:

if (Array.isArray(value)) {
  const augment = hasProto
    ? protoAugment
    : copyAugment
  augment(value, arrayMethods, arrayKeys)
  this.observeArray(value)
} else {
  // 省略...
}

可以发现在 augment 函数调用语句之后,还以该数组实例作为参数调用了 Observer 实例对象的 observeArray 方法:

this.observeArray(value)

这句话的作用是什么呢?或者说 observeArray 方法的作用是什么呢?我们知道,当被观测的数据(value)是数组时,会执行 if 语句块的代码,并调用 augment 函数从而拦截数组的变异方法,这样当我们尝试通过这些变异方法修改数组时是会触发相应的依赖(观察者)的,比如下面的代码:

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

ins.arr.push(3) // 能够触发响应

但是如果数组中嵌套了其他的数组或对象,那么嵌套的数组或对象却不是响应的:

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

ins.arr.push(1) // 能够触发响应
ins.arr[0].push(3) // 不能触发响应

上面的代码中,直接调用 arr 数组的 push 方法是能够触发响应的,但调用 arr 数组内嵌套数组的 push 方法是不能触发响应的。为了使嵌套的数组或对象同样是响应式数据,我们需要递归的观测那些类型为数组或对象的数组元素,而这就是 observeArray 方法的作用,如下是 observeArray 方法的全部代码:

/**
  * Observe a list of Array items.
  */
observeArray (items: Array<any>) {
  for (let i = 0, l = items.length; i < l; i++) {
    observe(items[i])
  }
}

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

#数组的特殊性

本小节我们补讲 defineReactive 函数中的一段代码,如下:

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
}

 get 函数中如何收集依赖 一节中我们已经讲解了关于依赖收集的内容,但是当时我们留下了如上代码段中高亮的那三行代码没有讲,现在我们就重点看一下高亮的三句代码,为什么当被读取的属性是数组的时候需要调用 dependArray 函数?

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

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

const ins = new Vue({
  el: '#demo',
  data: {
    arr: [
      { a: 1 }
    ]
  }
})

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

{
  arr: [
    { a: 1 }
  ]
}

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

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

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

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

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

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

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

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)
    }
  }
}

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

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

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

ins.arr[0] = 3  // 不能触发响应

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

#Vue.set($set) 和 Vue.delete($delete) 的实现

现在我们时候后讲解一下 Vue.set  Vue.delete 函数的实现了,我们知道 Vue 数据响应系统的原理的核心是通过 Object.defineProperty 函数将数据对象的属性转换为访问器属性,从而使得我们能够拦截到属性的读取和设置,但正如官方文档中介绍的那样,Vue 是没有能力拦截到为一个对象(或数组)添加属性(或元素)的,而 Vue.set  Vue.delete 就是为了解决这个问题而诞生的。同时为了方便使用 Vue 还在实例对象上定义了 $set  $delete 方法,实际上 $set  $delete 方法仅仅是 Vue.set  Vue.delete 的别名,为了证明这点,我们首先来看看 $set  $delete 的实现,还记得 $set  $delete 方法定义在哪里吗?不记得也没关系,我们可以通过查看附录 Vue 构造函数整理-原型 找到 $set  $delete 方法的定义位置,我们发现 $set  $delete 定义在 src/core/instance/state.js 文件的 stateMixin 函数中,如下代码:

export function stateMixin (Vue: Class<Component>) {
  // 省略...

  Vue.prototype.$set = set  Vue.prototype.$delete = del
  Vue.prototype.$watch = function (
    expOrFn: string | Function,
    cb: any,
    options?: Object
  ): Function {
    // 省略...
  }
}

可以看到 $set  $delete 的值分别是是 set  del,根据文件头部的引用关系可知 set  del 来自 src/core/observer/index.js 文件中定义的 set 函数和 del 函数。

接着我们再来看看 Vue.set  Vue.delete 函数的定义,如果你同样不记得这两个函数时在哪里定义的也没关系,可以查看附录 Vue 构造函数整理-全局API,我们发现这两个函数是在 initGlobalAPI 函数中定义的,打开 src/core/global-api/index.js 文件,找到 initGlobalAPI 函数如下:

export function initGlobalAPI (Vue: GlobalAPI) {
  // 省略...

  Vue.set = set  Vue.delete = del  
  // 省略...
}

可以发现 Vue.set 函数和 Vue.delete 函数的值同样是来自 src/core/observer/index.js 文件中定义的 set 函数和 del 函数。现在我们可以坚信 Vue.set 其实就是 $set,而 Vue.delete 就是 $delete,所以现在我们只需要搞清楚定义在 src/core/observer/index.js 文件中的 set 函数和 del 函数是如何实现的就可以了。

#Vue.set/$set

首先我们来看一下 Vue.set/$set 函数,打开 src/core/observer/index.js 文件,找到 set 函数,它定义在 defineReactive 函数的下面,如下是 set 函数的定义:

export function set (target: Array<any> | Object, key: any, val: any): any {
  // 省略...
}

set 函数接收三个参数,相信很多同学都有使用过 Vue.set/$set 函数的经验,那么大家对这三个参数应该不陌生。第一个参数 target 是将要被添加属性的对象,第二个参数 key 以及第三个参数 val分别是要添加属性的键名和值。

下面我们一点点来看 set 函数的代码,首先是一个 if 语句块:

if (process.env.NODE_ENV !== 'production' &&
  (isUndef(target) || isPrimitive(target))
) {
  warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
}

 if 语句块的判断条件中包含两个函数,分别是 isUndef  isPrimitive,可以在附录 shared/util.js 文件工具方法全解 中找到关于这两个函数的讲解。isUndef 函数用来判断一个值是否是 undefined  null,如果是则返回 trueisPrimitive 函数用来判断一个值是否是原始类型值,如果是则返回 true。所以如上代码 if 语句块的作用是:如果 set 函数的第一个参数是 undefined  null 或者是原始类型值,那么在非生产环境下会打印警告信息。这么做是合理的,因为理论上只能为对象(或数组)添加属性(或元素)。

紧接着又是一段 if 语句块,如下:

if (Array.isArray(target) && isValidArrayIndex(key)) {  target.length = Math.max(target.length, key)
  target.splice(key, 1, val)
  return val
}

这段代码对 target  key 这两个参数做了校验,如果 target 是一个数组,并且 key 是一个有效的数组索引,那么就会执行 if 语句块的内容。在校验 key 是否是有效的数组索引时使用了 isValidArrayIndex 函数,可以在附录 shared/util.js 文件工具方法全解 中查看详细讲解。也就是说当我们尝试使用 Vue.set/$set 为数组设置某个元素值的时候就会执行 if 语句块的内容,如下例子:

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

ins.$data.arr[0] = 3 // 不能触发响应ins.$set(ins.$data.arr, 0, 3) // 能够触发响应

上面的代码中我们直接修改 arr[0] 的值不不能够触发响应的,但是如果我们使用 $set 函数重新设置 arr 数组索引为 0 的元素的值,这样是能够触发响应的,我们看看 $set 函数是如何实现的,注意如下高亮代码:

if (Array.isArray(target) && isValidArrayIndex(key)) {
  target.length = Math.max(target.length, key)  target.splice(key, 1, val)  return val}

原理其实很简单,我们知道数组的 splice 变异方法能够完成数组元素的删除、添加、替换等操作。而 target.splice(key, 1, val) 就利用了替换元素的能力,将指定位置元素的值替换为新值,同时由于 splice 方法本身是能够触发响应的,所以一切看起来如此简单。

另外大家注意在调用 target.splice 函数之前,需要修改数组的长度:

target.length = Math.max(target.length, key)

将数组的长度修改为 target.length  key 中的较大者,否则如果当要设置的元素的索引大于数组长度时 splice 无效。

再往下依然是一个 if 语句块,如下:

if (key in target && !(key in Object.prototype)) {
  target[key] = val
  return val
}

如果 target 不是一个数组,那么必然就是纯对象了,当给一个纯对象设置属性的时候,假设该属性已经在对象上有定义了,那么只需要直接设置该属性的值即可,这将自动触发响应,因为已存在的属性是响应式的。但这里要注意的是 if 语句的两个条件:

  • key in target
  • !(key in Object.prototype)

这两个条件保证了 key  target 对象上,或在 target 的原型链上,同时必须不能在 Object.prototype 上。这里我们需要提一点,上面这段代码为什么不像如下代码这样做:

if (hasOwn(target, key)) {
  target[key] = val
  return val
}

使用 hasOwn 检测 key 是不是属于 target 自身的属性不就好了?其实原本代码的确是这样写的,后来因为一个 issue 代码变成了现在这个样子,可以 点击这里查看 issue

我们继续看代码,接下来是这样一段代码,这是 set 函数剩余的全部代码,如下:

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

如果代码运行到了这里,那说明正在给对象添加一个全新的属性,注意上面代码中高亮的三句代码,第一句高亮的代码定义了 ob 常量,它是数据对象 __ob__ 属性的引用。第二句高亮的代码使用 defineReactive 函数设置属性值,这是为了保证新添加的属性是响应式的。第三局高亮的代码调用了 __ob__.dep.notify() 从而触发响应。这就是添加全新属性触发响应的原理。

再看如下代码中高亮的部分:

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

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

最后我们来看一下剩下的这段 if 语句块:

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 语句块有两个条件,只要有一个条件成立,就会执行 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
}

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

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 */)}

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

export function observe (value: any, asRootData: ?boolean): Observer | void {
  // 省略...
  if (asRootData && ob) {    ob.vmCount++  }  return ob
}

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

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

const data = {
  obj: {
    a: 1
    __ob__ // ob2  },
  __ob__ // ob1}
new Vue({
  data
})

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

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

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

#Vue.delete/$delete

接下来我们继续看一下 Vue.delete/$delete 函数的实现,仍然是 src/core/observer/index.js 文件,找到 del 函数:

export function del (target: Array<any> | Object, key: any) {