Vue 源码(三)Props原理

3,632 阅读5分钟

前言

通过这篇文章将了解

  • 子组件 Vue 实例是怎么获取传入的props数据的
  • prop 响应原理

传入的prop数据是怎么添加到vm.$options.propsData上的

在开始之前需要明确一下:

createComponent函数中,会执行extractPropsFromVNodeData,这个函数就是用来提取传入的prop数据,并添加到组件占位符VNnode的componentOptions.propsData上;(在Vue 源码(一)如何创建VNode里面介绍过)

在创建组件实例过程中,会调用_init函数,在_init 中会合并options,对于组件的options合并,会将传入的prop数据绑定到 vm.$options.propsData

初始化

当执行_init方法时会调用initState方法去初始化options中的属性并添加响应,其中就包含props的初始化

initProps

props通过initProps函数初始化,定义在 src/core/instance/state.js

// propsOptions 子组件声明的 props 对象
function initProps (vm: Component, propsOptions: Object) {
  // 获取 父组件传入的 props
  const propsData = vm.$options.propsData || {}
  // 将 _props 挂载到 vm 上并初始化为一个空对象
  const props = vm._props = {}
  // 缓存 props 的每个 key
  const keys = vm.$options._propKeys = []
  // 只有子组件实例才有 vm.$parent 属性,指向父组件实例
  const isRoot = !vm.$parent
  if (!isRoot) {
    // 如果不是根实例,将 observer/index.js 中的 shouldObserve 设置成 false,这样就不会执行创建 Observer 实例了
    toggleObserving(false)
  }
  // 遍历 props 对象
  for (const key in propsOptions) {
    // 缓存 key
    keys.push(key)
    // 验证 传入的 props,并返回 传入的值或者默认值
    const value = validateProp(key, propsOptions, propsData, vm)
    // 开发环境下
    if (process.env.NODE_ENV !== 'production') {
      defineReactive(props, key, value, () => {
        // 注意这里!! 当父组件修改 传入的 props 属性时,会将 isUpdatingChildComponent 置为 true,所以不会报错
        // 当在子组件直接修改 props 属性时, isUpdatingChildComponent 为 false,会报错
        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 {
      // 为 props 的每个 key 添加响应
      defineReactive(props, key, value)
    }
    if (!(key in vm)) {
      // 代理 key 到 vm 对象上
      proxy(vm, `_props`, key)
    }
  }
  toggleObserving(true)
}

initProps 函数的流程如下:

  • 获取传入的prop数据
  • 遍历组件定义的 props
  • 通过validateProp验证传入的 prop ,获取传入的prop数据或者默认值
  • 调用defineReactive函数,通过Object.defineProperty将传入的prop数据添加到 vm._props中,并设置存取描述符。
  • 如果是根组件实例则通过proxy函数,代理 keyvm 对象上。组件实例的options.props通过Vue.extend创建子组件实例时,已经将props的所有key代理到了Sub.prototype._props上了(Vue 源码(一)如何创建VNode

疑问点

为什么要调用toggleObserving(false)

如果传入的prop数据是一个对象,调用defineReactive时,还会调用observe,给对象的属性添加响应;其实在父组件中,已经通过observe给对象内部所有属性添加响应了,所以这里就没必要再次添加了

validateProp

验证 传入的props,并返回 传入的值或者默认值

代码定义在src/core/util/props.js

export function validateProp (
  key: string,
  propOptions: Object,
  propsData: Object,
  vm?: Component
): any {
  const prop = propOptions[key]
  //  组件标签上没有属性 key,则为 true
  const absent = !hasOwn(propsData, key) // <x name /> 这种情况,absent 为 false
  // 获取传入的值
  let value = propsData[key]
  /* 处理布尔类型的 prop */
  // 如果 prop.type 不是数组,Boolean 和 prop.type 相同返回 0, 不同返回 -1
  // 如果 prop.type 是数组,优先返回第一个相同的。如果相同,返回对应的索引,如果不同返回 -1
  const booleanIndex = getTypeIndex(Boolean, prop.type)
  // 说明 props 可以是 Boolean 类型
  if (booleanIndex > -1) {
    if (absent && !hasOwn(prop, 'default')) {
      // 组件标签上没有属性 key,并且没有设置默认值,则 value 为 false
      value = false
    } else if (value === '' || value === hyphenate(key)) {
      // value 是 空字符串 或者和 key 一样(如果 key 是驼峰式 value 是连字符式 也可)
      // 比如:<x name> 或 <x name="name">、<x nameNick="name-nick">
      const stringIndex = getTypeIndex(String, prop.type)
      // 如果 prop.type 为 Boolean, value 为 true
      // 如果 prop.type 为 [Boolean, String],value 为 true
      if (stringIndex < 0 || booleanIndex < stringIndex) {
        value = true
      }
    }
  }
  if (value === undefined) {
    // 如果传入的是 undefined,则获取默认值
    value = getPropDefaultValue(vm, prop, key)
    const prevShouldObserve = shouldObserve
    toggleObserving(true)
    // 给默认值添加响应
    observe(value)
    toggleObserving(prevShouldObserve)
  }
  if (
    process.env.NODE_ENV !== 'production' &&
    !(__WEEX__ && isObject(value) && ('@binding' in value))
  ) {
    assertProp(prop, key, value, vm, absent)
  }
  return value
}

validateProp函数根据key获取组件中对应prop定义,如果type属性中包含布尔类型,则转换prop数据;比如<child bool>这种会设为true

如果没有传入会获取默认值,对于使用默认值的prop会对默认值添加响应;然后在开发环境下通过assertProp校验prop数据是否符合预期。

getPropDefaultValue

getPropDefaultValue作用是获取默认值

function getPropDefaultValue (vm: ?Component, prop: PropOptions, key: string): any {
  // no default, return undefined
  if (!hasOwn(prop, 'default')) {
    return undefined
  }
  const def = prop.default
  // 如果 默认值是一个对象或数组则报错
  if (process.env.NODE_ENV !== 'production' && isObject(def)) {
    warn()
  }
  // 这里是一种优化手段
  // 当第一次和第二次都没传入值时,说明两次都是用的默认值,第一次已经对这个默认值添加监听了
  // 所以第二次直接将第一次被监听的对象赋值给 value
  // 这样执行到后面的 observe 时,会因为有 __ob__ 属性,不会再次执行后面添加响应的逻辑
  if (vm && vm.$options.propsData &&
    vm.$options.propsData[key] === undefined &&
    vm._props[key] !== undefined
  ) {
    return vm._props[key]
  }
  // 如果 默认值是一个 function,并且 期望的类型不是 Function 时,说明 默认值是一个对象或者数组,执行这个函数拿到 默认值
  return typeof def === 'function' && getType(prop.type) !== 'Function'
    ? def.call(vm)
    : def
}

初始化过程如下

initprops.jpg

Props 更新

当修改父组件传递给子组件的prop数据时,子组件对应的值也会改变,同时会触发子组件的重新渲染。

在分析这个过程之前,先看下父子组件的依赖收集过程

父组件依赖收集

首先,在父组件render函数执行过程中,会访问到这个prop数据。并将父组件的Render Watcher添加到这个prop数据的dep.subs

子组件依赖收集

initProps函数中,对所有prop数据添加了响应。执行子组件render函数时,如果使用了某个prop数据,则会触发对应prop数据的getter方法,将子组件的Render Watcher添加到prop数据的dep.subs

属性值是基本数据类型

prop数据是基本数据类型时,getter方法会将子组件的Render Watcher添加到prop数据对应的dep.subs

属性值是对象

prop数据是对象时,父组件已经对prop数据的内部属性添加了响应,所以执行到defineReactive函数中的let childOb = !shallow && observe(val)时,因为已经存在__ob__属性,所以不会给属性值设置存取描述符,而只对vm._props.xxx设置存取描述符。

当子组件的render函数获取对象的属性值时,触发getter,实际上是将子组件的Render Watcher添加到父组件对应属性的dep.subs

属性值是默认值

prop数据是默认值时,会通过getPropDefaultValue获取默认值,并在validateProp中对默认值添加响应

// ...
if (value === undefined) {
    // 如果传入的是 undefined,则获取默认值
    value = getPropDefaultValue(vm, prop, key)
    const prevShouldObserve = shouldObserve
    toggleObserving(true)
    // 给默认值添加响应
    observe(value)
    toggleObserving(prevShouldObserve)
}
// ...

更新

当父组件修改prop数据时,会触发父组件中对应属性的setter,从而通知依赖此属性的所有Watcher更新;也就是说每次父组件修改prop数据都会导致自身更新(如果prop数据是对象,父组件修改的是对象内部属性的话,自身不会更新,因为没有将父组件的Render Watcher添加到修改属性的dep.subs);在父组件重新渲染的最后,会执行patch过程(patch过程会单独拿出两节来说,这里就先了解下),进而执行 patchVnode 函数,patchVnode 通常是一个递归过程,当它遇到组件VNode时,会执行组件的prepatch钩子函数,在 src/core/vdom/patch.js 中:

prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
    const options = vnode.componentOptions
    const child = vnode.componentInstance = oldVnode.componentInstance
    updateChildComponent(
      child,
      options.propsData, // updated props 传入子组件的最新的 props 值
      options.listeners, // updated listeners 自定义事件
      vnode, // new parent vnode
      options.children // new children
    )
}

prepatch内调用updateChildComponent函数,传入最新的prop数据;因为在执行父组件render函数时,子组件会创建一个组件占位符VNode,在这个过程中会获取最新的prop数据,并添加到组件占位符 VNode 的componentOptions.propsData属性中;所以prepatch中的options.propsData是最新的prop数据

updateChildComponent函数,它的定义在 src/core/instance/lifecycle.js 中:

export function updateChildComponent (
  vm: Component, // 子组件实例
  propsData: ?Object,
  listeners: ?Object,
  parentVnode: MountedComponentVNode, // 组件 vnode
  renderChildren: ?Array<VNode>
) {
  if (process.env.NODE_ENV !== 'production') {
    // 设置成 true 的目的是,给 props[key] 赋值时,触发 set 方法,不会让 customSetter 函数报错
    isUpdatingChildComponent = true
  }
  // ...

  // 更新 props
  if (propsData && vm.$options.props) {
    toggleObserving(false)
    // 之前的 propsData
    const props = vm._props
    // 子组件定义的 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的值触发 组件更新
      props[key] = validateProp(key, propOptions, propsData, vm)
    }
    toggleObserving(true)
    // keep a copy of raw propsData
    vm.$options.propsData = propsData
  }
  
  // ...

  if (process.env.NODE_ENV !== 'production') {
    // 更新完成后,置为 false
    isUpdatingChildComponent = false
  }
}

在这里只看 props 相关逻辑,首先将 isUpdatingChildComponent 变为 true,目的之后会说;接下来就是遍历propKeys,然后执行 props[key] = validateProp(key, propOptions, propsData, vm) 重新验证和计算新的 prop 数据,更新 vm._props,此时会触发 prop数据的setter过程,只要在渲染子组件的时候访问过这个 prop 值,那么根据响应式原理,就会触发子组件的重新渲染。大体流程是,调用子组件Redner Watcherupdate方法,因为现在正在执行队列,所以不会再次调用nextTick,而是将子组件的Render Watcher直接添加到队列中等待执行(所以,更新过程是先父后子)。

回到 updateChildComponent 函数,接下来将最新的propsData添加到vm.$options.propsData里面。并执行isUpdatingChildComponent = false;修改它的作用其实就是防止修改vm._props属性时报错。

开发环境下在给 prop数据设置gettersetter时,会传入customSetter,它定义在initProps函数中;属性修改触发settersetter函数内如果传入了customSetter,会执行这个函数。所以在开发环境下如果子组件直接修改prop数据会报错。

defineReactive(props, key, value, () => {
    // 注意这里!! 当父组件修改 传入的 props 属性时,会将 isUpdatingChildComponent 置为 true,所以不会报错
    // 当在子组件直接修改 props 属性时, isUpdatingChildComponent 为false,会报错
    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
      )
    }
})

如果prop数据是对象

<hello :a="obj" />
<!-- obj = { name: 'xxx' } -->

如果修改的是prop的内部属性,不会触发父组件的patch过程,因为父组件的render函数中并没有用到该属性,自然也不会触发updateChildComponent函数;但是在子组件的渲染过程中,已经将子组件的Render Watcher添加到父组件对应属性的dep.subs里面了;所以会触发子组件的更新。

也就是说当父组件更新内部属性时,会触发此属性的setter方法,从而触发子组件的Watcher更新;因为父组件传入是对象,是引用数据类型,所以子组件获取的prop数据也是最新的

总结

子组件 Vue 实例是怎么获取传入的props数据的

在执行父组件的render函数时,会为子组件创建组件占位符VNode,此时会根据子组件中props的定义从组件标签的属性中匹配传入的数据,并存储在组件占位符VNode 中

prop 响应原理

初始化子组件的 Vue 实例时,通过Object.defineProperty给传入的prop数据添加拦截,如果传入的是一个对象类型,由于父组件已经对对象的属性添加了拦截,所以不会再次在子组件添加拦截。

data的区别就是,data中的属性如果不是基本数据类型会为这个属性创建Observer实例;而props的数据不会;有一种情况除外,就是prop默认值是对象类型,会给这个默认值创建Observer实例。

接下来从两个方面分别说一下依赖收集和派发更新

  • 传给子组件的是基本数据类型
  • 传给子组件的是对象

传给子组件的是基本数据类型

父组件创建 VNode 时,收集当前 Render Watcher 到响应式属性的dep.subs中。创建 子组件VNode 时,也会收集当前Render Watcher 到prop数据的dep.subs中。

当父组件修改数据时,触发父组件的视图更新,获取最新的prop数据;在创建父组件 DOM树的过程中,赋值给子组件的vm._props;从而被prop数据的setter捕获,触发子组件视图更新。

也就是说,如果传给子组件的是基本数据类型,他们的更新原理是父组件驱动子组件更新

传给子组件的是对象

父组件创建 VNode 时,收集当前 Render Watcher 到响应式属性的dep.subs中。创建 子组件VNode 时,也会收集当前Render Watcher 到prop数据的dep.subs中。和上面不同的是,当子组件使用的是prop数据的内部属性时,会将Render Watcher 添加到父组件对应内部属性的dep.subs中。

当父组件修改属性的内部属性时,不会触发父组件更新,因为父组件没有使用这个内部属性,而使用的是整个对象。但是会触发子组件更新,因为子组件的Render Watcher 被收集到了这个内部属性的dep.subs里面了。

也就是说如果传给子组件的是一个对象,并且子组件使用了这个内部属性,子组件的 Render Watcher会被这个内部属性的dep.subs收集

如果父组件直接修改这个对象的引用,则和传入基本数据类型的更新流程一致。