Vue响应式之props
# 前言
props 与 data 的响应式处理类似,源码分析主要分析与 data 处理的不同点,最后指出一些处理细节
在分析之前,我们先提出几个问题:
- props 支持多种类型,是在哪进行参数校验的
- Vue 实例和 Vue 组件在 prop 上的处理有哪些不同
- 内部对 prop 的修改会影响到外部么
- 同一组件多次实例化, prop 属性会进行多次响应式设置么
# 源码分析
首先是 props 参数校验,传入的 props 选项经过转换被挂载到 vm.$options
上
无论是实例还是组件,最后都是调用的该方法
// src\core\util\options.js
/**
* 确保所有的 props 选项被转换为基于对象的格式
*/
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)
// 默认类型为 null
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)
// 可以看到,这里不会去判断 val 是否合法
// val 非纯对象时会被设置为类型
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
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
对于 Vue 实例来说,该方法在执行 _init
时 mergeOptions 方法中调用,props 最终挂载在 vm.$options
上
对于 Vue 组件,也是在实例化的时候处理的么?
试想一下,组件一旦定义完成, props 应该是不会再变了,不随实例化处理,所以最好应该定义在定义构造函数时就处理好,并把 props 挂载在原型
我们验证一下,找到全局组件的构造函数(根据 1.Vue 实例化过程)
// src\core\global-api\extend.js
Vue.extend = function (extendOptions: Object): Function {
const Super = this
// VueComponent 继承 Vue
const Sub = function VueComponent (options) {
this._init(options)
}
Sub.prototype = Object.create(Super.prototype)
Sub.prototype.constructor = Sub
// 合并参数
Sub.options = mergeOptions(
Super.options,
extendOptions
)
Sub['super'] = Super
// 一些优化,可以跳过
// 对于 props 和 computed 属性,在拓展的原型上定义 proxy getters
// 避免为每个创建的实例调用 Object.defineProperty
if (Sub.options.props) {
initProps(Sub)
}
if (Sub.options.computed) {
initComputed(Sub)
}
// Sub 静态方法复用 Vue 的
Sub.extend = Super.extend
// ...
return Sub
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
可以看到,当我们进行组件定义的时候,如
Vue.component('select2', {
props: ['options', 'value'],
template: '#select2-template',
})
2
3
4
会调用 mergeOptions 方法进行 options 合并和设置
Sub.options = mergeOptions(
Super.options,
extendOptions
)
2
3
4
所以 props 会被挂载到 VueComponent 原型的 options 上
因此第一个问题 -- props 支持多种类型,是在哪进行参数校验的? -- 我们解答了一半,props 定义会进行转换,其在 normalizeProps 中执行,我们稍后再看下传入 props 时会进行怎样的校验。 ``
同时我们也初步解决了问题2和问题4,对于 Vue 组件,我们将 props 定义在原型上,可以避免每个实例调用 Object.defineProperty
进行响应式设置,具体的稍后再分析。
无论 Vue 还是 Vue 组件,实例化的时候都会执行 this._init(options)
对于 Vue , options 值由我们显式传入,
对于 Vue 组件, options 值由解析模块解析后生成
// src\core\vdom\create-component.js
export function createComponentInstanceForVnode (
vnode: any, // we know it's MountedComponentVNode but flow doesn't
parent: any, // activeInstance in lifecycle state
): Component {
const options: InternalComponentOptions = {
_isComponent: true,
_parentVnode: vnode,
parent
}
// check inline-template render functions
const inlineTemplate = vnode.data.inlineTemplate
if (isDef(inlineTemplate)) {
options.render = inlineTemplate.render
options.staticRenderFns = inlineTemplate.staticRenderFns
}
return new vnode.componentOptions.Ctor(options)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
也就是说,参数有
parent: {...}
_isComponent: true
_parentVnode: {...}
2
3
不进行展开分析,只知道 Vue 组件实例的 $options 在 initInternalComponent 方法中设置
export function initInternalComponent (vm: Component, options: InternalComponentOptions) {
const opts = vm.$options = Object.create(vm.constructor.options)
// doing this because it's faster than dynamic enumeration.
const parentVnode = options._parentVnode
opts.parent = options.parent
opts._parentVnode = parentVnode
const vnodeComponentOptions = parentVnode.componentOptions
opts.propsData = vnodeComponentOptions.propsData
opts._parentListeners = vnodeComponentOptions.listeners
opts._renderChildren = vnodeComponentOptions.children
opts._componentTag = vnodeComponentOptions.tag
if (options.render) {
opts.render = options.render
opts.staticRenderFns = options.staticRenderFns
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
两者都会调用 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)
// ... 省略开发模式的一些处理
defineReactive(props, key, value)
// 对 vm[key] 的访问和修改是作用到 vm['_props'][key] 上
if (!(key in vm)) {
proxy(vm, `_props`, key)
}
}
toggleObserving(true)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
propsData 为该实例 props 的值, 由 options 选项传入(Vue 实例) 或组件实例化时父组件传入
isRoot 用来判断是 Vue 实例还是 Vue 组件
若为 Vue 组件,会执行 toggleObserving(false)
,作用是 observe(value)
不进行处理
继续往下分析, 对 propsOptions( props 定义,其值为 $options.props
) 进行处理
validateProp 会在没有传入值的情况下返回默认值
export function validateProp (
// prop 属性
key: string,
// props 类型定义和其他选项
propOptions: Object,
// prop 值
propsData: Object,
vm?: Component
): any {
// prop 定义
const prop = propOptions[key]
// 判断 propsData 是否传入 key 相应的值
const absent = !hasOwn(propsData, key)
let value = propsData[key]
const booleanIndex = getTypeIndex(Boolean, prop.type)
// prop 定义的类型中有 Boolean 类型
if (booleanIndex > -1) {
// propOptions 未传入且未定义默认值,取值为 false
if (absent && !hasOwn(prop, 'default')) {
value = false
// propOptions 传入了空字符串或 value 为 key 的连字格式 a-b-c === ABC
} else if (value === '' || value === hyphenate(key)) {
// 如果布尔值类型优先级更高(索引更小),则转为布尔值
const stringIndex = getTypeIndex(String, prop.type)
if (stringIndex < 0 || booleanIndex < stringIndex) {
value = true
}
}
}
// 若 propsData[key] 值未定义且不支持布尔类型
if (value === undefined) {
// 设置为 default 属性对应的值,
// 对于 default 值为方法的会进行调用并返回值
value = getPropDefaultValue(vm, prop, key)
// 因为默认值每次都是重新拷贝的,需要进行 observe 处理
const prevShouldObserve = shouldObserve
toggleObserving(true)
observe(value)
toggleObserving(prevShouldObserve)
}
// 开发模式下会通过 type 和 validator 校验 props
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
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
注意几点:
- 迭代是根据定义来的,也就是说, 传入了 props 未定义的元素,是不会进行处理的
- 设置值:propsData 没传入的且定义支持 Boolean 类型且没有定义 default 会设置值为 false ; propsData 传入了某种字符串且定义支持 Boolean 类型且 Boolean 类型优先级更高会设置值为 true ; propsData 没传入的其他情况会使用 default 参数设置值
- 通过 default 设置的值 value 为对象时,执行 observe(value)
- value 存在值时,不会进行 observe
- 通过 assertProp 校验 props
- 连字格式的特殊处理,暂时不知道为什么会有这个处理,这里 mark 一下
props: {
'a-b-c':{
type:[Boolean]
}
},
propsData:{
'a-b-c': 111
},
// 'a-b-c' 属性变转为驼峰式 aBC, $props['aBC'] = false
2
3
4
5
6
7
8
9
其中第5点回答了问题1:在开发模式下通过定义的 type 和 validator 进行 prop 校验,但是生产模式不会进行处理
回到 initProps , 继续执行 defineReactive(props, key, value)
, 对 prop 进行响应式处理
以下面这个为例子分析不同情况的处理:
props: {
info:{
default:function(){
return { name: "gahing" }
}
},
user:null,
}
propsData: {
user:{
name: "test"
}
}
2
3
4
5
6
7
8
9
10
11
12
13
info 值通过 default 生成,于是会执行 observe({name:'gahing'})
;
之后调用 defineReactive(props, 'info', { name: "gahing" })
定义了 info 的取值赋值,并挂载到 props 上;
childOb = observe({ name: "gahing" })
, 由于前面对该 val 进行响应式设置,因此这里返回的是 val.__ob__
结论:通过 default 生成的对象,会进行深度的响应式处理
user 值由 propsData 传入,validateProp 中不会执行 observe
之后调用 defineReactive(props, 'user', { name: "test" })
定义了 user 的取值赋值,并挂载到 props 上;
childOb = observe({ name: "test" })
, 对于 vue 组件,由于 shouldObserve = false
因此不会进行 childOb 的处理: childOb == undefined ,对于 vue 实例,则会进行深度的响应式处理
这其中的奥妙在于组件的 props 依赖于外部元素传入,而外部元素已经进行深度的响应式处理了
当 prop 选项子孙元素值变更,会通知所有用到该 prop 子元素的观察者进行更新
当 prop 选项值变更(包括使用默认值到赋值),会通过 defineReactive 执行时定义的闭包 dep 去通知观察者更新
于是,这也就回答问题4 -- 同一组件多次实例化, prop 属性会进行多次响应式设置么
答案是不一定,通过默认值生成的,会进行响应式设置,其他通过外部传入值的,不会进行设置,依赖于该值一开始的设置
那么 props 的执行过程大概就这样,我们最后分析下这个问题:内部对 prop 的修改会影响到外部么?
这个问题我们应该经常遇到过,文档告诉我们在组件中 prop 只能用来取值,不能进行赋值,那源码中是如何处理的呢?
还记得响应式设置的时候有个 customSetter 参数么?
// src\core\observer\index.js
export function defineReactive (
obj: Object,
key: string,
val: any,
customSetter?: ?Function,
shallow?: boolean
) {
// ... 省略部分代码
Object.defineProperty(obj, key, {
// ... 省略部分代码
set: function reactiveSetter (newVal) {
const value = getter ? getter.call(obj) : val
// ... 省略部分代码
if (process.env.NODE_ENV !== 'production' && customSetter) {
customSetter()
}
if (getter && !setter) return
if (setter) {
setter.call(obj, newVal)
} else {
val = newVal
}
childOb = !shallow && observe(newVal)
dep.notify()
}
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
开发模式时会调用 customSetter 方法
这里说个小技巧,怎么确定是 customSetter 进行处理的呢?开发模式下对 props 进行赋值,看控制台报错信息的执行路径就知道了
想起来刚刚分析 initProps 被我忽略的一个代码处理
// src\core\instance\state.js - initProps
if (process.env.NODE_ENV !== 'production') {
// 不能定义为 key,ref,slot,slot-scope,is
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)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
可以看到,在开发模式下, !isRoot && !isUpdatingChildComponent
的条件下会进行警告:避免修改 props 的值防止父组件重渲染
Vue 实例上的 props 修改不会有提示
isUpdatingChildComponent 在更新子组件时也不会提示,暂未知道其中原因
不过,不管什么模式什么情况下,值的修改都是会成功的
# 总结
我们在分析源码的时候已经对几个问题进行了解答,其中第二点关于 Vue 实例和 Vue 组件在 props 上处理的不同,也说出了几点,但还不够完全,主要是关于 Vue 组件化这块还不是特别清晰,以及组件在参数合并下有什么性能优化的地方,我们将在之后的 Vue 组件化文章中进行补充
最后,在抛一个问题,在上面的分析中, isRoot 我们说是 Vue 实例,反之为 Vue 组件,这个说法正确么?