一、背景
slot-插槽,在vue官方文档中定义为作为承载内容分发的出口,给组件的设计和使用提供了极大的灵活性,调用方可以根据自己的需要向插槽中填充自己想要的内容,这无疑提升了组件的适用性和扩展性。
slot插槽按照使用方式可分为三种:默认插槽、具名插槽、作用域插槽;我们通过源码来一一分析这三种插槽的原理。
二、默认插槽
2.1:使用示例
默认插槽是slot最简单的使用方式,看一下使用示例:
// App.vue
<template>
<div id="app">
<HelloWorld >
<span>Show Slot</span>
<span>Show something else</span>
</HelloWorld>
</div>
</template>
// HelloWorld.vue
<template>
<div class="hello">
<p>Something before</p>
// 默认插槽位置
<slot>
<span>Default Content</span>
</slot>
<p>Something after</p>
</div>
</template>
父组件中子组件的标签内填入的内容,全部都填充到子组件中定义的默认插槽位置。
子组件中需要使用<slot>标签指定插槽内容填充的位置,<slot>标签中的内容是默认占位内容,当父组件没有传递插槽内容时就显示此默认占位内容。
2.2:渲染函数
从使用示例中可以看到关于slot的使用是在<template>标签中的,一切关于template都应该查看渲染函数,我们看一下父组件及子组件的渲染函数:
// App.vue的渲染函数
var render = function() {
var _vm = this
var _h = _vm.$createElement
var _c = _vm._self._c || _h
return _c(
"div",
{ attrs: { id: "app" } },
[
_c("HelloWorld", [
_c("span", [_vm._v("Show Slot")]),
_vm._v(" "),
_c("span", [_vm._v("Show something else")])
])
],
1
)
}
需要说明的是:虽然插槽的内容是放在子组件中显示,但是内容是由父组件渲染生成的;这一点App.vue的渲染函数中也可以看出来。
从渲染函数中看到,父组件中写在子组件标签内的默认插槽内容,会当做children参数传递给子组件。children参数在子组件渲染时,会作为options选项的一个属性保存在子组件对应的VNode节点上,当子组件实例化时,经过initRender->resloveSlot处理后成为vm.$slots属性,vm.slots是一个对象,key是slot名称(未指定slot名称的,都存放在'default'这个key中);
再看一下子组件的渲染函数
// HelloWorld.vue 渲染函数
var render = function() {
var _vm = this
var _h = _vm.$createElement
var _c = _vm._self._c || _h
return _c(
"div",
{ staticClass: "hello" },
[
_c("p", [_vm._v("Something before")]),
_vm._v(" "),
_vm._t("default", [_c("span", [_vm._v("Default Content")])]),
_vm._v(" "),
_c("p", [_vm._v("Something after")])
],
2
)
}
可以看到子组件中<slot>标签的内容转换成_vm._t("default", [_c("span", [_vm._v("Default Content")])]),而_vm._t对应的是vue源码中的renderSlot方法,下一节我们看一下renderSlot方法是如何处理的。
2.3 renderSlot函数
先看一下renderSlot的源码
// vue-2.6.11源码 src\core\instance\render-helpers\render-slot.js
/**
* Runtime helper for rendering <slot>
*/
export function renderSlot (
name: string,
fallback: ?Array<VNode>,
props: ?Object,
bindObject: ?Object
): ?Array<VNode> {
// 具名插槽
const scopedSlotFn = this.$scopedSlots[name]
let nodes
if (scopedSlotFn) { // scoped slot
props = props || {}
if (bindObject) {
props = extend(extend({}, bindObject), props)
}
nodes = scopedSlotFn(props) || fallback
} else {
// 默认插槽走这个分支
nodes = this.$slots[name] || fallback
}
const target = props && props.slot
if (target) {
return this.$createElement('template', { slot: target }, nodes)
} else {
// 默认插槽走这个分支
return nodes
}
}
从上一节的语句_vm._t("default", [_c("span", [_vm._v("Default Content")])])可以分析出,最终返回的值是_vm.$slots['default']||fallback,而fallback就是[_c("span", [_vm._v("Default Content")])],即slot标签中的内容。
根据上一节的分析,_vm.$slots就是渲染子组件时传递的children参数经过处理之后生成的,这样就达到了父组件定义在子组件标签中的内容经过父组件渲染之后,插入到子组件指定的插槽位置中了。
三、具名插槽
在理解默认插槽后,理解具名插槽就简单很多了,我们还是从使用示例说起
3.1:具名插槽使用示例
// App.vue
<template>
<div id="app">
<HelloWorld >
<template v-slot:header>
<p>Show Header Slot</p>
</template>
<p>Show Default Slot</p>
<template v-slot:footer>
<p>Show Footer Slot</p>
</template>
</HelloWorld>
</div>
</template>
// HelloWorld.vue
<template>
<div class="hello">
<slot name="header"></slot>
<slot></slot>
<slot name="footer"></slot>
</div>
</template>
在子组件中,slot标签上使用name属性来对此插槽命名,而在父组件中,需要使用template标签包裹插槽内容,并在template标签上使用v-slot指令指定填充的插槽名称。
具名插槽解决了当有多个插槽时,通过指定插槽的名称来进行一一对应,未指定插槽名称的内容填充至默认插槽。
3.2:渲染函数
看一下使用具名插槽情况下的渲染函数
// App.vue
var render = function() {
var _vm = this
var _h = _vm.$createElement
var _c = _vm._self._c || _h
return _c(
"div",
{ attrs: { id: "app" } },
[
_c(
"HelloWorld",
{
scopedSlots: _vm._u([
{
key: "header",
fn: function() {
// 注意,_vm代表的是App.vue组件的vm实例,通过闭包的方式保留了对App.vue组件vm实例的访问
// 因此插槽中的内容实际的渲染者仍然是父组件
return [_c("p", [_vm._v("Show Header Slot")])]
},
proxy: true
},
{
key: "footer",
fn: function() {
return [_c("p", [_vm._v("Show Footer Slot")])]
},
proxy: true
}
])
},
[_vm._v(" "), _c("p", [_vm._v("Show Default Slot")])]
)
],
1
)
}
可以看到在App.vue的渲染函数中,渲染HelloWorld时多传入了一个data参数,data参数中有scopedSlots属性,_vm._u方法在源码中对应的是resolveScopedSlots,我们看一下resolveScopedSlots方法是如何处理的
// vue-2.6.11源码 src\core\instance\render-helpers\resolve-scoped-slots.js
export function resolveScopedSlots (
fns: ScopedSlotsData, // see flow/vnode
res?: Object,
// the following are added in 2.6
hasDynamicKeys?: boolean,
contentHashKey?: number
): { [key: string]: Function, $stable: boolean } {
res = res || { $stable: !hasDynamicKeys }
for (let i = 0; i < fns.length; i++) {
const slot = fns[i]
if (Array.isArray(slot)) {
resolveScopedSlots(slot, res, hasDynamicKeys)
} else if (slot) {
// marker for reverse proxying v-slot without scope on this.$slots
if (slot.proxy) {
slot.fn.proxy = true
}
res[slot.key] = slot.fn
}
}
if (contentHashKey) {
(res: any).$key = contentHashKey
}
return res
}
resolveScopedSlots的处理比较简单,最终返回的结果是一个对象,key是插槽名称,value值是一个函数,函数中进行了插槽内容的渲染,其通过闭包的方式保留了对父组件的访问,最终的渲染依旧是父组件进行的。
总结来说,父组件的渲染函数中,渲染子组件时,传递了一个data参数,data参数中有一个属性,其key为slotScopes,值是一个对象,值对象的key是插槽的名称,值对象的value是一个函数,在函数中进行插槽内容渲染。
接下来看一下HelloWorld.vue的渲染函数
// HelloWorld.vue 渲染函数
var render = function() {
var _vm = this
var _h = _vm.$createElement
var _c = _vm._self._c || _h
return _c(
"div",
{ staticClass: "hello" },
[
_vm._t("header"),
_vm._v(" "),
_vm._t("default"),
_vm._v(" "),
_vm._t("footer")
],
2
)
}
HelloWorld.vue的渲染函数没有什么变化,依旧是调用_vm._t函数(即源码中的renderSlot函数);
再回顾一下renderSlot函数
// vue-2.6.11源码 src\core\instance\render-helpers\render-slot.js
export function renderSlot (
name: string,
fallback: ?Array<VNode>,
props: ?Object,
bindObject: ?Object
): ?Array<VNode> {
// 具名插槽,根据插槽名称取到函数
const scopedSlotFn = this.$scopedSlots[name]
let nodes
if (scopedSlotFn) { // scoped slot
props = props || {}
if (bindObject) {
props = extend(extend({}, bindObject), props)
}
// 执行函数,进行插槽内容渲染,拿到渲染结果
nodes = scopedSlotFn(props) || fallback
} else {
nodes = this.$slots[name] || fallback
}
const target = props && props.slot
if (target) {
return this.$createElement('template', { slot: target }, nodes)
} else {
return nodes
}
}
具名插槽的内容渲染也比较简单,就是找到该插槽名称对应的函数,调用函数拿到渲染结果。
四、作用域插槽
具名插槽更进一步的用法是作用域插槽,作用域插槽可以将子组件中的数据传递给父组件用于渲染插槽内容,使得插槽的使用可以更加灵活。
4.1:使用示例
还是先从使用示例开始:
// App.vue
<template>
<div id="app">
<HelloWorld >
<template v-slot:header="headerScopeData">
<p>Show Header Slot</p>
<p>user name : {{ headerScopeData.user.name }}</p>
</template>
<p>Show Default Slot</p>
<template v-slot:footer>
<p>Show Footer Slot</p>
</template>
</HelloWorld>
</div>
</template>
// HelloWorld.vue
<template>
<div class="hello">
<slot v-bind:user="userData" name="header"></slot>
<slot></slot>
<slot name="footer"></slot>
</div>
</template>
子组件的slot标签上使用v-bind指令绑定需要传递的数据;父组件中插槽内容的template标签上,v-slot指定除了指定对应的插槽名称外,还指定了接收来自子组件传递的数据名称,这样在插槽内容中就可以使用该数据了。
4.3:渲染函数
看一下使用作用域插槽后的渲染函数
// App.vue 渲染函数
var render = function() {
var _vm = this
var _h = _vm.$createElement
var _c = _vm._self._c || _h
return _c(
"div",
{ attrs: { id: "app" } },
[
_c(
"HelloWorld",
{
scopedSlots: _vm._u([
{
key: "header",
// 不同之处:函数接收了一个参数,参数名为我们使用v-slot指令指定的数据名称
fn: function(headerScopeData) {
return [
_c("p", [_vm._v("Show Header Slot")]),
_vm._v(" "),
_c("p", [
_vm._v("user name : " + _vm._s(headerScopeData.user.name))
])
]
}
},
{
key: "footer",
fn: function() {
return [_c("p", [_vm._v("Show Footer Slot")])]
},
proxy: true
}
])
},
[_vm._v(" "), _c("p", [_vm._v("Show Default Slot")])]
)
],
1
)
}
可以看到,App.vue的渲染函数和具名插槽基本一致,不同之处在于header插槽对应的函数接收了一个参数,参数名为我们使用v-slot指令指定的数据名称,该数据在header插槽内容的渲染时被使用。
接下来再看一下HelloWorld.vue的渲染函数
// HelloWorld.vue 渲染函数
var render = function() {
var _vm = this
var _h = _vm.$createElement
var _c = _vm._self._c || _h
return _c(
"div",
{ staticClass: "hello" },
[
// 不同之处:多了两个参数,第二个参数是fallback,第三个参数是props
_vm._t("header", null, { user: _vm.userData }),
_vm._v(" "),
_vm._t("default"),
_vm._v(" "),
_vm._t("footer")
],
2
)
}
不同之处在于:header插槽的渲染函数多了2个参数;第二个参数是fallback,因为我们在子组件里header插槽的slot标签中没有写任何内容,因此这里是null;第三个参数是props对象,其key和value值就是v-bind指令指定的参数名称和值。
_vm._t就是renderSlot函数:
// vue-2.6.11源码 src\core\instance\render-helpers\render-slot.js
export function renderSlot (
name: string,
fallback: ?Array<VNode>,
props: ?Object,
bindObject: ?Object
): ?Array<VNode> {
// 具名插槽,根据插槽名称取到函数
const scopedSlotFn = this.$scopedSlots[name]
let nodes
if (scopedSlotFn) { // scoped slot
props = props || {}
if (bindObject) {
props = extend(extend({}, bindObject), props)
}
// 执行函数,进行插槽内容渲染,拿到渲染结果
// 不同之处:header插槽渲染时传递了props属性
nodes = scopedSlotFn(props) || fallback
} else {
nodes = this.$slots[name] || fallback
}
const target = props && props.slot
if (target) {
return this.$createElement('template', { slot: target }, nodes)
} else {
return nodes
}
}
总结:作用域插槽是将子组件中的数据当做参数传递给插槽渲染函数,实现了父组件渲染插槽内容时能够使用子组件中的数据。
五、总结
经过上面的使用示例、渲染函数分析、源码解读,对vue中的插槽实现有一个比较清晰的认知:插槽内容是由父组件渲染,子组件在指定的位置将插槽内容显示出来;具名插槽实现了同时使用多个插槽的情况,根据指定的插槽名称进行对应;通过作用域插槽可以将子组件中的数据传递给插槽渲染函数,进一步提升了插槽的灵活性和适用性。