Vue.js源码学习 —— 数据选项的初始化

如果您是刚开始准备阅读Vue.js的源码,建议先看一下本系列的Vue.js源码学习 —— 起步,相信会对您后面的阅读有很大帮助。

今天这篇文章会看一下Vue中数据选项的初始化,包括propsmethodsdatacomputedwatch等选项,代码主要位于文件core/instance/state.js中。选项的初始化与响应系统有很大关系,所以建议先看一下本系列的前两篇文章,这样会对今天要看的代码有更好地理解。

我不会在文中大量地贴源代码,您一定要将Vue工程clone到本地哦。

initState

initState我们曾在Vue.prototype._init方法中看到过:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export function initMixin (Vue: Class<Component>) {
Vue.prototype._init = function (options?: Object) {
// ...
initLifecycle(vm)
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate')
initInjections(vm) // resolve injections before data/props
initState(vm)
initProvide(vm) // resolve provide after data/props
callHook(vm, 'created')

// ...
}
}

可以看到在Vue实例beforeCreatecreated两个阶段之间进行的就是各数据选项的初始化。initState的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export function initState (vm: Component) {
vm._watchers = []
const opts = vm.$options
if (opts.props) initProps(vm, opts.props)
if (opts.methods) initMethods(vm, opts.methods)
if (opts.data) {
initData(vm)
} else {
// 没有传 data 选项时,会初始化一个内部的 _data,并且也会将其转换为响应式的
observe(vm._data = {}, true /* asRootData */)
}
if (opts.computed) initComputed(vm, opts.computed)
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
}

在这里我们看到了每个数据选项的初始化顺序,这些顺序是不能随便调整的,因为之前有依赖关系,比如data中的值可能是由props中的值得来的。

下面我们就按照上面的顺序依次分析。

initProps

我们先举个例子,这样可以一边调试一边看代码有助于理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const child = {
name: 'child',
props: ['title', 'likes'],
render (h) {
return h('div', [
h('h1', this.title),
h('span', this.likes)
])
}
}

const vm = new Vue({
el: '#app',
render (h) {
return h(child, {
// 给子组件中的 props 传值
props: {
title: 'Learn Vue',
likes: 50
}
})
}
})

因为编译模板的过程还没有讲,但是vm.$createElement函数也是就是例子中的h函数前面已经详细说过了,所以到目前为止我的例子都是使用的render函数来渲染页面。其实这更有利于我们看清楚背后的操作。

Vue.js源码学习 —— 合并选项前的预处理中有讲过对props选项的规范,上面例子中的props经过合并选项后会被规范成如下形式,最终会变成一个纯对象,并且属性的值对象中一定有type字段。

1
2
3
4
5
6
7
8
props: {
title: {
type: null
},
likes: {
type: null
}
}

当然了,我们可以在定义props的时候直接就写成上面的形式。

现在我们来看一下initProps函数中具体做的事情。

1
2
3
4
5
6
7
8
9
10
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

// ...
}
  • propsData就是父组件给子组件传的值,在上面的例子中propsData此时是:

    1
    2
    3
    4
    {
    likes: 50,
    title: "Learn Vue"
    }

    关于为什么可以从组件实例的选项中取到propsData,可以看一下本系列之前的文章Vue.js源码学习 —— createComponent,里面有具体讲propsData是如何获取的,以及如何传递到组件的初始化方法中的。

  • 第2行代码给vm新增了一个属性_props_props中放的就是props选项初始化后的内容,这样就还能保持vm.$options.props是原来的样子了。

  • 接着声明了keys变量来放props中所有的字段名,同时在会将所有的字段名缓存在vm.$options._propKeys中,这样后面有用到的地方就无需再对props遍历一次了。

  • 声明变量isRoot,在父实例为空时,当前的实例就是根实例,上面例子中vm就是根实例。

接着是下面这段:

1
2
3
4
5
6
7
8
function initProps (vm: Component, propsOptions: Object) {
// root instance props should be converted
if (!isRoot) {
toggleObserving(false)
}
// ...
toggleObserving(true)
}

toggleObserving函数在讲响应系统时有看到过,它起到了一个开关作用,如果是false,在observe工厂函数中不会去创建Observer实例,也就是不会将传入的值变为响应式的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export let shouldObserve: boolean = true

export function toggleObserving(value: boolean) {
shouldObserve = value
}

export function observe(value: any, asRootData: ?boolean): Observer | void {
// ...
let ob: Observer | void
if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
ob = value.__ob__
} else if (
shouldObserve && // 此时 shouldObserve 被手动设置为 false
!isServerRendering() &&
(Array.isArray(value) || isPlainObject(value)) &&
Object.isExtensible(value) &&
!value._isVue
) {
ob = new Observer(value)
}
// ...
}

最后又将开关设置为true,使得后面的值可以继续被观察。由此看来必然是for循环中使用到了observe函数,在生产环境中for循环内会执行下面这行代码:

1
defineReactive(props, key, value)

defineReactive函数内又有这样一行:

1
2
3
4
5
6
7
8
9
10
11
export function defineReactive(
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
// ...
let childOb = !shallow && observe(val)
// ...
}

我们知道props的值也有可能是对象类型的,所以将开关设置为false的作用就是在值为对象类型时不深度去转换。因为props中的值一般都是来自于父组件的,在父组件中就已经将这个值对象转换成响应式的了,所以在子组件这里没必要再转换一次。但是当前实例如果就是根实例,还是需要进行深度转换的。经过defineReactive函数转换后,props本身还是响应式的。

此外,在开发环境下还给defineReactive函数传了customSetter这个参数,customSetter会在属性的setter被触发时调用。通过这个传参给出了警告信息:不能在子组件中直接改变props中的值。这正是Vue提倡的单向数据流的实现。

在开发环境下还会给一个告警信息,不能使用保留的属性名作为组件的属性。

再接着看下面这段:

1
2
3
if (!(key in vm)) {
proxy(vm, `_props`, key)
}

这样做是为了在组件实例上定义与props同名的属性,这样我们就可以通过组件实例直接访问props选项的属性了,但最终访问到的值来自_props

另外我们还要注意if中的判断,只有在属性即不在组件实例自身上也不在实例的原型链上时,才去调用proxy进行代理。这是因为在使用Vue.extend创建组件构造器时已经调用过proxy函数了,打开core/global-api/extend.js中的代码看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Vue.extend = function (extendOptions: Object): Function {
// ...

// For props and computed properties, we define the proxy getters on
// the Vue instances at extension time, on the extended prototype. This
// avoids Object.defineProperty calls for each instance created.
if (Sub.options.props) {
initProps(Sub)
}
if (Sub.options.computed) {
initComputed(Sub)
}

// ...
}

function initProps (Comp) {
const props = Comp.options.props
for (const key in props) {
proxy(Comp.prototype, `_props`, key)
}
}

从注释也可知道这是一种优化手段,在创建组件构造器时直接将propscomputed的属性代理到构造器的原型上,这样就避免了每次创建组件实例时都要调用Object.defineProperty,而该函数的性能表现不佳。props的属性经过代理后的结果如下:

那经过初始化后,_props中的内容现在如下:

validateProp

再来看一下下面这行代码:

1
const value = validateProp(key, propsOptions, propsData, vm)

我们知道定义props可以像上面举的例子那样只是简单的数组,也可以对象,当是对象时可以配置一些高级选项,比如现在将例子中的props用对象替代:

1
2
3
4
5
6
7
8
9
10
11
props: {
title: String,
likes: {
type: Number, // 该 prop 的类型
default: 0, // 该 prop 的默认值
required: true, // 该 prop 是否是必填项
validator: function (value) { // 该值的验证函数
return value >= 0
}
}
}

如果来自父组件的传值不满足上述条件中的其中一条,在开发环境下Vue就会在控制台给出一条警告信息。

在我们举的这个例子中给validateProp函数传的参数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// prop 的名字
key = 'likes'
// props 选项
propsOptions = {
title: String,
likes: {
type: Number, // 该 prop 的类型
default: 0, // 该 prop 的默认值
required: true, // 该 prop 是否是必填项
validator: function (value) { // 该值的验证函数
return value >= 0
}
}
}
// props 数据
propsData = {
title: 'Learn Vue',
likes: 50
}
// 组件的实例对象
vm = vm

validateProp函数的传参现在搞清楚了,接着来看它的内部实现。validateProp函数内的处理流程主要可以看成分两步,第1步先获取该prop的值,第2步来根据prop的配置选项判断值是否符合要求,如果不符合要求在开发环境下给出告警信息。

根据我们的传参,现在下面几个变量的值如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
// prop 的选项配置
prop = {
type: Number, // 该 prop 的类型
default: 0, // 该 prop 的默认值
required: true, // 该 prop 是否是必填项
validator: function (value) { // 该值的验证函数
return value >= 0
}
}
// 是否没有给该 prop 传值
absent = false
// prop 的值
value = 50

接下来的一段是判断prop的类型是否是布尔类型,如果是布尔类型的,设置value的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 因为 prop.type 可以是一个数组,所以这里返回了一个 booleanIndex
const booleanIndex = getTypeIndex(Boolean, prop.type)
// booleanIndex 的值是 -1 时表示prop的类型中没有布尔
// 大于 -1 时,表示有布尔类型
if (booleanIndex > -1) {
// 如果没有传该 prop 的值,并且 prop 的配置选项中也没有指定 default,默认值为 false
if (absent && !hasOwn(prop, 'default')) {
value = false
} else if (value === '' || value === hyphenate(key)) {
// hyphenate 函数会将 key 转换成连字符形式,比如 likesCount 会转变成 likes-count
// value 是空字符串,或者 value 与 key的连字符形式字符串相等
const stringIndex = getTypeIndex(String, prop.type)
// 如果 prop 类型中没有 String,或者数组中 Boolean 在 String 前面
// 会将 value 置为 true
if (stringIndex < 0 || booleanIndex < stringIndex) {
value = true
}
}
}

value === hyphenate(key)这种情况的处理就支持了下面这种形式的给组件传值:

1
<blog-post is-published></blog-post>

如果此时仍然没有得到这个prop的值,那就接着从default配置项尝试获取该值。

1
2
3
4
5
6
7
8
9
10
// check default value
if (value === undefined) {
value = getPropDefaultValue(vm, prop, key)
// since the default value is a fresh copy,
// make sure to observe it.
const prevShouldObserve = shouldObserve
toggleObserving(true)
observe(value)
toggleObserving(prevShouldObserve)
}

调用getPropDefaultValue函数来获取默认值,得到值之后又调用observe函数将该值转换成可响应的。我们来看一下getPropDefaultValue函数做的事情:

  • 首先判断该prop是否配置了default选项,如果没有直接返回undefined

  • 拿到default的值后判断它是否是对象类型,如果是对象类型在开发环境下会给出告警信息,Object/Array类型的默认值需要用一个工厂函数来返回。也就是下面这样给默认值是不行的:

    1
    2
    3
    items: {
    default: [1, 2, 3]
    }

    此时需要像下面这样给默认值:

    1
    2
    3
    4
    5
    items: {
    default () {
    return [1, 2, 3]
    }
    }

    这样做的目的是为了防止多个组件实例共享同一份数据造成的问题。

  • 判断在上一次的组件更新中如果该prop的值还是undefined,但是_props中有该prop的值,说明_props[key]就是上一次使用的默认值,那直接返回它就好了,避免了同一个值再次触发watcher执行。

    1
    vm.$options.propsData[key] === undefined

    你可能会疑惑为什么代码执行到这里了还需要上面一句的判断,因为前面已经知道valueundefined了。问题就在于validateProp函数不仅用在了initProps方法中,在core/instance/lifecycle.js中的updateChildComponent函数中也用到了。

    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
    export function updateChildComponent (
    vm: Component,
    propsData: ?Object,
    listeners: ?Object,
    parentVnode: MountedComponentVNode,
    renderChildren: ?Array<VNode>
    ) {
    // ...

    // update props
    if (propsData && vm.$options.props) {
    toggleObserving(false)
    const props = vm._props
    const propKeys = vm.$options._propKeys || []
    for (let i = 0; i < propKeys.length; i++) {
    const key = propKeys[i]
    const propOptions: any = vm.$options.props // wtf flow?
    // 在组件每次更新时,会更新 props,所以也会调用 validateProp 来校验值
    props[key] = validateProp(key, propOptions, propsData, vm)
    }
    toggleObserving(true)
    // keep a copy of raw propsData
    vm.$options.propsData = propsData
    }

    // ...
    }

    updateChildComponent函数主要用来更新组件的,在讲patch的过程时我们说相同类型的组件,会复用之前的组件实例,只是更新组件一些相关的属性,最终就是调用updateChildComponent函数来更新的。

  • 最后如果默认值不是一个函数,但 prop 的类型不是函数,就返回这个函数的执行结果。其他情况直接将默认值返回。

getPropDefaultValue函数说完了,回到validateProp函数中,之后该调用assertProp函数来校验得到的值是否符合要求了。调用assertProp函数主要的处理流程如下:

  • 如果prop配置了requiredtrue,就说明该属性的值必传,此时absenttrue说明没传,则开发环境下在控制台给出告警信息:

    1
    'Missing required prop: "' + name + '"'
  • 如果value的值为null,并且也没有要求必传,此时直接返回。这也说明不管指定prop指定的什么类型,值为null都是合法的。

  • 接着校验类型,依次判断值是否符合配置的类型,如果无法匹配到满足的类型,则开发环境下在控制台给出告警信息:

    1
    2
    `Invalid prop: type check failed for prop "${name}".` +
    ` Expected ${expectedTypes.map(capitalize).join(', ')}`
  • 如果prop配置了validator,会接着判断值是否能通过验证函数,将value传入validator函数,如果返回值为false,则开发环境下在控制台给出告警信息:

    1
    'Invalid prop: custom validator check failed for prop "' + name + '".'

以上就是校验props选项的全部流程了。

initMethods

我们先不看在开发环境时处理的那部分,initMethods就变得非常简单了:

1
2
3
4
5
function initMethods (vm: Component, methods: Object) {
for (const key in methods) {
vm[key] = typeof methods[key] !== 'function' ? noop : bind(methods[key], vm)
}
}

可以看到我们之所以能通过组件实例直接访问到methods选项中的方法,是因为在初始化时像组件实例中添加了与methods中同名的方法,并且这个方法中绑定了当前组件实例对象为方法内的this。为了程序的严谨性,当methods中某个属性对应的值不是函数时,则添加一个空函数到组件实例中。

接下来我们看在开发环境下做的一些校验:

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
function initMethods (vm: Component, methods: Object) {
const props = vm.$options.props
for (const key in methods) {
if (process.env.NODE_ENV !== 'production') {
// 如果 key 对应的值不是函数,给出告警提示
if (typeof methods[key] !== 'function') {
warn(
`Method "${key}" has type "${typeof methods[key]}" in the component definition. ` +
`Did you reference the function correctly?`,
vm
)
}
// 如果 methods 中方法名与 props 中的属性名相同了,给出告警提示
if (props && hasOwn(props, key)) {
warn(
`Method "${key}" has already been defined as a prop.`,
vm
)
}
// 如果 key 在当前实例中已经存在了,并且以 _ 或 $ 开头,说明开发者定义的方法名与 Vue 内部定义的方法名或属性名冲突了
// _ 或 $ 开头的属性名或方法名都作为Vue的保留关键字,在开发中要避免使用
if ((key in vm) && isReserved(key)) {
warn(
`Method "${key}" conflicts with an existing Vue instance method. ` +
`Avoid defining component methods that start with _ or $.`
)
}
}

// ...
}
}

initData

先看获取data数据的这段代码:

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

// ...
}

vm._props一样,这里也定义了vm._data来实际存储data选项中的值。在看选项合并策略的那部分代码时我们知道对于Vue实例对象来说,data选项合并后返回的是mergedInstanceDataFn函数,而对于Vue子类构造器来说,data选项合并后可能返回mergedDataFn函数,也可能直接将data返回。所以上面判断了如果data是函数就将该函数的返回结果赋值给vm._data,否则直接将data赋值给vm._data

此外,我们看到最终得到的data数据必须是一个纯对象,如果不是的话,会被重置为空对象,并且在开发环境下会给出告警信息。

拿到data后,会将data代理到Vue实例对象上,这样我们就能直接通过Vue实例访问到data数据了。使用的仍然是proxy这个函数:

1
proxy(vm, `_data`, key)

但是data中的属性不能和props中的属性和methods中的方法重名,否则在开发环境下会给出告警信息。

最后调用observe函数将data数据转换成响应式的。可以看到在这里给asRootData传了true

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

我们在讲响应系统的设计时说了这个参数的作用,感兴趣的同学可以去看一下。

initComputed

下面的代码省略了告警信息和isSSR判断的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const computedWatcherOptions = { lazy: true }

function initComputed (vm: Component, computed: Object) {

const watchers = vm._computedWatchers = Object.create(null)

for (const key in computed) {
const userDef = computed[key]
const getter = typeof userDef === 'function' ? userDef : userDef.get
// create internal watcher for the computed property.
watchers[key] = new Watcher(
vm,
getter || noop,
noop,
computedWatcherOptions
)
if (!(key in vm)) {
defineComputed(vm, key, userDef)
}
}
}

先在Vue实例上定义了_computedWatchers来放所有计算属性的观察者,然后开始for循环遍历计算属性,为每个计算属性创建一个观察者,放入_computedWatchers中。我们看到下面这样判断:

1
2
// 计算属性是一个函数,直接用这个函数,否则取计算属性中的 get方法
const getter = typeof userDef === 'function' ? userDef : userDef.get

这是因为可以用两种形式来定义计算属性,我们直接看一下Vue官方文档中给的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var vm = new Vue({
data: { a: 1 },
computed: {
// 仅读取
aDouble: function () {
return this.a * 2
},
// 读取和设置
aPlus: {
get: function () {
return this.a + 1
},
set: function (v) {
this.a = v - 1
}
}
}
})

再看给Wathcer构造器的传参:

1
2
3
4
5
6
watchers[key] = new Watcher(
vm, // 当前的 Vue 实例对象
getter || noop, // 前面得到的 getter 当做表达式,如果没有就传一个空函数
noop, // 回调函数不需要关心,传了一个空函数
computedWatcherOptions // 这里指定了 { lazy: true }
)

再接着就是在当前的Vue实例上定义与计算属性同名的属性,以便能通过Vue实例直接访问到计算属性。这里我们看到了在初始化props也出现了的一句判断:

1
2
3
if (!(key in vm)) {
// ...
}

原因也是与props时的一样,在创建组件构造器时直接构造器的原型上定义了与计算属性同名的属性,这样避免了每一次创建实例时都要再定义一遍,减少对Object.defineProperty的调用次数,达到性能优化的目的。

这里没有再使用proxy函数去处理,而调用的是专门用于计算属性的defineComputed函数。该函数中主要是确定如何给Object.defineProperty方法传getsetgetset的实现说明了计算属性的整个工作流程,下一节我们详细讨论。先把开发环境下会给出的几个告警信息看一下:

  • 如果前面获得的getter是空时,说明开发者传的计算属性有误,会给出如下告警信息:

    1
    `Getter is missing for computed property "${key}".`
  • 最后如果计算属性中的属性名和dataprops选项中有重名的,会给出如下告警信息:

    1
    2
    3
    4
    5
    if (key in vm.$data) {
    warn(`The computed property "${key}" is already defined in data.`, vm)
    } else if (vm.$options.props && key in vm.$options.props) {
    warn(`The computed property "${key}" is already defined as a prop.`, vm)
    }

计算属性的实现

在创建计算属性的Watcher实例时,传了lazytrue,在上一篇文章中讲Watcher的部分没有说lazy有什么作用,今天正好可以说一说它的用途了。如果lazytrue,在Watcher的构造器函数中如下几行会影响:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export default class Watcher {
constructor (
vm: Component,
expOrFn: string | Function,
cb: Function,
options?: ?Object,
isRenderWatcher?: boolean
) {
// ...
this.dirty = this.lazy // for lazy watchers
// ...
this.value = this.lazy
? undefined
: this.get()
}
}

dirty此时也是true,并且没有立即调用get方法来计算表达式的值。这里说明了关键的几点:

  • dirtytrue时说明可以计算观察者的值,但是还没有去计算。

  • 没有调用get方法,也导致表达式中的依赖项不会被收集到当前这个Watcher实例中,也就是在创建计算属性的观察者时还没有去收集依赖。

    我们知道在get执行期间,Dep.target会变成当前这个观察者,在这期间的依赖都会被收集到当前这个观察者中。

dirtytrue会在什么地方判断呢?观察者的值最终会什么时候计算呢?先看一下计算属性的属性描述符中set的实现,我把与set方法有关的代码抽出来如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
export function defineComputed (
target: any,
key: string,
userDef: Object | Function
) {
// ...
if (typeof userDef === 'function') {
sharedPropertyDefinition.set = noop
} else {
sharedPropertyDefinition.set = userDef.set || noop
}
// ...
}

再拿前面举的例子来说,userDef是函数的情况,对应的就是下面这种写法:

1
2
3
4
// 仅读取
aDouble: function () {
return this.a * 2
},

此时会将set赋值一个空函数。另一种对应的是下面这种写法:

1
2
3
4
5
6
7
8
9
// 读取和设置
aPlus: {
get: function () {
return this.a + 1
},
set: function (v) {
this.a = v - 1
}
}

此时如果用户在计算属性中提供了set方法,那就用这个set方法,否则使用一个空函数。

可以看到set很简单,并没有做什么特殊的处理。但是注意,set中使用了data数据,而data数据是响应式的。也就是当我们给aPlus赋值时,a的值会变化,这会触发a的观察者收到通知,一会分析完get的实现过程,我们会看到a的观察者都有哪些。

好了,现在来看get部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export function defineComputed (
target: any,
key: string,
userDef: Object | Function
) {
const shouldCache = !isServerRendering()
if (typeof userDef === 'function') {
sharedPropertyDefinition.get = shouldCache
? createComputedGetter(key)
: createGetterInvoker(userDef)
} else {
sharedPropertyDefinition.get = userDef.get
? shouldCache && userDef.cache !== false
? createComputedGetter(key)
: createGetterInvoker(userDef.get)
: noop
}
// ...
}

如果当前运行的环境是服务器端渲染,shouldCache的值为false,此时的get值为:

1
2
3
4
5
if (typeof userDef === 'function') {
sharedPropertyDefinition.get = createGetterInvoker(userDef)
} else {
sharedPropertyDefinition.get = userDef.get ? createGetterInvoker(userDef.get) : noop
}

在服务器端渲染时,计算属性比较简单就是一个单纯的getter,也没有为它创建观察者,所以在createGetterInvoker返回的函数中会直接调用传过去的函数。

咱们把重点放在非服务器端渲染时,看一下createComputedGetter函数的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function createComputedGetter (key) {
// 返回 computedGetter 函数
return function computedGetter () {
const watcher = this._computedWatchers && this._computedWatchers[key]
if (watcher) {
if (watcher.dirty) {
watcher.evaluate()
}
if (Dep.target) {
watcher.depend()
}
return watcher.value
}
}
}

当使用计算属性时,computedGetter会被触发,然后将它的观察者的值返回。在这里看到了判断dirty是否为true,前面说了dirtytrue说明观察者的值可以计算但还没计算。接着调用了evaluate方法,它的实现如下:

1
2
3
4
5
6
7
8
export default class Watcher {
// ...
evaluate () {
this.value = this.get()
this.dirty = false
}
// ...
}

先调用get计算表达式当前的值,然后将dirty置为false,表示已经计算过了。在get执行时,前面的例子中aPlus属性的get作为表达式会被调用:

1
2
3
4
5
aPlus: {
get: function () {
return this.a + 1
},
}

这会间接导致aPlus的观察者收集到a属性的依赖,这样当a属性的值改变时aPlus的观察者就会收到通知,之后会走到update方法中。在update方法中又有如下判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export default class Watcher {
// ...
update () {
/* istanbul ignore else */
if (this.lazy) {
// 计算属性的观察者 lazy 为 true
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}
// ...
}

可以看到在update方法中又将dirty置为true等待下次被计算。所以对于lazy watcher来说,当收到通知时永远都是将dirty置为true,只有在手动调用它的evaluate方法时才会去计算表达式的值。

再回到computedGetter中,如果Dep.target有值,说明现在在别的观察者(渲染函数的观察者或其他计算属性的观察者)的观察期间,于是调用了depend方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export default class Watcher {
// ...
depend () {
let i = this.deps.length
while (i--) {
this.deps[i].depend()
}
}
// ...
}

export default class Dep {
// ...
// this.deps[i].depend()
depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
// ...
}

所以watcher.depend的作用是将计算属性的观察者收集到的所有依赖,都添加到此时的观察者的依赖中。有一点绕,我们举例子来看:

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
var vm = new Vue({
el: '#app',
data: { a: 1 },
computed: {
aPlus: {
get: function () {
return this.a + 1
},
set: function (v) {
this.a = v - 1
}
}
},
render (h) {
return h('div', [
h('div', this.aPlus),
h('button', {
on: {
click: () => {
this.a = 5
}
}
}, 'Click Me')
])
}
})

当第一次渲染DOM树时,渲染函数的观察者会去执行渲染函数,导致this.aPlus被调用,这样aPlus属性的getter就会被触发,在执行aPlus属性观察者的get方法期间,当前的观察者会切换为aPlus属性观察者,此时a属性的依赖就被aPlus属性的观察者收集到。等get方法执行结束当前的观察者又会切回到渲染函数观察者。再执行aPlus属性观察者的depend方法时,就让渲染函数观察者收集到了a属性的依赖。此时a属性就又两个观察者了:aPlus属性的观察者,和渲染函数的观察者。这样当我们点击按钮改变a的值时,两个观察者都会收到通知,渲染函数观察者会去触发DOM树重新渲染,aPlus属性的观察者会将它的dirty属性置为true

可以不执行watcher.depend吗?也就是在这里不刻意让渲染函数的观察者或其他观察者收集到a属性的依赖?

答案是不行的,这有可能导致在a属性的值改变时,依赖了aPlus的地方无法收到通知。比如上面的例子,页面上没有直接使用a属性去渲染,导致a属性的依赖不会直接被渲染函数的观察者收集到。a的值改变时就不会重新触发渲染,aPlus就不会重新计算,页面上就没有反应。

我画了一张图来辅助理解:

initWatch

Vue官方文档中对watch选项的描述:

一个对象,键是需要观察的表达式,值是对应的回调函数。值也是可以是方法名,或者包含选项的对象。Vue 实例将会在实例化时调用 $watch(),遍历 watch 对象的每一个 property。

举个例子看一下:

1
2
3
4
5
6
7
8
9
10
11
const vm = new Vue({
data: {
a: 1
},
watch: {
a: function (val, oldVal) {
console.log('new: %s, old: %s', val, oldVal)
}
}
})
vm.a = 2

这里a就是表达式,a对应的函数就是回调函数。当然a后面的值不一定是一个函数,也可以是方法名,或者包含选项的对象,选项可是handlerdeepimmediatea后面也可以是这些有效值的一个数组。

接下来我们看一下initWatch中是如何处理各种情况的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function initWatch (vm: Component, watch: Object) {
// 遍历 watch 选项中的每一个 property,为其创建 watcher
for (const key in watch) {
const handler = watch[key]
// 如果值是一个数组,继续遍历这个数组,用每一项创建一个 watcher
if (Array.isArray(handler)) {
for (let i = 0; i < handler.length; i++) {
createWatcher(vm, key, handler[i])
}
} else {
createWatcher(vm, key, handler)
}
}
}

举个值是数组的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
watch: {
a: [
'handle1',
function handle2 (val, oldVal) {
console.log('new: %s, old: %s', val, oldVal)
}
]
},
methods: {
handle1: function(val, oldVal) {
console.log('new: %s, old: %s', val, oldVal)
}
}

handle1handle2都能得到执行。

再来看createWatcher函数的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function createWatcher (
vm: Component,
expOrFn: string | Function,
handler: any,
options?: Object
) {
// 值是选项对象的时候,选项可以是`handler`、`deep`、`immediate`
if (isPlainObject(handler)) {
options = handler
// 取 handler 选项
handler = handler.handler
}
// 值是方法名时,会在当前的 Vue 实例上找该方法
// initMethods在这之前已经完成了,它中的方法已经代理到了当前的 Vue实例上
if (typeof handler === 'string') {
handler = vm[handler]
}
return vm.$watch(expOrFn, handler, options)
}

createWatcher函数主要是准备好handleroptions然后传给vm.$watch,所以vm.$watch中才是真正创建Watcher实例的地方。

先看vm.$watch中的下面这部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
export function stateMixin (Vue: Class<Component>) {        
Vue.prototype.$watch = function (
expOrFn: string | Function,
cb: any,
options?: Object
): Function {
const vm: Component = this
if (isPlainObject(cb)) {
return createWatcher(vm, expOrFn, cb, options)
}
// ...
}
}

由此可见从createWatcher函数传过来的cb参数还有可能是一个对象,那就是下面这样的写法也是支持的:

1
2
3
4
5
6
7
8
9
10
11
watch: {
a: {
handler: {
// 选项对象中再套一个选项对象
handler: function(val, oldVal) {
console.log('new: %s, old: %s', val, oldVal)
}
},
deep: true
}
}

接着创建Watcher实例:

1
2
3
options = options || {}
options.user = true
const watcher = new Watcher(vm, expOrFn, cb, options)

在选项中设置usertrue标识是来自用户创建的Watcher实例,主要是为了在执行回调函数使用try/catch增加程序的健壮性。在Watcher构造器内会将新创建的Watcher实例放入vm._watchers中。

然后如果开发者在选项中配置了immediatetrue,会马上执行回调函数将观察者的值传出去。

1
2
3
4
5
6
7
if (options.immediate) {
try {
cb.call(vm, watcher.value)
} catch (error) {
handleError(error, vm, `callback for immediate watcher "${watcher.expression}"`)
}
}

在调用回调函数时绑定了当前的Vue实例为回调函数的this,所以千万不要用箭头函数的形式定义回调函数,理由是箭头函数没有自己的this,在箭头函数中的this会访问到父级作用域的this,而不会按照期望访问到当前的Vue实例。另外此时没有给oldVal传值,开发者拿到的会是undefined

最后vm.$watch返回的是unwatchFn函数,其内部会去调用watcher.teardown()来停止对表达式值的观察。

我们在开发中可以直接使用vm.$watch来观察一个表达式,举个例子:

1
2
3
4
const unwatch = vm.$watch('a', function(val, oldVal) {
console.log('new: %s, old: %s', val, oldVal)
})
unwatch()

stateMixin中其他内容

咱们顺便把stateMixin中其他内容也看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export function stateMixin (Vue: Class<Component>) {
const dataDef = {}
dataDef.get = function () { return this._data }
const propsDef = {}
propsDef.get = function () { return this._props }
if (process.env.NODE_ENV !== 'production') {
dataDef.set = function () {
warn(
'Avoid replacing instance root $data. ' +
'Use nested data properties instead.',
this
)
}
propsDef.set = function () {
warn(`$props is readonly.`, this)
}
}
Object.defineProperty(Vue.prototype, '$data', dataDef)
Object.defineProperty(Vue.prototype, '$props', propsDef)

Vue.prototype.$set = set
Vue.prototype.$delete = del
}

这里给Vue的原型上增加了$data$props属性,$set$delete方法。这使得我们可以直接通过vm.$data.xxx访问到vm._data.xxx,也就是初始化以后data对象的 property。而vm.$options.data中的内容仍然是我们一开始给Vue实例对象传入的内容。对于props选项也一样。

此外,当你试图去替换$data或者$props时在开发环境下会给出告警信息。

setdel方法的实现过程在Vue.js源码学习 —— 响应系统的设计中已经讲过了。

至此,各数据选项的初始化过程就都看完了。总的来说初始化过程中做的事主要是将除了methods以外的其他选项转为响应式的,然后让Vue实例代理各数据选项的所有属性。

下一篇文章我们将分析一下v-model的实现,以及总结一下为什么说 Vue 是一个 MVVM 框架。

本文作者:意林
本文链接:http://shinancao.cn/2020/02/08/Vue-Source-Code-12/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!