前言
传递插槽内容的方式
- 模板插槽
直接往组件标签里传递内容,会经由complie
模块编译,vue会给添加一个内部属性_
。这是一个内部属性,指这是经由编译器生成的插槽对象,使用保留属性而不是vnode patchFlag
,因为在手动渲染函数中可以直接传递给子组件,并且优化提示需要保留在插槽对象本身。
- 函数插槽
这是手动渲染函数,用户可以手动写一个插槽对象,可以直接传递子组件,给但是需要注意key
和value
的对应关系,这是因为具名插槽有对应关系,如果key
和value
不对应,会导致在子组件中渲染出错。vue在规范化children
(这里的children
是插槽对象)的时候会给其添加一个上下文实例_ctx
,(在已编译或已规范化的children
具有上下文实例)。
- 函数插槽(
vnode
数组)
第三种方式就比较少用了,vue中也不推荐这样使用,这里就简单的提一下。这种方式是在函数插槽的基础做了一些改变,children
由一个插槽函数对象变成了vnode
数组
用户自己编写的渲染函数,但是传递的不是插槽函数对象,而是一个vnode
数组,这种方式也是可以通过的,但是vue不推荐这种写法,会提示用户去使用插槽函数对象,
工具函数
检查插槽函数执行完返回的vnode
数组中,返回其中正确的vnode,
插槽的标识
简单说明一下SlotFlags
,在什么情况下会是稳定的Fragment
,什么情况下不是稳定的Fragment
。
第一种情况是,如果模板插槽中传递的内容是<slot>
,这就是FORWARDED
。意思是正在转发<slot>
到子组件中。父项是否需要更新子项取决于父项本身收到的插槽类型。必须在运行时(在“normalizeChildren”中)创建子节点时对其进行优化,如下例子
<Comp>
<slot></slot>
</Comp>
第二种情况是插槽引用范围变量(v-for或外部插槽属性)或具有条件结构(v-if、v-for)的插槽。父级需要强制子级更新,因为插槽没有完全捕获其依赖项。这就是DYMAMIC
,如下例子:
<Comp>
<template v-if="counter===1" v-slot:default="slotProps"></template>
</Comp>
第三种情况是稳定的Fragment
,STABLE
,仅引用插槽道具或上下文状态的稳定插槽。插槽可以完全捕获自己的依赖项,因此在传递时,父级不需要强制子级进行更新。如下例子:
const App = {
template: `
<Comp>
<p>{{counter}}</p>
<button @click="increment"></button>
</Comp>
`,
setup() {
const counter = ref(0)
function increment() {
counter.value++
}
return { counter, increment }
},
components: {
Comp
}
}
插槽内容数据的格式
插槽内容数据除了指定的插槽函数,还有三个保留属性,$stable
、_ctx
、_
,这里简单的介绍一下。
$stable
:手写渲染函数时,跳过强制子级更新。_ctx
:渲染上下文,也就是当前组件实例_
:SlotsFlags
标识符,优化插槽的更新,这也是区分是不是使用了模板形式的插槽
初始化插槽
进入initProps
,第一个区分要点就是是不是SLOTS_CHILDREN
,不是SLOTS_CHILDREN
是指用户直接指定了vnode
数组,插槽的vnode
数组应该是由函数执行返回,为了确保规范会走normalizeVNodeSlots
对用户指定的vnode
数组进行规范化。
type
是来自children
中的_
属性,这是代表用户是使用内置组件<slot>
,仅由complie
模块编译过的,并且是已经是符合标准,可以直接放入组件实例上的slots
属性中,还有一种情况就是用户自己写了插槽函数对象,没有经过complie
模块编译,也就不会由_
属性,也要进行规范化,走normalizeObjectSlots
。
标准化插槽
插槽内容的传递方式有很多种,需要将它们统一成一种形式方便处理。通过组件标签传递内容经过编译的不需要规范化,我们只需要规范化用户手动写的渲染函数和传递的vnode
数组。
我们一种种情况来分析,第一种先看用户手动赋予的插槽函数对象,在这种情况下会执行normalizeObjectSlots
,对用户传递的插槽对象进行统一的规范化,遍历插槽对象中的属性,首先要确保这不是vue保留属性:_
、_ctx
、$stable
之中的一个。
如果是函数,就会走normalizeSlot
标准化传递的插槽函数,内部会对用户传递的函数进行包装,这个后面再说,最后标记这是一个不是经由complie
编译的插槽。
但是有的用户就是喜欢搞骚操作,就是不传函数,传一个vnode
,使用normalizeSlotValue
规范化用户传递的vnode
,可以传递单个vnode
或者是一个vnode
数组,内部会判断,最终都是交给normalizeVNode
去规范化vnode
。最后以函数的形式放入实例上的slots
属性中。
第二种情况类似渲染函数插槽的变体,传递的不是插槽函数,是一个vnode
数组,走normalizeVNodeSlots
进行规范化,走的也是normalizeSlotValue
的流程,最后只有产生一个defulat
插槽的插槽函数。
包装插槽函数
插槽函数产生时,并不是直接执行用户传递的函数,就算是经由编译产生的也会包装,交给了withCtx
函数(位置在runtime-core/src/componentRenderContext.ts
)
这个函数需要传递的一个函数(插槽函数)和渲染上下文ctx
还有一个isNonScopedSlot
用于区分是不是作用域插槽,ctx
是必须要存在的,不然开头直接结束,里面会产生一个renderFnWithContext
函数,这个函数作用的有两个,一个是在插槽函数执行时可以找到对应的渲染上下文,第二个作用是暂停块跟踪,因为当用户手动去调用编译产生的插槽函数可能回扰乱块跟踪。
后面会给renderFnWithContext
函数上几个属性,_n
是表明是否包装过了,前面的判断如果是true
就会直接退出。_c
表示已经编译过了,_d
是指默认禁用块跟踪,_ns
表示这是作用域插槽?,最后将renderFnWithContext
函数返回。
解析插槽中的内容
插槽内容的vnode
产生的地方实在渲染函数执行完成之后产生的,不是直接执行包装过后的插槽函数,而是交给一个vue私有函数执行,这个函数是renderSlot
,位置在runtime-core/src/helpers/renderSlot.ts
,假设组件如下面接受插槽内容,
<slot :attribute="'我是向外传递的内容'">我是备用内容</slot>
经过编译器编译成渲染函数就会如图所示,renderSlot
函数接受五个参数,第一个是实例上的插槽函数对象slots
,第二个是插槽的名字,第三个是插槽作用域接收的props
,第四个是插槽的后备内容渲染函数,第五个我猜是样式是否使用伪类选择器:slotted
(ps:如果说的不对,希望大佬可以在评论区说明)。
首先开头处理自定义元素,isCE
是当前实例是自定义元素实例的标识,直接返回VNode
,结束。不是自定义元素走下面的流程,取出对应名称的插槽函数赋值给slot
。
到了这个位置,说明这是基于模板调用不是用户调用,不用担心扰乱块跟踪,启用块跟踪。在vue中,插槽内容是一个块,这里打开一个块。
现在把props
传递给slot
函数执行得到vnode
(因为每一个插槽都会有一个单独的函数去产生vnode
,传递的props
只能在当前的插槽函数中使用,这就形成了一个作用,也就是作用域插槽的原理),交给ensureValidVNode
对vnode
进行检查,这当然是在slot
存在的情况下,validSlotContent
是检查之后返回的正确的vnode
,下面就是产生一个vnode block
,类型是Fragment
这里有几个地方说一下,如果props
中带有key
,会替代name
作为key
使用,如果validSlotContent
是假值(没有正确的vnode
),这个时候fallback
内容就将其替换。只要validSlotContent
是真值并且slots
上有属性_
值为SlotFlags.STABLE
就代表这是一个稳定的Fragment
,不然就是BAIL
。
接着是确认插槽的样式的作用域id,再将块跟踪禁用,返回产生的vnode block
。只有的的渲染就和普通的vnode
初始化挂载没啥区别了,依然走patch
。
插槽内容的更新
一个插槽内容的更新其实非常的简单,当数据发生变化时,只需要靠重新执行插槽函数,得到新的vnode block
,就可以交给patch
去进行DOM diff
了。
也有可能是因为父组件更新导致子组件更新,在这种情况会重新确认一次vnode
(位置在runtime-core/src/renderer.ts
中的updateComponentPreRender
函数),在这个过程中就会调用updateSlots
去更新插槽(位置在runtime-core/src/componentSlots.ts
)进行更新,这和数据改变导致的插槽更新的区别是整个插槽的vnode
都更新了,也就是插槽函数的更新,看下面的简单例子
const Comp = defineComponent({
template: `
<div>
<slot name="default">我是default的备用内容</slot>
<slot name="footer">我是footer的备用内容</slot>
</div>
`,
})
const App = {
template: `
<Comp>
<template #[slotName]>
<div>我是传递过来的{{slotName}}传递的内容</div>
</template>
</Comp>
`,
setup() {
const slotName = ref('header')
function changeSlotName() {
slotName.value = slotName.value === 'header' ? 'default' : 'header'
}
return {
slotName,
changeSlotName
}
},
components: {
Comp,
}
}
const app = createApp(App).mount('#app')
应该为父组件App
更新,但是传递子组件Comp
的插槽使用到父组件的状态slotName
,在这个例子中,slotName
作为插槽的动态插槽名称,发生改变代表插槽的渲染位置会发生变化。
updateSlots
函数接收三个参数,当前组件实例,children
在这里是新的插槽函数对象,optimized
是是否是优化模式,needDeletionCheck
是用于后面要不要删除旧的插槽函数,默认是删除的。deletionComparisonTarget
是对比的目标 也就是新的插槽函数对象。
这是属于编译插槽(模板的方式)的更新分支,分为很多种边界。
- 第一种:如果是热更新,父组件可能已经更新,强制更新插槽
- 第二种:优化且稳定不需要更新
- 第三种:已编译但是是动态的插槽,更新插槽但是跳过规范化,需要注意的是,当使用手写渲染函数渲染优化插槽时,如果有必要,我们需要删除
slots._
标记,以便以后可靠的更新,即让renderSlots
创建bailed Fragment
。
这第二种情况是用户手写渲染函数,用户自己去配置插槽函数对象,这里执行规范化函数目的是为了将新的插槽函数放入slots
中。前面说明了$stable
的作用是是否跳过更新,真值就不会删除旧的插槽函数,假值到后面就会删除旧的插槽函数。
上面的两种情况,到最后删除的目标永远是新的children
,不在新的children
之中说明这是旧的插槽函数需要删除。
这最后一种情况,是用户手写渲染函数,传递是不是插槽函数对象而是一个vnode
数组,这里执行规范化是利用了里面的将具体的值变成一个函数返回这个值的流程。所以比较目标一定是{default: 1}
。
这是这个函数的最后一个流程,删除旧的插槽函数,这里主要依靠是前面产生的needDeletionCheck
和deletionComparisonTarget
,判断条件是不是保留属性和不在比较对象中就删除。
这种更新情况的先前条件一定是父组件更新导致子组件更新,这已经不是数据更新,而是直接的vnode
更新。
渲染函数接收插槽内容的改变
在v3中,this.$slots
有了一些改变,第一张图是v2的$slots
,第二张图是v3的$slots
,可以看到两者差别很大。在v3中,在组件中获取插槽内容模板的方式也有了变化,v2只需要通过vm.$slots.default
,就可以拿到default
插槽的vnode
,v3则由属性变成了函数,需要this.$slots.default()
才会拿到default
插槽的vnode
,
这张图就是this.$slots.default()
执行的返回结果,这样看起来其实和v2获取的其实没有什么区别,这里就会由一个疑问,为什么v3要将$slots
的内容获取改成需要函数执行,我目前知道的一点就是作用于插槽,如果按照v2的写法,就无法从组件往插槽内部传递数据,v3提供了在渲染函数中给插槽传递数据方法。看如下例子:
这个例子的先前条件是使用h
函数渲染结构,App
组件和Comp
组件是父子组件关系,在渲染函数中传递插槽内容,需要在函数h
的第三个函数中传递,此时第三个参数不在是数组而是对象,对应着具名插槽。
上述例子中我指定了default
插槽的内容,是一个函数返回vnode
,在Comp
组件中就可以使用this.$slots.default()
执行后拿到返回的vnode
。如果我在执行时传递一个参数,那么在App
组件中的插槽函数中,我就可以在插槽函数中拿到在Comp
组件中传递的数据,可以设置多个形参接受、解构参数拿到其中一个,也可以使用剩余运算符拿到所有的,并且这也形成了一个闭包。这样我们就可以手写渲染函数时,对数据进行拦截,然后自行决定如何渲染。
总结
插槽的初始化和更新分析完毕,在这过程中,知道了vue是如何将插槽内容渲染到指定位置,在有多个插槽的情况,如何按照名字找到渲染位置,如何处理用户传递的内容,作用域插槽的数据是如何传递和使用,在开发中可以更好的使用插槽。
如果有缺少或者是有误的,欢迎大家在评论区指导和纠正,谢谢大家