likes
comments
collection
share

响应式原理七:props

作者站长头像
站长
· 阅读数 45

props 作为 Vue 核心特性之一,也是父子组件通信的一种方式,在 Vue 项目中经常使用。那么,它的内部是如何实现的呢?这将是本文即将要探究的话题。

规范化

props 对外提供的接口有两种方式,一是使用数组,数组元素只能是字符串,即字符串数组;二是对象。因此,在初始化 props 之前,需要对其进行规范化,作用是将用户传入的 props 规范为对象格式。

在初始化 Vue 实例时,对 options 进行合并配置处理,包括相应特性的规范化处理,比如 props,其核心逻辑实现在函数 mergeOptions,具体实现如下:

// src/core/util/options.js

/**
 * Merge two option objects into a new one.
 * Core utility used in both instantiation and inheritance.
 */
export function mergeOptions (
  parent: Object,
  child: Object,
  vm?: Component
): Object {
  ...
  normalizeProps(child, vm)
  ...
  
  return options
}

从代码实现中可看出,normalizeProps 实现了 props 的规范化处理,具体实现如下:

// src/core/util/options.js

/**
 * Ensure all props option syntax are normalized into the
 * Object-based format.
 */
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
}

函数接收两个参数:

  • options:Vue 实例组合项对象,即 options,数据类型为对象;
  • vm:Vue 实例。

作用是对 props 进行规范化,将用户传入的 props 规范为对象格式,以此符合 Vue 框架格式。

从代码实现可看出,props 接收两种数据格式:字符串数组和对象;如果 props 既不是数组,又不是对象,则会在开发环境下抛出告警。

如果 props 是数组,且数组每一个元素只能是字符串,表示 propkey 。遍历数组 props,对其 key 转换成驼峰化格式,同时设置 typenull,举例如下:

props: ['message']

// 规范化
props: {
  message: {
    type: null
  }
}

如果 props 是对象,对其进行遍历,通过 key 获取对应 prop 的值 val,同时把 key 转换为驼峰化格式。接着对 val 数据类型进行判断,如果是对象,则直接赋值;否则将其设置为属性 type 的值,即 { type: val },举例如下:

props: {
  message: String,
  user: {
    type: String,
    default: ''
  }
}

// 规范化
props: {
  message: {
    type: String
  },
  user: {
    type: String,
    default: ''
  }
}

上面两次提到驼峰化处理,那么它又是如何实现的呢?

// src/shared/util.js

/**
 * Create a cached version of a pure function.
 */
export function cached<F: Function> (fn: F): F {
  const cache = Object.create(null)
  return (function cachedFn (str: string) {
    const hit = cache[str]
    return hit || (cache[str] = fn(str))
  }: any)
}

/**
 * Camelize a hyphen-delimited string.
 */
const camelizeRE = /-(\w)/g
export const camelize = cached((str: string): string => {
  return str.replace(camelizeRE, (_, c) => c ? c.toUpperCase() : '')
})

从代码实现中可看出,Vue 框架对 props 驼峰化处理做了缓存,即使用变量 cache 来保存,如果存在的话,则直接从 cache 获取且返回;否则调用传入的回调函数进行处理,并将其值缓存,再返回。

初始化

在初始化 Vue 实例时,函数 initStateprops 做了初始化操作,具体实现如下:

// src/core/instance/state.js

export function initState (vm: Component) {
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  ...
}

接着来看函数 initProps 具体是如何实现的?

// src/core/instance/state.js

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

函数接收 2 个参数:

  • vm:Vue 组件实例;
  • propsOptions:规范化后的 props

函数主要做了三件事:

  • 校验 props 对象每个 prop
  • prop 转换为响应式对象;
  • prop 代理到 vm 实例。

在分析这三部分逻辑前,先来说明变量定义的作用。

  • propsData:赋值为 vm.$options.propsData || {},作用是父组件传入 props 值;
  • props:赋值为 vm._props = {},作用是代理 propsvm 实例上;
  • keys:赋值为 vm.$options._propKeys = [],作用是将 props key 缓存到数组,以便将来更新时能直接从数组获取,而无需再次遍历对象 props

整体上来看,对 props 初始化的逻辑不是很复杂,即遍历规范化后 props,然后对每个 prop 做三件事:校验、响应式和代理。那么,接下来我们就详细来分析它们。

校验

遍历 propsOptions ,将 key 保存到数组 keys,调用函数 validateProp 对其进行校验,具体实现如下:

// src/core/util/props.js
export function validateProp (
  key: string,
  propOptions: Object,
  propsData: Object,
  vm?: Component
): any {
  const prop = propOptions[key]
  const absent = !hasOwn(propsData, key)
  let value = propsData[key]
  // boolean casting
  const booleanIndex = getTypeIndex(Boolean, prop.type)
  if (booleanIndex > -1) {
    if (absent && !hasOwn(prop, 'default')) {
      value = false
    } else if (value === '' || value === hyphenate(key)) {
      // only cast empty string / same name to boolean if
      // boolean has higher priority
      const stringIndex = getTypeIndex(String, prop.type)
      if (stringIndex < 0 || booleanIndex < stringIndex) {
        value = true
      }
    }
  }
  // check default value
  if (value === undefined) {
    value = getPropDefaultValue(vm, prop, key)
    // since the default value is a fresh copy,
    // make sure to observe it.
    const prevShouldObserve = shouldObserve
    toggleObserving(true)
    observe(value)
    toggleObserving(prevShouldObserve)
  }
  if (
    process.env.NODE_ENV !== 'production' &&
    // skip validation for weex recycle-list child component props
    !(__WEEX__ && isObject(value) && ('@binding' in value))
  ) {
    assertProp(prop, key, value, vm, absent)
  }
  return value
}

函数接收 4 个参数:

  • keyprop 对应的键名 key
  • propOptions:规范后生成的 props 对象;
  • propsData:父组件传递 props 数据;
  • vm:Vue 实例。

同样的,validateProp 函数主要也做了三件事:处理 Boolean 类型数据、处理默认值数据和 prop 断言。

在做这三件事之前,先做了一些前期工作。通过 keypropOtions 获取 prop,比如:

// 规范化
props: {
  message: {
    type: String
  },
  user: {
    type: String,
    default: ''
  }
}

// 通过 key 获取 prop
props['message'] = {
  type: String
}

接着判断 prop 是否缺省,即父组件是否有对 prop 传值,即 const absent = !hasOwn(propsData, key),调用函数 hasOwn 判断 key 是否在对象 propsData 上,如果为 false,表示缺省;否则不缺省,具体实现如下:

// src/core/shared/util.js
/**
 * Check whether an object has the property.
 */
const hasOwnProperty = Object.prototype.hasOwnProperty
export function hasOwn (obj: Object | Array<*>, key: string): boolean {
  return hasOwnProperty.call(obj, key)
}

最后获取父组件传入 prop 对应的值,即 let value = propsData[key]

准备工作做好了,接着来分析下校验阶段所做的三件事。

处理 Boolean 数据类型

调用函数 getTypeIndex 判断 prop.type 是否为 Boolean 类型,如果满足的话,则返回匹配的索引;否则返回 -1。具体实现如下:

// src/core/util/props.js

function getTypeIndex (type, expectedTypes): number {
  if (!Array.isArray(expectedTypes)) {
    return isSameType(expectedTypes, type) ? 0 : -1
  }
  for (let i = 0, len = expectedTypes.length; i < len; i++) {
    if (isSameType(expectedTypes[i], type)) {
      return i
    }
  }
  return -1
}

由于 prop 类型定义时可以是单个原生构造函数,也可以是原生构造函数的数组,举例如下:

export default {
  props: {
    message: {
      type: String
    },
    value: {
      type: [Stirng, Boolean]
    }
  }
}

于是,如果 expectedTypes 是单个原生构造函数,则调用函数 isSameType 判断其与 type 是否为同一个数据类型,返回其索引;如果 expectedTypes 是原生构造函数数组,则对其进行遍历,同样调用函数 isSameType 判断每个数组元素与 type 是否为同一个数据类型,并且返回其索引。isSameType 具体实现如下:

// src/core/util/props.js

const functionTypeCheckRE = /^\s*function (\w+)/

/**
 * Use function string name to check built-in types,
 * because a simple equality check will fail when running
 * across different vms / iframes.
 */
function getType (fn) {
  const match = fn && fn.toString().match(functionTypeCheckRE)
  return match ? match[1] : ''
}

function isSameType (a, b) {
  return getType(a) === getType(b)
}

通过一个具体的例子来说明类型判断:

props['message'] = {
  type: String
}

props['message'].toString()    // 'function String() { [native code] }'

props['message'].toString().match(functionTypeCheckRE)    =>    {
  0: "function String"
  1: "String"
  groups: undefined
  index: 0
  input: "function String() { [native code] }"
}

// 最终返回结果:String

通过函数 getTypeIndex 获取索引 booleanIndex,如果 booleanIndex > -1,则说明 prop.typeBoolean 类型,那么继续执行后续逻辑。

如果父组件没有传递 prop 数据且没有设置默认值 default 的情况下,则将 prop 的值 value 设置为 false,此判断是通过 absent && !hasOwn(prop, 'default') 实现。

如果 value === '' 或者 value === hyphenate(key)value 与转换后 key 相等,那么 getTypeIndex 获取 String 类型索引,如果 stringIndex < -1 或者 booleanIndex < stringIndex ,则将 prop 的值设置为 true,需要注意的是 Boolean 优先级比 String 高,例子如下:

定义子组件 B 如下:

export default {
  message: String,
  userName: [Boolean, String]
}

然后在父组件引用子组件 B,两种方式分别如下:

<!-- value === '' -->
<template>
  <b message="Hello JavaScript" user-name></b>
</template>

<!-- value ===  hyphenate(key)-->
<template>
  <b message="Hello JavaScript" user-name="user-name"></b>
</template>

这个例子中符合上述条件,最终 userName 值为 true

响应式

通过函数 validateProp 校验 prop 有效性后,则会将 prop 转换为响应式对象。从代码实现可以看出,最终是调用函数 defineReactiveprop 转换为响应式对象,至于其逻辑实现,可参考《响应式原理一:data 初始化》。

不过需要注意的是:在开发环境下,会校验 prop 键名 key 是否为 HTML 保留属性,如果是的话,则会抛出告警。

代理

在初始化 props 最后一步,则是将 props 代理到 vm._props ,即调用函数 proxy 实现代理,具体实现如下:

// src/core/instance/state.js
const sharedPropertyDefinition = {
  enumerable: true,
  configurable: true,
  get: noop,
  set: noop
}

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

例子如下:

export default {
  props: {
    message: {
      type: String,
    }
  }
}

当我们访问 prop message 时,一般是通过 this.message,那么,在其内部实际的访问时 this._props_message。需要注意的是这里只是代理根实例 props,对于非根实例的子组件,props 代理是发生在 Vue.extend,即在执行 render 函数创建子组件时,具体实现如下:

// src/core/global-api/extend.js
/**
  * Class inheritance
*/
Vue.extend = function (extendOptions: Object): Function {
  // For props and computed properties, we define the proxy getters on
  // the Vue instances at extension time, on the extended prototype. This
  // avoids Object.defineProperty calls for each instance created.
  ...
  if (Sub.options.props) {
    initProps(Sub)
  }
  ...
  return Sub
}

接着看下 initProps 具体实现:

// src/core/global-api/extend.js
function initProps (Comp) {
  const props = Comp.options.props
  for (const key in props) {
    proxy(Comp.prototype, `_props`, key)
  }
}

可见,最终还是调用函数 proxyprops 代理到 vm._props`。

参考链接

转载自:https://juejin.cn/post/7080529193439592485
评论
请登录