vue源码分析(二十) 数据响应系统 —— props、methods

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

# 1. 概述

​ 在前面的章节中,我们已经讲解了 initState 函数中的 initDatainitComputedinitWatch,到目前为止整个 initState 函数中我们还剩下 props 以及 method 等选项的初始化和实现没有讲解,本章节我们将继续分析剩余选项的初始化及实现。

# 2. Props初始化

​ 首先我们看一下 props 选项的初始化及实现,代码如下:

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

if (opts.props) initProps(vm, opts.props)
1

​ 通过上面代码我们可以看出,只有当 opts.props 选项存在时才会调用 initProps 函数进行初始化工作。initProps 函数与其他选项的初始化函数类似,接收两个参数分别是组件实例对象 vm 和选项 opts.props

​ 说明:关于 props 选项可以移步 这里 (opens new window) 学习。

​ 我们在 规范化props (opens new window) 已经分析过,props最终被规范化为一个对象,并且该对象每个属性的键名就是对应 prop 的名字,而且每个属性的值都是一个至少会包含一个 type 属性的对象。例如:

// 数组规范化后的值
const child = {
  props: {
    age: {
      type: null
    },
    msg: {
      type: null
    }
  }
}
// 对象规范化后的值
const child = {
  props: {
    age: {
      type: Number
    },
    msg: {
      type: String,
      default: ''
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

# 2.1 initProps

​ 接下来我们来分析 initProps 函数,如下:

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

function initProps (vm: Component, propsOptions: Object) {
  const propsData = vm.$options.propsData || {}
  const props = vm._props = {}
  // cache prop keys so that future props updates can iterate using Array
  // instead of dynamic object key enumeration.
  const keys = vm.$options._propKeys = []
  const isRoot = !vm.$parent
  
  // 省略...
}
1
2
3
4
5
6
7
8
9
10

initProps 函数,首先定义了 propsData 常量,如果 vm.$options.propsData 存在,则使用 vm.$options.propsData 的值作为 propsData 常量的值,否则 propsData 常量的值为空对象。

propsData 就是 props 数据,我们知道组件的 props 代表接收来自外界传递进来的数据,这些数据总要存在某个地方,使得我们可以在组件内使用,而 vm.$options.propsData 就是用来存储来自外界的组件数据的。

​ 接下来定义了 props 常量和 vm._props 属性,它和 vm._props 属性具有相同的引用并且初始值为空对象。

​ 结下来定义了常量 keys,同时在 vm.$options 上添加 _propKeys 属性,并且常量 keysvm.$options._propKeys 属性具有相同的引用,且初始值是一个空数组。

​ 最后定义isRoot 常量用来标识是否是根组件,因为根组件实例的 $parent 属性的值是不存在的,所以当 vm.$parent 为假时说明当前组件实例是根组件。

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

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

function initProps (vm: Component, propsOptions: Object) {
  // 省略...
  if (!isRoot) {
    toggleObserving(false)
  }
  // 省略...
  toggleObserving(true)
}
1
2
3
4
5
6
7
8

​ 接下来是一个 if 语句块, 只要当前组件实例不是根节点,那么该 if 语句块内的代码将会被执行,即调用 toggleObserving 函数并传递 false 作为参数。

​ 我们再来看看 toggleObserving 的定义,如下:

​ 源码目录:src/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

​ 通过上面代码我们知道, toggleObserving 函数是一个开关,因为它能修改 shouldObserve 变量的值。

​ 我们再回到 initProps 函数,所以这个 if 语句的作用就是关闭数据检测。在 initProps 最后又打开数据检测。下面我们分析一下这样做的具体原因,我们继续往下看,代码如下:

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

function initProps (vm: Component, propsOptions: Object) {
  // 省略...
  for (const key in propsOptions) {
    keys.push(key)
    // 获取props的值
    const value = validateProp(key, propsOptions, propsData, vm)
    /* istanbul ignore else */
    if (process.env.NODE_ENV !== 'production') {
      // 省略...
    } else {
      defineReactive(props, key, value)
    }
  }
  // 省略...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

​ 说明:关于 validateProp 我们后面单独分析。

​ 首先该 for...in 循环所遍历的对象是 propsOptions,它就是 props 选项参数,我们前面分析了它的格式,所以 for...in 循环中的 key 就是每个 prop 的名字。

for...in 循环体内首先将 prop 的名字(key)添加到 keys 数组中,我们知道常量 keysvm.$options._propKeys 属性具有相同的引用,所以这等价于将 key 添加到 vm.$options._propKeys 属性中。

​ 接着定义了 value 常量,该常量的值为 validateProp 函数的返回值。 validateProp 函数的作用:用来校验名字(key)给定的 prop 数据是否符合预期的类型,并返回相应 prop 的值(或默认值)。也就是说常量 value 中保存着 prop 的值。

​ 接着是一个 if...else 语句块,我们先来分析 else 分支,else 中使用 defineReactive 函数将 prop 定义到常量 props 上,我们知道 props 常量与 vm._props 属性具有相同的引用,所以这等价于在 vm._props 上定义了 prop 数据。

​ 通过前面章节的分析我们知道 defineReactive 的作用是将数据对象的数据属性转换为访问器属性。

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

// defineReactive
let childOb = !shallow && observe(val)

export function observe (value: any, asRootData: ?boolean): 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)
  }
  // 省略...
  return ob
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

​ 通过上面代码我们知道在使用 defineReactive 函数定义属性时,会调用 observe 函数对值继续进行观测。但由于之前使用了 toggleObserving(false) 函数关闭了开关,所以上面高亮代码中调用 observe 函数是一个无效调用。所以我们可以得出一个结论:在定义 props 数据时,不将 prop 值转换为响应式数据,这里要注意的是:由于 props 本身是通过 defineReactive 定义的,所以 props 本身是响应式的,但没有对值进行深度定义。为什么这样做呢?很简单,我们知道 props 是来自外界的数据,或者更具体一点的说,props 是来自父组件的数据,这个数据如果是一个对象(包括纯对象和数组),那么它本身可能已经是响应式的了,所以不再需要重复定义。另外在定义 props 数据之后,又调用 toggleObserving(true) 函数将开关开启,这么做的目的是不影响后续代码的功能,因为这个开关是全局的。

​ 只有当不是根组件的时候才会关闭开关,这说明如果当前组件实例是根组件的话,那么定义的 props 的值也会被定义为响应式数据。

​ 通过以上内容的讲解,我们应该知道的是 props 本质上与 data 是相同的,区别就在于二者数据来源不同,其中 data 数据定义的组件自身,我们称其为本地数据,而 props 数据来自于外界。

​ 我们再来看一下 if...else 语句块中 if 分支,代码如下:

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

function initProps (vm: Component, propsOptions: Object) {
  // 省略...
  for (const key in propsOptions) {
    // 省略...
    /* istanbul ignore else */
    if (process.env.NODE_ENV !== 'production') {
      const hyphenatedKey = hyphenate(key)
      if (isReservedAttribute(hyphenatedKey) ||
          config.isReservedAttr(hyphenatedKey)) {
        warn(
          `"${hyphenatedKey}" is a reserved attribute and cannot be used as component prop.`,
          vm
        )
      }
      defineReactive(props, key, value, () => {
        if (!isRoot && !isUpdatingChildComponent) {
          warn(
            `Avoid mutating a prop directly since the value will be ` +
            `overwritten whenever the parent component re-renders. ` +
            `Instead, use a data or computed property based on the prop's ` +
            `value. Prop being mutated: "${key}"`,
            vm
          )
        }
      })
    } else {
      // 省略...
    }
  }
  // 省略...
}
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

​ 在非生产环境下会执行 if 语句块的代码,首先使用 hyphenateprop 的名字转为连字符加小写的形式,并将转换后的值赋值给 hyphenatedKey 常量,紧接着又是一个 if 条件语句块,其条件是在判断 prop 的名字是否是保留的属性(attribute),如果是则会打印警告信息,警告你不能使用保留的属性(attribute)名作为 prop 的名字。

​ 接着使用了 defineReactive 函数定义 props 数据,可以看到与生产环境不同的是,在调用 defineReactive 函数时多传递了第四个参数,我们知道 defineReactive 函数的第四个参数是 customSetter,即自定义的 setter,这个 setter 会在你尝试修改 props 数据时触发,并打印警告信息提示你不要直接修改 props 数据。

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

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

function initProps (vm: Component, propsOptions: Object) {
  // 省略...
  for (const key in propsOptions) {
    // 省略...
    // static props are already proxied on the component's prototype
    // during Vue.extend(). We only need to proxy props defined at
    // instantiation here.
    if (!(key in vm)) {
      proxy(vm, `_props`, key)
    }
  }
  // 省略...
}
1
2
3
4
5
6
7
8
9
10
11
12
13

​ 上面代码目的就是在组件实例对象上定义与 props 同名的属性,使得我们能够通过组件实例对象直接访问 props 数据,但其最终代理的值仍然是 vm._props 对象下定义的 props 数据。

​ 只有当 key 不在组件实例对象上以及其原型链上没有定义时才会进行代理,这是一个针对子组件的优化操作,对于子组件来讲这个代理工作在创建子组件构造函数时就完成了,即在 Vue.extend 函数中完成的,这么做的目的是避免每次创建子组件实例时都会调用 proxy 函数去做代理,由于 proxy 函数中使用了 Object.defineProperty 函数,该函数的性能表现不佳,所以这么做能够提升一定的性能指标。

# 2.2 validateProp

# 3. initMethods

​ 首先我们看一下 methods 选项的初始化及实现,代码如下:

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

if (opts.methods) initMethods(vm, opts.methods)
1

​ 通过上面代码我们可以看出,只有当 opts.methods 选项存在时才会调用 initMethods 函数进行初始化工作。initMethods 函数与其他选项的初始化函数类似,接收两个参数分别是组件实例对象 vm 和选项 opts.methods

​ 说明:关于 methods 选项可以移步 这里 (opens new window) 学习。

​ 接下来我们来分析 initMethods 函数,如下:

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

function initMethods (vm: Component, methods: Object) {
  const props = vm.$options.props
  for (const key in methods) {
    // 省略....
  }
}
1
2
3
4
5
6

initMethods 函数,首先定义常量 props 值为 vm.$options.props 的引用。然后是通过 for...in 循环遍历 methods 选项对象,其中 key 就是每个方法的名字。

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

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

function initMethods (vm: Component, methods: Object) {
  const props = vm.$options.props
  for (const key in methods) {
    if (process.env.NODE_ENV !== 'production') {
      if (typeof methods[key] !== 'function') { // 事件不是方法的话报错
        warn(
          `Method "${key}" has type "${typeof methods[key]}" in the component definition. ` +
          `Did you reference the function correctly?`,
          vm
        )
      }
      //如果props属性中定义了key,则在methods中不能定义同样的key
      if (props && hasOwn(props, key)) {
        warn(
          `Method "${key}" has already been defined as a prop.`,
          vm
        )
      }
      //isReserved 检查一个字符串是否以$或者_开头的字母
      if ((key in vm) && isReserved(key)) {
        warn(
          `Method "${key}" conflicts with an existing Vue instance method. ` +
          `Avoid defining component methods that start with _ or $.`
        )
      }
    }
    // 省略....
  }
}
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

for...in 循环体里面首先判断在非生产环境下,执行一些警告,主要有以下几点:

  • 检测该方法是否真正的有定义,如果没有定义则打印警告信息,提示开发者是否正确地引用了函数;
  • 检测 methods 选项中定义的方法名字是否在 props 选项中有定义,如果有的话则需要打印警告信息提示开发者:方法名已经被用于 prop;我们在前几章节的分析知道props 选项的初始化要先于 methods 选项,并且每个 prop 都需要挂载到组件实例对象下,如此一来 methods 选项中的方法名字很有可能与 props 选项中的属性名字相同,这样会导致覆盖的问题;
  • 检测方法名字 key 是否已经在组件实例对象 vm 中有了定义,并且该名字 key 为保留的属性名,如果为真则需要打印警告信息提示开发者:方法名与Vue内置方法冲突。根据 isReserved 函数可知以字符 $_ 开头的名字为保留名;

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

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

function initMethods (vm: Component, methods: Object) {
  const props = vm.$options.props
  for (const key in methods) {
    // 省略....
    vm[key] = typeof methods[key] !== 'function' ? noop : bind(methods[key], vm)
  }
}
1
2
3
4
5
6
7

​ 通过这句代码可知,之所以能够通过组件实例对象访问 methods 选项中定义的方法,就是因为在组件实例对象上定义了与 methods 选项中所定义的同名方法,当然了在定义到组件实例对象之前要检测该方法是否是函数类型:typeof methods[key] !== 'function',如果没有则添加一个空函数到组件实例对象上。