如果对组件不太了解,可以先阅读笔者的这两篇文章,在对组件有了一定的了解之后,在查看本篇文章: vue进阶(一),深入了解组件,自定义组件 Vue进阶(二)设计高级组件——自定义通知
注意:本篇文章的重点是使用slot开发一个分页组件,如果希望详细了解Vue中slot的用法,可以查看官网文档,同时,如果您在阅读本文中发现错误或者使用不当的地方,还请您指出修正!
1. 什么是插槽
Vue实现了一套内容分发的API,而<slot> 元素就是承载分发内容的出口。 使用<slot> ,我们可以这样写一个组件: slotTest.vue:
<div>
<span>这个组件中使用了slot元素</span>
<slot></slot>
</div>
使用slotTest.vue:
<current-name>
这个内容将展示在slot的位置
</current-name>
显示效果: 当组件在渲染时,<slot></slot> 将会被替换为“这个内容将展示在slot的位置”。插槽内可以包含任何模板代码,包括 HTML,甚至其他自定义组件:
<current-name>
<div>这个内容将展示在slot的位置</div>
</current-name>
显示结果: 如果slotTest的template 中没有包含一个<slot> 元素,那么,在使用slotTest是,<slot>任意内容</slot> 组件起始标签和结束标签之间的任何内容都会被抛弃。
2. 插槽的具体使用
现在有这样的一个组件,其可以显示一些个人信息。 person.vue:
<template>
<div>
<span>这个组件中使用了slot元素</span>
<div>
firstName:
<slot name="firstName"></slot>
</div>
<div>
lastName:
<slot name="lastName" :last_name="lastname"></slot>
</div>
<div>
sex:
<slot name="sex">男(如果没有使用到这个插槽,那么这就是这个插槽的默认填充)</slot>
</div>
<div>
身高:
<slot name="height">如果使用了这个插槽,那么这里的内容就无效了</slot>
</div>
<div>
体重:
<slot name="weight"></slot>
</div>
<slot/>
</div>
</template>
<script>
export default {
name: "currentName",
data(){
return{
firstName: 'Niall',
lastname: 'August'
}
}
}
</script>
使用person.vue:
<template>
<div>
<person>
<template v-slot:height>180cm</template>
<template v-slot:[slotName]>{{firstname}}</template>
<template v-slot:lastName=last>{{last.last_name}}</template>
<template #weight>90kg</template>
<template><div>这个内容将展示在default slot的位置</div></template>
</person>
</div>
</template>
<script>
import person from "./components/person";
export default {
name: "Test",
components: {
person
},
data(){
return{
slotName:'firstName',
firstname: 'Jack'
}
}
}
</script>
显示结果:
2.1 具名插槽
在person.vue中,我们设置了多个插槽,为了能够区分不同的插槽,我们为设置了每个插槽的name 属性,一个不带name的slot出口都会带有一个默认名字default 。 当我们在使用组件时,在分发内容上就需要在一个 <template> 元素上使用 v-slot 指令,并以 v-slot 的参数的形式提供其名称,以上面我们分发height时:
<template v-slot:height>180cm</template>
这里我们还需要注意,我们根据slot.name 来分发内容,但是和我们在使用组件时的顺序没有关系,也就是说,我们分发内容的顺序和显示没有关系,显示的顺序由我们的组件中slot的顺序决定。所以,虽然我们在使用person时将<template v-slot:height>180cm</template> 写在了最前面,但是最终的显示内容还是按照了person中slot的顺序来显示的。 在设置了slot.name 之后,<template> 元素中的所有内容都将会被传入相应的插槽。任何没有被包裹在带有 v-slot 的 <template> 中的内容都会被视为默认插槽的内容。 也就是上文中的:
<template><div>这个内容将展示在default slot的位置</div>
最终会渲染到默认插槽的位置。
和v-bind,v-on可以简写成 :,@ ,类似的,v-slot也可以用 # 来简写:
<template #weight>90kg</template>
2.2 使用动态插槽名
<template v-slot:[slotName]>{{firstname}}</template>
这里的slotName是Vue实例中的变量名,可以进行动态绑定到person中对应的插槽。此时,slotName = firstName,所以,这种写法也就等价于:
<template v-slot:firstName>{{firstname}}</template>
2.3 作用域插槽
有时候,我们想要在使用组件的时候访问到子组件内的数据。比如我们在使用person时向其lastName插槽中分发的内容需要使用person里面的lastname数据:
<template v-slot:lastName>{{lastname}}</template>
上述代码是不会正常工作的,因为我们提供的内容实在父级渲染的,person能够访问到lastname,但是我们在使用person时,我们父级实例并不存在lastName。 为了让person中的lastname能够在父级渲染时使用,我们可以将person.lastname作为一个属性绑定到person的插槽上面:
<slot name="lastName" :last_name="lastname"></slot>
这里就给person中name为lastName的插槽增加了一个last_name属性,其属性值为person.lastname,然后,当我们给lastName插槽分发内容时,我们可以先使用插槽的last_name属性获取person.lastname:
<template v-slot:lastName=last>{{last.last_name}}
在使用时,我们先将lastName插槽赋值给last,然后我们就可以通过last.last_name获取到对应的值了。 使用参数解构:
<template v-slot="{lastname : last}">{{last.last_name}}
像last_name这种绑定在<slot> 元素上的属性也被称作为 插槽prop。
3. 开发分页组件
我们现在使用slot来实现下面的分页组件:
我们先根据效果图分析组件的组成,上面的fruit,color,car可以是一个列表,下面是一个div。大家可以认为这个组件很容易写出来,毕竟其仅仅是由两部分构成,上面是一个横置的列表,下面是一个div。但是我们希望这个分页组件具有足够的灵活性,这样来使用组件:
<tabs value='fruit'>
<tab value="fruit" label="水果">
<div>I like apple best</div>
</tab>
<tab value="color" label="颜色">
<div>The sky is blue</div>
</tab>
<tab value="car" label="汽车">
<div>I want to have a nice car</div>
</tab>
</tabs>
而不是这样:
<tabs value='fruit'>
<tab value="fruit" label="水果"></tab>
<tab value="color" label="颜色"></tab>
<tab value="car" label="汽车"></tab>
<div belong="friut">I like apple best</div>
<div belong="color">The sky is blue</div>
<div belong="car">I want to have a nice car</div>
</tabs>
如果仅仅是按第二种思路来写tab组件的话,直接根据此时tabs.value的值来设置div.show的值就能完成。但是,第二种思路开发出来的分页组件灵活性和复用性都不高。 如果希望我们的组件最后按照第一种写法来使用,我们该怎么做呢? 我们先观察tabs组件
<tabs value='fruit'>
...
<tab value="color" label="颜色">
<div>The sky is blue</div>
</tab>
...
</tabs>
在tabs组件中插入分页tab,<tab> 是自定义组件,我们也需要使用一个slot 来接收。 那么,我们现在可以明确的是,tabs组件中都需要<slot> 来分发内容。现在我们先思考tabs的结构,一个列表用于展示列表标签,一个div来展示内容,看到这里,你可能会以为,这样写不就是上文中的思路二吗,带着疑问继续看下去,相信你能明白两者的不同。 tabs.vue:
<template>
<div class="tabs">
<ul class="ul">
<slot></slot>
</ul>
<div></div>
</div>
</template>
这里的<slot> 用于接收tabs中所有的tab,tab是我们的自定义组件,所有的tab标签会以列表的形式展现出来,所以tab.vue:
<template>
<li>
{{label}}
</li>
</template>
<script>
export default {
name: "tab",
props:{
label:{
type: String,
default: 'tab'
},
value:{
type: [Number, String],
required: true
}
}
}
</script>
这时候,我们组件的上半部分就已经完成了,但是每一个分页的内容我们该如何显示出来呢? 我们的tab是这样使用的:
<tab value="color" label="颜色">
<div>The sky is blue</div>
</tab>
我们在其中插入了<div> 标签,我们使用一个slot来接收它?如果我们使用slot接收它,那分页的内容就只能出现在列表里面了,我们现在并不希望在列表中展示分页的内容,而是将分页的内容展示在列表下面的一个容器中。 我们看一看思路和代码结构的矛盾点: 代码方面: 思路方面:
现在我们需要解决的是,如何将<tab> 接收到的<div> 是在tab内部,但是我们需要在tab之外来创建。所以,我们如果要解决这个矛盾,我们需要先将tab中分发的内容提取出来,然后再在tabs中将其创建出来。 所以,我们在tabs中增加一个数据:
data(){
return{
panes: []
}
}
然后修改tab,在mounted之后就将自身加到panes中:
mounted() {
this.$parent.panes.push(this)
}
这样,我们在tabs中就可以通过this.panes获取到每一个tab的分页内容。 我们在tabs中再使用一个pane组件,这个也是我们的自定义组件,其作用就是显示某一个分页的内容。 因为this.panes中保存了所有的分页内容,如果需要按照标签来显示内容的话,我们还需要给tabs增加一个selected值,同时在tab中增加一个active标识,用于判断pane中的内容是否是该分页的内容,同时,当我们点击一个tab时,需要更改tabs.selected。 在使用pane时,我们向其传入panesSelected,panesSelected是根据此时分页的active标识来过滤出需要渲染的DOM节点。
修改tabs:
<template>
<div class="tabs">
<ul class="ul">
<slot v-on:click="onChange"></slot>
</ul>
<div></div>
<pane :panes="panesSelected"></pane>
</div>
</template>
<script>
import pane from "./pane";
export default {
...
components:{
pane
},
data(){
return{
panes: [],
selected: this.value
}
},
computed:{
panesSelected () {
return this.panes.map( item => item.active ? item.$slots.default : null
)
}
},
methods:{
onChange(index){
this.selected = index
}
}
}
</script>
修改tab:
<template>
<li @click="handleClick">
{{label}}
</li>
</template>
<script>
export default {
...
methods:{
handleClick(){
this.$parent.onChange(this.value)
}
},
computed:{
active () {
return this.$parent.selected === this.value
}
}
}
</script>
现在我们来设计pane,pane已经能够获取到需要显示的内容了,关键是我们应该如何渲染出slot? panesSelected中存放的是 {{tab(此tab的active为true)}}.$slots.default 。 我们如何将slot数据渲染出来呢? 我们将使用两种方式来达到我们的期望:
- 使用Vue模板
- 使用Vue的渲染函数
1. 使用Vue模板
<template>
<div class="pane">
<slot/>
</div>
</template>
<script>
export default {
name: "pane",
props:{
panes:{
type: Array,
required: true
}
},
watch:{
panes(val){
this.$slots.default = val
this.$forceUpdate()
}
}
}
</script>
我们将监听panes(传入的panesSelected),每当其发生变化时(即分页tab被点击之后,改变了tab.active,导致panesSelected改变),将panes重新赋值给** this.$slots.default**。这里我们还需要调用this.\$forceUpdate() ,因为<slot> 并不是响应式的,当我们给this.$slots.default 重新赋值之后,页面并不会刷新。 注意,使用模板来渲染传入的slot是笔者思考很长时间才实现成功的,就像Vue官方文档所说: 所以,笔者相信一定存在不使用this.$forceUpdate() 并且通过Vue模板来实现这里的pane组件,但是笔者愚钝,还希望您能提出更好的意见。
2. 使用Vue的渲染函数 这种情况下,我们不需要创建模板:
<script>
export default {
props: {
panes: {
type: Array,
required: true
}
},
render () {
return (
<div>
{panes}
</div>
)
}
}
</script>
这样就能将panes(即传入的panesSelected)渲染出来了。
4. 源码
tabs.vue:
<template>
<div class="tabs">
<ul class="ul">
<slot v-on:click="onChange"></slot>
</ul>
<div></div>
<pane :panes="panesSelected"></pane>
</div>
</template>
<script>
import pane from "./pane";
export default {
name: "tabs",
props:{
value: {
type: [String, Number],
required: true
}
},
components:{
pane
},
data(){
return{
panes: [],
selected: this.value
}
},
computed:{
panesSelected () {
return this.panes.map( item => item.active ? item.$slots.default : null
)
}
},
methods:{
onChange(index){
this.selected = index
this.$emit('change', index)
}
}
}
</script>
<style lang="scss" scoped>
.tabs{
background: #cccccc;
border-radius: 5px;
width: 200px;
padding: 5px;
ul{
margin: 0;
padding: 0;
display: inline-flex;
justify-content: space-between;
line-height: 40px;
box-sizing: border-box;
width: 100%;
}
}
</style>
tab.vue:
<template>
<li :class="[active?'active':'']" @click="handleClick">
{{label}}
</li>
</template>
<script>
export default {
name: "tab",
props:{
label:{
type: String,
default: 'tab'
},
value:{
type: [Number, String],
required: true
}
},
mounted() {
this.$parent.panes.push(this)
},
methods:{
handleClick(){
this.$parent.onChange(this.value)
}
},
computed:{
active () {
return this.$parent.selected === this.value
}
}
}
</script>
<style lang="scss" scoped>
li{
background-color: lightgray;
border: #666666 1px solid;
border-radius: 5px;
list-style: none;
}
.active{
background-color: lightblue;
}
</style>
pane.vue:
<template>
<div class="pane">
<slot/>
</div>
</template>
<script>
export default {
name: "pane",
props:{
panes:{
type: Array,
required: true
}
},
watch:{
panes(val){
this.$slots.default = val
this.$forceUpdate()
}
}
}
</script>
<style lang="scss" scoped>
.pane{
margin-top: 10px;
box-sizing: border-box;
padding: 10px;
height: 200px;
width: 200px;
border: #666666 1px solid;
line-height: 30px;
}
</style>
App.vue:
<template>
<div>
<tabs value='fruit'>
<tab :value=item.label v-for="(item, index) in test" :key="index" :label="item.label">
<div>{{item.content}}</div>
</tab>
</tabs>
</div>
</template>
<script>
import tabs from "./components/tab/tabs";
import tab from "./components/tab/tab";
export default {
name: "Test",
components: {
tabs,
tab
},
data(){
return{
obj: {
fruit: 'I like apple best',
color: 'The sky is blue',
car: 'I want to have a nice car'
},
test:[
{
label:'fruit',
content:'I like apple best'
},
{
label:'color',
content: 'The sky is blue'
},
{
label:'car',
content: 'I want to have a nice car'
}
]
}
}
}
</script>
<style lang="scss" scoped>
</style>
结果:
|