[Vue源码学习] props

353 阅读2分钟

系列文章

前言

从之前的章节中,我们知道Vue是如何将普通数据转换为响应式数据,但是组件除了拥有自身的数据外,还可以接收来自父组件中传入的数据,那么在本章节中,我们就来看看Vue是如何处理来自外部的数据。

propsData

由于props选项是用来接收来自父组件的数据,所以首先得从父组件构造propsData说起。在创建组件的父占位符的过程中,会调用Vue.extend方法,构造子组件构造器,在此过程中,会调用normalizeProps方法,规范化组件的props选项,代码如下所示:

/* core/util/options.js */
function normalizeProps(options: Object, vm: ?Component) {
  const props = options.props
  if (!props) return
  const res = {}
  let i, val, name
  if (Array.isArray(props)) {
    i = props.length
    while (i--) {
      val = props[i]
      if (typeof val === 'string') {
        name = camelize(val)
        res[name] = { type: null }
      } else if (process.env.NODE_ENV !== 'production') {
        warn('props must be strings when using array syntax.')
      }
    }
  } else if (isPlainObject(props)) {
    for (const key in props) {
      val = props[key]
      name = camelize(key)
      res[name] = isPlainObject(val)
        ? val
        : { type: val }
    }
  } else if (process.env.NODE_ENV !== 'production') {
    warn(
      `Invalid value for option "props": expected an Array or an Object, ` +
      `but got ${toRawType(props)}.`,
      vm
    )
  }
  options.props = res
}

可以看到,由于props选项支持数组、对象等多种书写格式,所以需要使用normalizeProps方法,使每个数据的配置都规范化为统一的格式,从而方便之后的解析。在规范化完成后,在Vue.extend方法中,还会调用initProps方法,将props代理到组件的原型上,因为这部分数据是可以共享的,其代码如下所示:

/* core/global-api/extend.js */
Vue.extend = function (extendOptions: Object): Function {
  // ...
  if (Sub.options.props) {
    initProps(Sub)
  }
  // ...
}

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

可以看到,在initProps方法中,主要就是将数据代理到组件原型上的_props对象中。处理完props选项后,就调用extractPropsFromVNodeData方法来构建propsData,代码如下所示:

/* core/vdom/create-component.js */
export function createComponent(
  Ctor: Class<Component> | Function | Object | void,
  data: ?VNodeData,
  context: Component,
  children: ?Array<VNode>,
  tag?: string
): VNode | Array<VNode> | void {
  // ...
  // extract props
  const propsData = extractPropsFromVNodeData(data, Ctor, tag)
  // ...
}

/* core/vdom/helpers/extract-props.js */
export function extractPropsFromVNodeData(
  data: VNodeData,
  Ctor: Class<Component>,
  tag?: string
): ?Object {
  // we are only extracting raw values here.
  // validation and default values are handled in the child
  // component itself.
  const propOptions = Ctor.options.props
  if (isUndef(propOptions)) {
    return
  }
  const res = {}
  const { attrs, props } = data
  if (isDef(attrs) || isDef(props)) {
    for (const key in propOptions) {
      const altKey = hyphenate(key)
      if (process.env.NODE_ENV !== 'production') {
        const keyInLowerCase = key.toLowerCase()
        if (
          key !== keyInLowerCase &&
          attrs && hasOwn(attrs, keyInLowerCase)
        ) {
          tip(
            `Prop "${keyInLowerCase}" is passed to component ` +
            `${formatComponentName(tag || Ctor)}, but the declared prop name is` +
            ` "${key}". ` +
            `Note that HTML attributes are case-insensitive and camelCased ` +
            `props need to use their kebab-case equivalents when using in-DOM ` +
            `templates. You should probably use "${altKey}" instead of "${key}".`
          )
        }
      }
      checkProp(res, props, key, altKey, true) ||
        checkProp(res, attrs, key, altKey, false)
    }
  }
  return res
}

可以看到,在extractPropsFromVNodeData方法中,首先从组件配置中提取刚刚处理过的props选项,并赋值给propOptions,然后从VNodeData中提取attrsprops,接着开始遍历propOptions,处理每一个数据,首先调用hyphenate方法,将属性名处理成连字符的形式,然后使用checkProp方法尝试从attrsprops中提取数据,代码如下所示:

/* core/vdom/helpers/extract-props.js */
function checkProp(
  res: Object,
  hash: ?Object,
  key: string,
  altKey: string,
  preserve: boolean
): boolean {
  if (isDef(hash)) {
    if (hasOwn(hash, key)) {
      res[key] = hash[key]
      if (!preserve) {
        delete hash[key]
      }
      return true
    } else if (hasOwn(hash, altKey)) {
      res[key] = hash[altKey]
      if (!preserve) {
        delete hash[altKey]
      }
      return true
    }
  }
  return false
}

可以看到,首先尝试从props中提取数据,否则尝试从attrs中提取数据,如果数据存在于attrs中,还会将数据从attrs中删除。

经过extractPropsFromVNodeData方法处理后,就可以得到从父组件中传到子组件的数据propsData,在createComponent方法的最后,会将propsData放到VNode.componentOptions中。

从前面的章节中,我们知道,在父组件patch的过程中,会创建子组件的实例,同时也会将组件的父占位符VNode当作配置选项传入,所以在子组件调用initInternalComponent方法进行配置合并的过程中,就可以从父占位符VNode中提取propsData。那么接下来,就来看看子组件是如何处理该数据的。

initProps

在初始化子组件,得到propsData后,会调用initState方法,在该方法中,又会调用initProps方法,这个方法就是用来处理propsData的,代码如下所示:

/* core/instance/state.js */
export function initState(vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  // ...
}

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
  // root instance props should be converted
  if (!isRoot) {
    toggleObserving(false)
  }
  for (const key in propsOptions) {
    keys.push(key)
    const value = validateProp(key, propsOptions, propsData, vm)
    /* istanbul ignore else */
    if (process.env.NODE_ENV !== 'production') {
      const hyphenatedKey = hyphenate(key)
      if (isReservedAttribute(hyphenatedKey) ||
        config.isReservedAttr(hyphenatedKey)) {
        warn(
          `"${hyphenatedKey}" is a reserved attribute and cannot be used as component prop.`,
          vm
        )
      }
      defineReactive(props, key, value, () => {
        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 {
      defineReactive(props, key, value)
    }
    // static props are already proxied on the component's prototype
    // during Vue.extend(). We only need to proxy props defined at
    // instantiation here.
    if (!(key in vm)) {
      proxy(vm, `_props`, key)
    }
  }
  toggleObserving(true)
}

可以看到,在initProps方法中,对于非根组件实例,会调用toggleObserving方法,阻止嵌套数据的响应式化,然后遍历组件的props选项,对每个数据使用validateProp方法,该方法是用来检测数据是否满足配置中的typerequiredvalidator选项,在不满足时会提示警告,如果有default选项的话,在没有传入数据时返回默认值,validateProp方法最终会返回对应的数据。

在得到数据之后,就是调用defineReactive方法,将该数据转换为响应式数据,需要注意的是,由于前面调用了toggleObserving(false)方法,所以不会对嵌套数据进行响应式化,只有最外层数据才会经过响应式处理,然后判断如果该属性没有定义在Vue实例上,就使用proxy方法,将_props上的数据代理到Vue实例上。最后就是调用toggleObserving(true)方法,恢复标志位。

经过initProps方法处理后,已经将父组件传入的数据,经过响应式处理后,代理到子组件实例上了。但是当在子组件中使用最外层数据时,父组件中对应的数据是无法感知的,也就是说,父组件中该数据对应的dep集合中是不包含子组件的渲染Watcher的(嵌套数据除外)。那接下来,就来看看在父组件中修改数据时,子组件是如何收到最新的数据,并进行更新的。

update

首先需要明确的是,对于props选项中的最外层数据来说,它在父组件对应的dep中只会添加父组件的渲染Watcher,所以当数据在父组件中进行修改时,最开始只会将父组件的渲染Watcher添加到更新列表queue中,而不会添加子组件的渲染Watcher,然后在下一帧执行flushSchedulerQueue方法,执行父组件的重新渲染。在执行patch的过程中,会进行对比更新的逻辑,当开始对比两个子组件的占位符VNode时,由于是相同的组件VNode,所以同样会调用patchVnode方法,在该方法中,会执行组件的prepatch钩子函数,更新子组件,代码如下所示:

/* core/vdom/create-component.js */
prepatch(oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
  const options = vnode.componentOptions
  const child = vnode.componentInstance = oldVnode.componentInstance
  updateChildComponent(
    child,
    options.propsData, // updated props
    options.listeners, // updated listeners
    vnode, // new parent vnode
    options.children // new children
  )
}

可以看到,在prepatch钩子函数中,这里的options.propsData就是父组件更新后提取出来的props数据,那继续来看updateChildComponent方法,代码如下所示:

/* core/instance/lifecycle.js */
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[key] = validateProp(key, propOptions, propsData, vm)
    }
    toggleObserving(true)
    // keep a copy of raw propsData
    vm.$options.propsData = propsData
  }
  // ...
}

可以看到,在updateChildComponent方法中,通过遍历在initProps方法中绑定的属性名_propKeys,来处理数据,还是同样调用validateProp方法检测并取得最新的数值,由于在组件初始化时,props中的数据已经定义了响应式,所以这次重新赋值,就会触发该数据对应的set访问器,如果检测到数据发生变化,就会将子组件的渲染Watcher动态的添加到更新列表queue中,当父组件完成更新后,就会触发子组件的重新渲染,最终,父子组件就都得到了更新。

总结

Vue会根据props选项,在创建父占位符节点的时候构建propsData,然后在实例化子组件时,将propsData进行响应式处理,当数据在父组件中进行更新时,会在对比新旧组件VNode的过程中,调用prepatch钩子函数,对子组件中的数据进行更新,从而触发子组件的重新渲染。