数据初始化之响应式原理
本章知识要点:
- 生命周期和事件机制的初始化过程是怎样的?
- 父组件如何快捷监听子组件的生命周期触发事件?
inject/provide
的实现与传值时的问题。props/data/methods
的初始化过程。computed
的实现原理。watch
的实现原理。- 为什么
beforeCreate
生命周期里无法获取数据?
上一章中我们已经结合Vue
源码分析了响应式系统源码的组成,并且熟悉了defineReactive/observe
方法和Watcher/Dep/Observer
类的代码实现。这一章我们主要侧重于Vue
的数据初始化过程,并且通过数据初始化的过程,看看Vue
是如何将Watcher、defineReactive
应用到项目中的。
回到Vue
初始化过程,找到core/init.js
中的_init
方法:
export function initMixin (Vue: Class<Component>) {
Vue.prototype._init = function (options?: Object) {
const vm: Component = this
// 1. 选项合并过程
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
...
// 2. 数据初始化过程
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')
...
// 3. 挂载过程
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
}
_init
方法是Vue
实例化过程的关键。其中第一步mergeOptions
方法已经在前面章节中讲过,它在这里主要的作用是将外部用户传入的options
和Vue
内部的选项配置进行合并。第二步则是数据的初始化过程,也就是本章主要分析的内容,下面我们逐一分析下其中每一个方法的含义。
initLifecycle
找到./lifecycle.js
文件中的initLifecycle
方法:
export function initLifecycle (vm: Component) {
const options = vm.$options
// 建立父子关系,在父元素中 $children 添加自身
let parent = options.parent
// keep-alive组件的 abstract 为 true
if (parent && !options.abstract) {
while (parent.$options.abstract && parent.$parent) {
parent = parent.$parent
}
parent.$children.push(vm)
}
...
}
initLifecycle
方法的第一步做的工作就是处理建立parent
和自身实例vm
之间的关系:在parent.$children
中添加vm
。这里options.abstract
的判断主要是区分是否是KeepAlive
组件,如果是KeepAlive
则继续查找上一级的parent
。
接着往下看,这里定义了一些比较常用的属性,第一类是vnode
节点相关的,如$parent,$root,$children,$refs
vm.$parent = parent
vm.$root = parent ? parent.$root : vm
vm.$children = []
vm.$refs = {}
第二类则跟生命周期比较相关,代码如下:
// 这里存的是渲染 watcher,
// 在 vm.$forceUpdate()调用的是此 watcher
vm._watcher = null
// 用于控制组件是否活跃,在 keep-alive 组件有使用
vm._inactive = null
vm._directInactive = false
// 是否挂载完毕
vm._isMounted = false
// 是否已经销毁
vm._isDestroyed = false
// 是否正在销毁
vm._isBeingDestroyed = false
这里的几个属性都比较好理解,需要注意的是_watcher
这个属性,这里存放的是渲染Watcher
,后面会提到这个属性,同时它也是$forceUpdate()
实现的关键。
initEvents
找到./events.js
文件中的initEvents
方法:
export function initEvents (vm: Component) {
// 存储所有的 events
vm._events = Object.create(null)
// 是否有名称以 hook: 开头的事件
vm._hasHookEvent = false
const listeners = vm.$options._parentListeners
if (listeners) {
updateComponentListeners(vm, listeners)
}
}
可以看出,这个方法的定义很简单。首先是定义了_events
属性,用于存储所有绑定了的事件。其次设置了_hasHookEvent
属性,这个属性用于判别监听的事件中是否有名称以hook:
的事件。而最后的updateComponentListeners
方法是当父组件在子组件上有监听事件的时候,会把相应的监听事件更新到子组件上,即存储_events
里。因此使用$emit
的时候能够触发_events
里的事件,也就是触发了父组件的事件。
那么这里的_hasHookEvent
属性到底有什么用呢?这里先挖个坑,待会在callHook
方法里再具体说明。
initRender
找到./render.js
中的initRender
方法:
export function initRender (vm: Component) {
// 虚拟 DOM
vm._vnode = null // the root of the child tree
// v-once 编译缓存的 虚拟DOM
vm._staticTrees = null // v-once cached trees
const options = vm.$options
// _parentVnode 为父组件的 vnode
const parentVnode = vm.$vnode = options._parentVnode
const renderContext = parentVnode && parentVnode.context
// _renderChildren 是父组件调用子组件,在子组件之间的内容,用于 slot
// slots = { default: [Vnode], [name]: [Vnode], ... }
vm.$slots = resolveSlots(options._renderChildren, renderContext)
vm.$scopedSlots = emptyObject
}
这里初始化了几个属性,都是和render
阶段相关,如_vnode
存储render
生成的vnode
,$slots
存储slot
的vnode
等。
接下来又定义了两个比较重要的方法:
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
两者唯一的区别就是最后一个参数不同:_c
主要用于模板编译,如template
编译后生成的代码将会使用_c
来创建节点。而$createElement
主要用于用户编写的render
函数。
最后,initRender
还将$attrs/$listeners
设置为响应式了。
defineReactive(vm, '$attrs', parentData && parentData.attrs || emptyObject, null, true)
defineReactive(vm, '$listeners', options._parentListeners || emptyObject, null, true)
callHook
callHook
方法同样是在./lifecycle.js
文件当中,代码如下
export function callHook (vm: Component, hook: string) {
// issue: https://github.com/vuejs/vue/pull/7596/files
// 每次执行生命周期的时候,不应该进行依赖收集,
// 因此这个位置传的 target 为 undefined
pushTarget()
const handlers = vm.$options[hook]
const info = `${hook} hook`
if (handlers) {
for (let i = 0, j = handlers.length; i < j; i++) {
invokeWithErrorHandling(handlers[i], vm, null, vm, info)
}
}
...
popTarget()
}
由于生命周期里使用数据时不应该搜集依赖,所以会先执行pushTarget
。上一章我们已经学习过,pushTarget
推入的就是当前的Watcher
,由于这里传入的是undefined
,那么当前Watcher
是不存在的,因此也就不会进行依赖搜集。
但是,为什么生命周期里不需要进行依赖搜集呢?举个例子:有一个属性A
和WatcherB
,他们之间没有任何关系,但是如果生命周期里使用了属性A
,此时进行依赖搜集,即属性A
与WatcherB
绑定关系,那么每次在其他地方改变属性A
都会去触发这个WatcherB
,这就会产生副作用。
看完了这段代码,再看下一段代码:
// * 如果绑定的事件中有 hook:[eventName], 那么 _hasHookEvent 为true
// * 可以通过这种方法监听子组件的生命周期事件
if (vm._hasHookEvent) {
vm.$emit('hook:' + hook)
}
这里就提到了我们前面挖下的坑:_hasHookEvent
具体有什么作用?这里用一个简单的例子说明:
// child.vue
export default {
created() {
console.log('child created')
}
}
// parent.vue
<template>
<Child @hook:created="childCreated"></Child>
</template>
export default {
components: { Child },
methods: {
childCreated() {
console.log('child crerated in parent')
}
}
}
// 输出结果
// child created
// child crerated in parent
这个例子中,子组件Child
有一个created
钩子。父组件Parent
里监听了hook:created
事件。由于父组件监听的事件会记录到子组件中,因此当前实例的_hasHookEvent
为true
。那么在子组件触发钩子的时候,调用callHook
,也就是这里会触发vm.$emit('hook:' + hook)
。而此时,我们在父组件监听了hook:created
。所以当子组件调用callHook('created')
的时候,会触发组件hook:created
绑定的事件。换句话来说,也就是我们可以通过hook:来监听子组件生命周期钩子的执行。
initInjections/initProvide
先来看下initInjections
,打开./initInjections.js
文件:
export function initInjections (vm: Component) {
const result = resolveInject(vm.$options.inject, vm)
if (result) {
// * 只监听最外层,内层的值不监听
toggleObserving(false)
Object.keys(result).forEach(key => {
...
defineReactive(vm, key, result[key])
})
toggleObserving(true)
}
}
initInjects
首先是通过resolveInject
方法找到对应的注入的值,然后将值遍历进行响应式处理。这里toggleObserving(false)
的函数是将shouldObserve
变量改为false
,从而使得observe
方法不能监测对象:
export let shouldObserve: boolean = true
export function toggleObserving (value: boolean) {
shouldObserve = value
}
export function observe (value: any, asRootData: ?boolean): Observer | void {
...
if (shouldObserve && ...) {
ob = new Observer(value)
}
...
}
即如果inject
某个字段的值为对象,那么就不会进行深度监测了。
接下里是initProvide
方法,也比较简单:
export function initProvide (vm: Component) {
const provide = vm.$options.provide
if (provide) {
vm._provided = typeof provide === 'function'
? provide.call(vm)
: provide
}
}
如果provide
为函数,就直接执行,最终将值赋值给vm._provided
。而initInject
里resolveInject
的核心就是递归向上查找_provided
提供的值。
let source = vm
while (source) {
if (source._provided && hasOwn(source._provided, provideKey)) {
result[key] = source._provided[provideKey]
break
}
source = source.$parent
}
这里有几点需要注意:
无法通过
provide
里的值无法通过this.xxx
来访问,因为没有做任何代理处理。provide
里的值需要通过this._provided.xxx
访问provide
里的值未经过响应式处理,因此改变其值,inject
不像props
那样,无法接收到这种变化,所以inject
里的值还是原值,没有变化。
从这里其实就可以看出provide/inject
传值过程中的一些局限性了。
initState
initState
方法是处理响应式数据的核心,在讲这个方法实现之前,我们先熟悉一个知识点:Watcher
的种类有哪些?
Vue
中watcher
主要分为三类:
第一种是renderWatcher
,它的核心是视图的渲染,在渲染的过程中,将渲染需要的数据搜集为依赖。
第二种是computedWatcher
,它的核心是计算属性,在使用computed
属性时,它会将它内部计算的数据搜集为依赖。
第三种是watchWatcher
,它的核心是监测字段,直接将某个字段或函数依赖的数据搜集为依赖。
了解完这个知识点后我们就看看initState
是如何处理这三种watcher
的。打开./initState.js
文件:
export function initState (vm: Component) {
vm._watchers = []
const opts = vm.$options
// * 校验 props 并添加到 vm 上 设置为响应式
if (opts.props) initProps(vm, opts.props)
// * 将 options 里的 methods 分别挂载到 vm 上
if (opts.methods) initMethods(vm, opts.methods)
// * 校验 data key是否冲突 并添加到 vm 上 设置为响应式
if (opts.data) {
initData(vm)
} else {
observe(vm._data = {}, true /* asRootData */)
}
// * 实例化 computed watcher 并作为依赖添加到相应的数据中
if (opts.computed) initComputed(vm, opts.computed)
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
}
可以看出initState
里对props,methods,data,computed,watch
都进行了处理,下面我们分别看一下这5个方法。
initProps
先看下initProps
里定义的几个属性:
const propsData = vm.$options.propsData || {}
const props = vm._props = {}
const keys = vm.$options._propKeys = []
propsData
是在core/init.js
中的initInternalComponent
中进行赋值的:
const vnodeComponentOptions = parentVnode.componentOptions
opts.propsData = vnodeComponentOptions.propsData
propsData
表示父组件传到子组件的值,_props
是存储处理后的props
,_propKeys
用于存储key
,将其保存为数组形式,这样后续就不用遍历对象的key
,而是直接遍历数组,相当于做了一点优化。
接着校验父组件传入的值是否符合规范,validateProp
方法将出入的值propsData
和当前我们写的规范propsOptions
进行对比校验了
const value = validateProp(key, propsOptions, propsData, vm)
接着校验属性是否是保留属性,如key,ref,slot,slot-scope,is,style,class
这些都不能使用,判断的代码如下:
if (isReservedAttribute(hyphenatedKey)
|| config.isReservedAttr(hyphenatedKey)) { ... }
校验完成就会将props
定义为响应式了:
defineReactive(props, key, value)
最后还有一段代码,对props
做了一层代理:
// 代理方法
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)
}
// 对 key 进行代理
proxy(vm, `_props`, key)
proxy
方法代理完后,每次访问this.key
相当于访问this._props.key
。这就解释了为什么我们可以直接在Vue
中用this
来访问props
里的属性。
initMethods
initMethods
方法比较简单,它主要的工作是遍历options
中的methods
,然后将所有method
添加到vm
实例上。
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)
}
}
在添加到实例之前,它还做了三层判断:
if (process.env.NODE_ENV !== 'production') {
if (typeof methods[key] !== 'function') {...}
if (props && hasOwn(props, key)) {...}
if ((key in vm) && isReserved(key)) {...}
}
在非生产环境下,如果method
不是函数,或者和props
属性重名,或者是以_
或$
开头命名都会进行警告。这里之所以不能以_
或$
开头命名,是为了防止将这些属性代理到Vue
实例上的时候,与Vue
内置的属性、api
等冲突。后面data
的处理亦是如此。
initData
首先如果data
是函数形式,那么会立即执行一次:
data = vm._data = typeof data === 'function'
? getData(data, vm)
: data || {}
注意这里在执行genData
的时候同样是不需要搜集依赖的,所以在执行data
函数前会进行pushTarget()
当前Watcher
置空:
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()
}
}
接着就是data
了属性的校验和代理了:
if (process.env.NODE_ENV !== 'production') {
if (methods && hasOwn(methods, key)) {...}
}
if (props && hasOwn(props, key)) {...}
else if (!isReserved(key)) {
proxy(vm, `_data`, key)
}
observe(data, true /* asRootData */)
首先校验data
里的属性是否与methods
和props
里的属性重名;其次将data
里的属性进行代理,使得每次访问this.key
相当于访问this._data.key
。最后使用observe
方法将data
变为响应式,这里第二个参数为true
,代表他是根数据。之前响应式章节提到过根数据的ob.vmCount
大于0,因此$set
方法是无法直接对data
下的属性进行响应式处理的。
initComputed
首先,在vm
上定义一个属性_computedWatchers
,用于保存所有的计算属性Watcher
:
const watchers = vm._computedWatchers = Object.create(null)
然后遍历所有的计算属性,分别取出它们的get
,如果get
不存在,那么在非生产环境下会进行警告:
for (const key in computed) {
const userDef = computed[key]
const getter = typeof userDef === 'function'
? userDef : userDef.get
if (process.env.NODE_ENV !== 'production'
&& getter == null) {...}
}
接着,通过获得的getter
实例化Watcher
,保存到_computedWatchers
上:
const computedWatcherOptions = { lazy: true }
watchers[key] = new Watcher(
vm,
getter || noop,
noop,
computedWatcherOptions
)
值得注意的是,这里的传入的lazy
为true
。我们看一下Watcher
类的执行过程:
this.dirty = this.lazy
...
this.value = this.lazy
? undefined
: this.get()
也就是说lazy为true的时候,Watcher实例化过程是不会进行依赖搜集的。此时computed
与所依赖的数据是没有绑定关系的。
接着往下看,在实例化Watcher
后,就开始对对计算属性进行响应式处理:
if (!(key in vm)) {
defineComputed(vm, key, userDef)
} else if (process.env.NODE_ENV !== 'production') {
if (key in vm.$data) {...}
else if (vm.$options.props && key in vm.$options.props) {...}
}
一是判断computed
的名称是否和data
,props
里的属性名称冲突;二是通过defineComputed
来进行响应式处理。
找到defineComputed
方法,由于这里不讨论服务端渲染相关的,我们将代码简化后如下:
if (typeof userDef === 'function') {
sharedPropertyDefinition.get = createComputedGetter(key)
sharedPropertyDefinition.set = noop
} else {
sharedPropertyDefinition.get = userDef.get
? createComputedGetter(key)
: noop
sharedPropertyDefinition.set = userDef.set || noop
}
Object.defineProperty(target, key, sharedPropertyDefinition)
这里computed
主要对函数和对象两种写法进行了兼容处理,同时get
都是通过createComputedGetter
来创建的,找到该函数:
function createComputedGetter (key) {
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
}
}
}
这里的代码并不多,我们一步步来看具体是如何执行的。首先是判断相应的watcher
是否存在。如果不存在,则不进行任何处理。如果存在,判断watcher.dirty
是否为true
。由于实例化时lazy
为true
,所以dirty=lazy
也为true
。因此首次获取computed
的属性时,会执行watcher.evaluate()
。在Watcher
类中找到evaluate
:
evaluate () {
this.value = this.get()
this.dirty = false
}
首先会执行get
方法,进行依赖搜集,搜集完后,将dirty
置为true
。因此下次再获取computed
属性的时候,判断watcher.dirty
为false
,那么就会直接返回上次计算的值watcher.value
。这样就实现了computed
的缓存特性,那么computed
是如何在数据改变的时候更新的呢?从响应式章节,我们知道,当数据改变时会触发watcher
的update
方法:
update () {
if (this.lazy) {
this.dirty = true
}
...
}
所以当数据变化时,由于this.lazy
为true
,dirty
被置为true
。此时再去获取computed
属性的时候,判断watcher.dirty
为true
,那么就会重新进行计算新的值了。至此,我们就能明白computed
缓存和更新的实现原理了。
最后,在computedGetter
的后半段还有一段代码:
if (Dep.target) {
watcher.depend()
}
这行代码是什么意思呢?为什么要在这个位置进行依赖搜集呢?这里用一个例子来说明:比如现在是在render
的过程,当前Dep.target
为renderWatcher
。在渲染过程中发现模板里有一个computed
属性,这个时候就会触发computed
的get
属性,也就是会执行watcher.depend()
。执行这个的过程会使得renderWatcher
将computed
依赖的数据搜集为自己的依赖,这样在依赖数据改变的时候,视图就会更新。如果没有这个搜集过程呢?那么当computed
依赖的数据改变时,视图并不会一定就更新,因为data
和computed
都和renderWatcher
没有绑定关系。至此,computed
的所有代码就讲完了。
initWatch
function initWatch (vm: Component, watch: Object) {
for (const key in watch) {
const handler = watch[key]
if (Array.isArray(handler)) {
for (let i = 0; i < handler.length; i++) {
createWatcher(vm, key, handler[i])
}
} else {
createWatcher(vm, key, handler)
}
}
}
这段代码比较容易理解,initWatch
方法主要是遍历watch
,然后分别用createWatcher
来创建watcher
:
function createWatcher (
vm: Component,
expOrFn: string | Function,
handler: any,
options?: Object
) {
if (isPlainObject(handler)) {
options = handler
handler = handler.handler
}
if (typeof handler === 'string') {
handler = vm[handler]
}
return vm.$watch(expOrFn, handler, options)
}
前面一段判断的代码,主要是将对象形式和字符串形式定义的watch
转成函数形式,然后调用$watch
方法。$watch
方法是在定义Vue
原型对象5个mixin
的时候定义的,看下其核心实现:
Vue.prototype.$watch = function (
expOrFn: string | Function,
cb: any,
options?: Object
): Function {
...
options.user = true
const watcher = new Watcher(vm, expOrFn, cb, options)
...
}
}
可以看出,这里其实就是实例化了一个watcher
,并且options.user
为true
。这个实例化过程与普通的搜集依赖的过程并无差别,唯一的区别在于options.user
为true
时会在run
方法执行回调里捕捉错误:
if (this.user) {
try {
this.cb.call(this.vm, value, oldValue)
} catch (e) {
...
}
} else {
this.cb.call(this.vm, value, oldValue)
}
为什么要这样处理呢?因为watch
的回调函数是用户自己写的,如果错误会给用户进行提示。
最后,$watch
返回的是一个函数:
// * 取消监听,从依赖中移除
return function unwatchFn () {
watcher.teardown()
}
主要是用于取消监听某个值:teardown
的本质也是对Dep
和Watcher
之间的关系进行解绑:
let i = this.deps.length
while (i--) {
this.deps[i].removeSub(this)
}
this.active = false
总结
通过三章响应式原理相关的学习,我们分别了解了如何构建一个极简的响应式系统,Vue
是如何实现和应用响应式系统的。这一章我们从Vue
实例化的角度,熟悉了数据初始化的过程,包括生命周期,事件机制以及相关数据的初始化过程,并了解了computed
,watch
的实现原理。
最后,附加一个知识点,为什么生命周期中beforeCreate
钩子里不能获取到数据,但是created
钩子里能获取数据呢?
initLifecycle(vm)
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate')
initInjections(vm)
initState(vm)
initProvide(vm)
callHook(vm, 'created')
这里可以明显看出,beforeCreate
是在数据初始化(如data,props,inject
等)前调用的,而created
是在数据初始化后调用的,这也就能解释上面那个问题了。