响应式原理七:props
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 是数组,且数组每一个元素只能是字符串,表示 prop 的 key 。遍历数组 props,对其 key 转换成驼峰化格式,同时设置 type 为 null,举例如下:
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 实例时,函数 initState 对 props 做了初始化操作,具体实现如下:
// 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 = {},作用是代理props到vm实例上;keys:赋值为vm.$options._propKeys = [],作用是将propskey缓存到数组,以便将来更新时能直接从数组获取,而无需再次遍历对象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 个参数:
key:prop对应的键名key;propOptions:规范后生成的props对象;propsData:父组件传递props数据;vm:Vue 实例。
同样的,validateProp 函数主要也做了三件事:处理 Boolean 类型数据、处理默认值数据和 prop 断言。
在做这三件事之前,先做了一些前期工作。通过 key 从 propOtions 获取 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.type 是 Boolean 类型,那么继续执行后续逻辑。
如果父组件没有传递 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 转换为响应式对象。从代码实现可以看出,最终是调用函数 defineReactive 将 prop 转换为响应式对象,至于其逻辑实现,可参考《响应式原理一: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)
}
}
可见,最终还是调用函数 proxy 将 props 代理到 vm._props`。
参考链接
转载自:https://juejin.cn/post/7080529193439592485