在虚拟DOM层面,组件的props与普通HTML标签的属性差别不大。假设我们有如下模板:
<MyComponenent title="A Big Title" :other="val" />
这段模板对应的虚拟DOM是:
const vnode = {
type: MyComponent,
props:{
title: 'A big Title',
other: this.val
}
}
可以看到,模板与虚拟DOM几乎是“同构”的。另外,在编写组件时,需要显式地指定组件会接收那些props数据,如下面代码所示:
const MyComponent = {
name: 'MyComponent',
// 组件接收名为title的props,并且该props的类型为String
props:{
tite: String
},
render(){
return{
type: 'div',
children: `count is: ${this.title}` // 访问props数据
}
}
}
所以,对于一个组件,有两部分关于props的内容需要关心的是:
- 为组件传递的props数据,即组件的vnode.props对象
- 组件选项对象中定义的props选项,即MyComponent.props对象
结合两个选项来解析出组件在渲染时需要用到的props数据,具体实现如下:
function mountComponent(vnode, container, anchor){
const componentOptions = vnode.type
// 从组建选项对象中取出props定义,即propsOption
const {render, data, props: propsOption /* 其他省略 */ } = componentOptions
beforeCreate && beforeCreate()
const state = reactive(data())
// 调用resolveProps函数解析出最终的props数据与attrs数据
const [props, attrs] = resolveProps(propsOption, vnode.props)
const instance = {
state,
// 将解析出的props数据包装为shallowReactive并定义到组件实例上
props: shallowReacive(props),
isMounted: false,
subTree: null
}
vnode.comoponent = instance
// 省略部分代码
}
// resolveProps 函数用于解析组件props和attrs数据
function resolveProps(options, propsData){
const props = {}
const attrs = {}
// 遍历为组件传递的props数据
for (const key in propsData){
if(key in options){
// 如果为组件传递的props数据在组件自身的props选项中有定义,则将其视为合法的props
props[key] = propsData[key]
}else{
// 否则将其作为attrs
attrs[key] = propsData[key]
}
}
return [props,attrs]
}
我们将组件选项中定义的MyComponent.props对象和为组件传递的vnode.props对象想结合,最终解析出组件在渲染时需要使用的props和attrs数据。
这里需要注意两点:
- 在Vue.js3中,没有定义在MyComponent.props选项中的props数据将存储到attrs对象中
- 上述实现中没有包含默认值、类型校验等内容的处理。当然这些实现起来也不难
处理完props数据后,再来看关于props数据变化的问题。props本质上是父组件的数据,当props发生变化时,会触发父组件重新渲染,假设父组件的模板如下:
<template>
<MyComponent :title="title" />
</template>
其中,响应式数据title的初始值为字符串"A big title",因此首次渲染时,父组件的虚拟DOM为:
const vnode = {
type: MyComponent,
props: {
title: 'A big title'
}
}
当响应式数据title发生变化时,父组件的渲染函数会重新执行,产生的新虚拟DOM如下:
const vnode = {
type: MyComponent,
props: {
title: 'A small title'
}
}
接着,父组件会进行自更新。在更新过程中,渲染器发现父组件的subTree包含组件类型的虚拟节点,所以会调用patchComponent函数完成子组件的更新,如下patch函数的代码所示:
function patch(n1,n2,container,anchor){
if(n1 & n1.type !== n2.type){
unmount(n1)
n1 = null
}
const { type } = n2
if(typeof type === 'string'){
// 省略部分代码
}else if(type === Text){
// 省略部分代码
}else if(type === Fragment){
// 省略部分代码
}else if(typeof type === 'object'){
// vnode.type 的值是选项对象,作为组件来处理
if(!n1){
mountComponent(n2,container, anchor)
}else{
// 更新组件
patchComponent(n1,n2,anchor)
}
}
}
我们由父组件自更新所引起的子组件更新叫做子组件的被动更新。
当子组件发生被动更新时,需要做的是:
- 检测子组件是否真的需要更新,因为子组件的props可能不变
- 如果需要更新,则更新子组件的props,slots等内容
patchComponent函数的具体实现如下:
function patchComponent(n1,n2,anchor){
// 获取组件实例,即n1.component,同时让新的组件虚拟节点 n2.component也指向组件实例
// 将组件实例添加到新的组件vnode对象上,即n2.component = n1.component
const instance = (n2.component = n1.component)
// 获取当前的props数据
const {props} = instance
// 调用hasPropsChanged检测为子组件传递的props是否发生变化,如果没有变化,则不需要更新
if(hasPropsChanged(n1.props, n2.props)){
// 调用 resolveProps函数重新获取props数据
const {nextProps} = resolveProps(n2.type.props, n2.props)
// 更新props
for(const k in nextProps){
props[k] = nextProps[k]
}
// 删除不存在的props
for(const k in props){
if(!(k in nextProps)) delete props[k]
}
}
}
function hasPropsChanged(prevProps,nextProps){
const nextKeys = Object.keys(nextProps)
// 如果旧props的数量变了,则说明有变化
if(nextKeys.length !== Object.keys(prevProps).length){
return true
}
for(let i=0;i<nextKeys.length;i++){
const key = nextKeys[i]
// 有不相等的props, 则说明有变化
if(nextProps[key] !== prevProps[key]) return true
}
return false
}
要注意的是
- 需要将组件实例添加到新的组件vnode对象上,即n2.component = n1.component,否则下次更新时将无法取得组件实例
- instance.props对象本身是浅响应的(即shallowReactive)。因此,在更新组件的props时,只需要设置instance.props对象下的属性值即可触发组件重新渲染。
上面的实现中,没有处理attrs与slots的更新,attrs的更新本质上与更新props的原理相似。本质上,原理都是根据组件的props选项定义以及为组件传递的props数据来处理。
由于props数据与组件自身的状态数据都需要暴露到渲染函数中,并使得渲染函数能够通过this访问它们,因此需要封装一个渲染上下文对象,如下面代码所示:
function mountComponent(vnode, container, anchor){
// 省略部分代码
const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null
}
vnode.component = instance
// 创建渲染上下文对象,本质上是组件实例的代理
const renderContext = new Proxy(instance, {
get(t,k,r){
// 取得组件自身状态与props数据
const {state, props } = t
// 先尝试读取自身状态数据
if(state && k in state){
return state[k]
}else if(k in props){ // 如果组件自身没有改数据,则尝试从props中读取
return props[k]
}else{
console.error('不存在')
}
},
set(t,k,v,r){
const {state, props} = t
if(state && k in state){
state[k] = v
}else if(k in props){
props[k] = v
}else{
console.error('不存在')
}
}
})
// 生命周期函数调用时要绑定渲染上下文对象
created && created.call(renderContext)
//省略部分代码
}
上面这段代码,为组件实例创建了一个代理对象,该对象即渲染上下文对象。其作用是拦截数据状态的读取和设置操作,每当在渲染函数或生命周期钩子中通过this读取数据时,都会优先从组件的自身状态中读取,如果组件本身并没有对应的数据,则再从props数据中读取。最后将渲染上下文作为渲染函数以及生命周期钩子的this值即可。
补充知识
对象的解构赋值
如果变量名与属性名不一致,必须写成下面这样。
let { foo: baz } = { foo: 'aaa', bar: 'bbb' };
baz // "aaa"