数据初始化之响应式原理
本章知识要点:
- 生命周期和事件机制的初始化过程是怎样的?
- 父组件如何快捷监听子组件的生命周期触发事件?
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.propsDatapropsData表示父组件传到子组件的值,_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是在数据初始化后调用的,这也就能解释上面那个问题了。
