Skip to content

数据初始化之响应式原理

本章知识要点:

  • 生命周期和事件机制的初始化过程是怎样的?
  • 父组件如何快捷监听子组件的生命周期触发事件?
  • inject/provide的实现与传值时的问题。
  • props/data/methods的初始化过程。
  • computed的实现原理。
  • watch的实现原理。
  • 为什么beforeCreate生命周期里无法获取数据?

上一章中我们已经结合Vue源码分析了响应式系统源码的组成,并且熟悉了defineReactive/observe方法和Watcher/Dep/Observer类的代码实现。这一章我们主要侧重于Vue的数据初始化过程,并且通过数据初始化的过程,看看Vue是如何将Watcher、defineReactive应用到项目中的。

回到Vue初始化过程,找到core/init.js中的_init方法:

javascript
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方法已经在前面章节中讲过,它在这里主要的作用是将外部用户传入的optionsVue内部的选项配置进行合并。第二步则是数据的初始化过程,也就是本章主要分析的内容,下面我们逐一分析下其中每一个方法的含义。

initLifecycle

找到./lifecycle.js文件中的initLifecycle方法:

javascript
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

javascript
vm.$parent = parent
vm.$root = parent ? parent.$root : vm

vm.$children = []
vm.$refs = {}

第二类则跟生命周期比较相关,代码如下:

javascript
  // 这里存的是渲染 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方法:

javascript
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方法:

javascript
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存储slotvnode等。

接下来又定义了两个比较重要的方法:

javascript
 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设置为响应式了。

javascript
defineReactive(vm, '$attrs', parentData && parentData.attrs || emptyObject, null, true)
defineReactive(vm, '$listeners', options._parentListeners || emptyObject, null, true)

callHook

callHook方法同样是在./lifecycle.js文件当中,代码如下

javascript
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是不存在的,因此也就不会进行依赖搜集。

但是,为什么生命周期里不需要进行依赖搜集呢?举个例子:有一个属性AWatcherB,他们之间没有任何关系,但是如果生命周期里使用了属性A,此时进行依赖搜集,即属性AWatcherB绑定关系,那么每次在其他地方改变属性A都会去触发这个WatcherB,这就会产生副作用。

看完了这段代码,再看下一段代码:

javascript
// * 如果绑定的事件中有 hook:[eventName], 那么 _hasHookEvent 为true
// * 可以通过这种方法监听子组件的生命周期事件
if (vm._hasHookEvent) {
  vm.$emit('hook:' + hook)
}

这里就提到了我们前面挖下的坑:_hasHookEvent具体有什么作用?这里用一个简单的例子说明:

javascript
// 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事件。由于父组件监听的事件会记录到子组件中,因此当前实例的_hasHookEventtrue。那么在子组件触发钩子的时候,调用callHook,也就是这里会触发vm.$emit('hook:' + hook)。而此时,我们在父组件监听了hook:created。所以当子组件调用callHook('created')的时候,会触发组件hook:created绑定的事件。换句话来说,也就是我们可以通过hook:来监听子组件生命周期钩子的执行。

initInjections/initProvide

先来看下initInjections,打开./initInjections.js文件:

javascript
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方法不能监测对象:

javascript
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方法,也比较简单:

javascript
export function initProvide (vm: Component) {
  const provide = vm.$options.provide
  if (provide) {
    vm._provided = typeof provide === 'function'
      ? provide.call(vm)
      : provide
  }
}

如果provide为函数,就直接执行,最终将值赋值给vm._provided。而initInjectresolveInject的核心就是递归向上查找_provided提供的值。

javascript
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的种类有哪些?

Vuewatcher主要分为三类:

第一种是renderWatcher,它的核心是视图的渲染,在渲染的过程中,将渲染需要的数据搜集为依赖。

第二种是computedWatcher,它的核心是计算属性,在使用computed属性时,它会将它内部计算的数据搜集为依赖。

第三种是watchWatcher,它的核心是监测字段,直接将某个字段或函数依赖的数据搜集为依赖。

了解完这个知识点后我们就看看initState是如何处理这三种watcher的。打开./initState.js文件:

javascript
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里定义的几个属性:

javascript
const propsData = vm.$options.propsData || {}
const props = vm._props = {}
const keys = vm.$options._propKeys = []

propsData是在core/init.js中的initInternalComponent中进行赋值的:

javascript
const vnodeComponentOptions = parentVnode.componentOptions
opts.propsData = vnodeComponentOptions.propsData

propsData表示父组件传到子组件的值,_props是存储处理后的props_propKeys用于存储key,将其保存为数组形式,这样后续就不用遍历对象的key,而是直接遍历数组,相当于做了一点优化。

接着校验父组件传入的值是否符合规范,validateProp方法将出入的值propsData和当前我们写的规范propsOptions进行对比校验了

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

接着校验属性是否是保留属性,如key,ref,slot,slot-scope,is,style,class这些都不能使用,判断的代码如下:

javascript
if (isReservedAttribute(hyphenatedKey) 
  || config.isReservedAttr(hyphenatedKey)) { ... }

校验完成就会将props定义为响应式了:

javascript
defineReactive(props, key, value)

最后还有一段代码,对props做了一层代理:

javascript
// 代理方法
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实例上。

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

在添加到实例之前,它还做了三层判断:

javascript
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是函数形式,那么会立即执行一次:

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

注意这里在执行genData的时候同样是不需要搜集依赖的,所以在执行data函数前会进行pushTarget()当前Watcher置空:

javascript
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了属性的校验和代理了:

javascript
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里的属性是否与methodsprops里的属性重名;其次将data里的属性进行代理,使得每次访问this.key相当于访问this._data.key。最后使用observe方法将data变为响应式,这里第二个参数为true,代表他是根数据。之前响应式章节提到过根数据的ob.vmCount大于0,因此$set方法是无法直接对data下的属性进行响应式处理的。

initComputed

首先,在vm上定义一个属性_computedWatchers,用于保存所有的计算属性Watcher

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

然后遍历所有的计算属性,分别取出它们的get,如果get不存在,那么在非生产环境下会进行警告:

javascript
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上:

javascript
const computedWatcherOptions = { lazy: true }
watchers[key] = new Watcher(
  vm,
  getter || noop,
  noop,
  computedWatcherOptions
)

值得注意的是,这里的传入的lazytrue。我们看一下Watcher类的执行过程:

javascript
this.dirty = this.lazy
...
this.value = this.lazy
    ? undefined
    : this.get()

也就是说lazy为true的时候,Watcher实例化过程是不会进行依赖搜集的。此时computed与所依赖的数据是没有绑定关系的。

接着往下看,在实例化Watcher后,就开始对对计算属性进行响应式处理:

javascript
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的名称是否和dataprops里的属性名称冲突;二是通过defineComputed来进行响应式处理。

找到defineComputed方法,由于这里不讨论服务端渲染相关的,我们将代码简化后如下:

javascript
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来创建的,找到该函数:

javascript
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。由于实例化时lazytrue,所以dirty=lazy也为true。因此首次获取computed的属性时,会执行watcher.evaluate()。在Watcher类中找到evaluate

javascript
evaluate () {
  this.value = this.get()
  this.dirty = false
}

首先会执行get方法,进行依赖搜集,搜集完后,将dirty置为true。因此下次再获取computed属性的时候,判断watcher.dirtyfalse,那么就会直接返回上次计算的值watcher.value。这样就实现了computed的缓存特性,那么computed是如何在数据改变的时候更新的呢?从响应式章节,我们知道,当数据改变时会触发watcherupdate方法:

javascript
update () {
  if (this.lazy) {
    this.dirty = true
  }
 ...
}

所以当数据变化时,由于this.lazytruedirty被置为true。此时再去获取computed属性的时候,判断watcher.dirtytrue,那么就会重新进行计算新的值了。至此,我们就能明白computed缓存和更新的实现原理了。

最后,在computedGetter的后半段还有一段代码:

javascript
if (Dep.target) {
  watcher.depend()
}

这行代码是什么意思呢?为什么要在这个位置进行依赖搜集呢?这里用一个例子来说明:比如现在是在render的过程,当前Dep.targetrenderWatcher。在渲染过程中发现模板里有一个computed属性,这个时候就会触发computedget属性,也就是会执行watcher.depend()。执行这个的过程会使得renderWatchercomputed依赖的数据搜集为自己的依赖,这样在依赖数据改变的时候,视图就会更新。如果没有这个搜集过程呢?那么当computed依赖的数据改变时,视图并不会一定就更新,因为datacomputed都和renderWatcher没有绑定关系。至此,computed的所有代码就讲完了。

initWatch

javascript
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:

javascript
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的时候定义的,看下其核心实现:

javascript
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.usertrue。这个实例化过程与普通的搜集依赖的过程并无差别,唯一的区别在于options.usertrue时会在run方法执行回调里捕捉错误:

javascript
if (this.user) {
 try {
  this.cb.call(this.vm, value, oldValue)
 } catch (e) {
  ...
 }
} else {
 this.cb.call(this.vm, value, oldValue)
}

为什么要这样处理呢?因为watch的回调函数是用户自己写的,如果错误会给用户进行提示。

最后,$watch返回的是一个函数:

javascript
// * 取消监听,从依赖中移除
return function unwatchFn () {
  watcher.teardown()
}

主要是用于取消监听某个值:teardown的本质也是对DepWatcher之间的关系进行解绑:

javascript
let i = this.deps.length
while (i--) {
 this.deps[i].removeSub(this)
}
this.active = false

总结

通过三章响应式原理相关的学习,我们分别了解了如何构建一个极简的响应式系统,Vue是如何实现和应用响应式系统的。这一章我们从Vue实例化的角度,熟悉了数据初始化的过程,包括生命周期,事件机制以及相关数据的初始化过程,并了解了computedwatch的实现原理。

最后,附加一个知识点,为什么生命周期中beforeCreate钩子里不能获取到数据,但是created钩子里能获取数据呢?

javascript
initLifecycle(vm)
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate')
initInjections(vm)
initState(vm)
initProvide(vm)
callHook(vm, 'created')

这里可以明显看出,beforeCreate是在数据初始化(如data,props,inject等)前调用的,而created是在数据初始化后调用的,这也就能解释上面那个问题了。