Vue 3 组件插槽机制详解
在 Vue.js 应用开发中,组件化是核心思想。父子组件之间的数据传递通常通过 props 和事件完成。然而,当我们需要在父组件中定义并注入复杂的 DOM 结构到子组件的特定位置时,传统的数据传递方式会变得笨拙。Vue 的插槽(Slot)机制正是为了解决这一问题而生,它允许我们更灵活地组合和定制组件内容。
一、基础插槽(默认插槽)
我们首先来看一个简单的组件,它包含固定的内部结构。假设我们有一个基础的卡片组件,其内容是预设的:
const app = Vue.createApp({
template: `<base-card />`
});
app.component('base-card', {
template: `
<div class="card-container">
<div class="card-header">卡片标题</div>
<div class="card-content">这是卡片的固定内容。</div>
<div class="card-footer">© 2023</div>
</div>
`
});
app.mount("#app");
如果希望 card-content 部分由父组件决定,而不是固定不变,这时就可以引入插槽。在子组件模板中放置一个 <slot></slot> 标签,父组件在其组件标签内部编写的内容就会被渲染到 <slot> 的位置:
const app = Vue.createApp({
template: `
<!-- 父组件提供自定义内容 -->
<base-card>
<p>父组件动态注入的段落内容。</p>
<button>点击操作</button>
</base-card>
<!-- 另一个 BaseCard 实例,内容完全不同 -->
<base-card>
<img src="placeholder.jpg" alt="图片描述" style="max-width: 100%;"/>
</base-card>
`
});
app.component('base-card', {
methods: {
handleCardAction() {
alert('卡片内部事件被触发!');
}
},
template: `
<div class="card-container">
<div class="card-header">卡片标题</div>
<div class="card-content">
<!-- 这里是默认插槽,用于接收父组件传递的内容 -->
<slot></slot>
</div>
<div class="card-footer">
<span @click="handleCardAction" style="cursor: pointer;">了解更多</span>
</div>
</div>
`
});
app.mount("#app");
通过这种方式,BaseCard 组件的结构保持不变,但其核心内容可以由父组件动态定制,大大提高了组件的复用性。
二、插槽内容的作用域
一个重要的概念是:插槽内容是在父组件的作用域中编译的。这意味着插槽中使用的任何数据或方法都将引用父组件的实例,而不是子组件的实例。即使插槽内容在子组件的模板中渲染,其数据源仍然是父组件。
const app = Vue.createApp({
data() {
return {
parentMessage: '来自父组件的消息'
};
},
template: `
<content-wrapper>
<!-- 这里的 parentMessage 访问的是父组件的 data -->
<p>{{ parentMessage }}</p>
<inner-component />
</content-wrapper>
`
});
app.component('content-wrapper', {
data() {
return {
childData: '子组件自己的数据'
};
},
template: `
<div class="wrapper">
<h3>组件标题</h3>
<slot></slot> <!-- 插槽内容在这里渲染 -->
<p>{{ childData }}</p> <!-- 子组件可以访问自己的数据 -->
</div>
`
});
app.component('inner-component', {
template: `<div>内部组件内容</div>`
});
app.mount("#app");
在上面的例子中,<p>{{ parentMessage }}</p> 会显示父组件的 parentMessage,而不是子组件的任何数据。
三、插槽的默认内容(回退内容)
如果我们定义了插槽,但父组件没有为它提供任何内容,那么该插槽将什么都不渲染。为了提供更好的用户体验或作为备用,我们可以为插槽设置默认内容。当父组件没有提供内容时,插槽的默认内容就会被渲染出来。
const app = Vue.createApp({
template: `
<!-- 父组件未提供内容,将显示默认内容 -->
<info-panel></info-panel>
<!-- 父组件提供内容,将覆盖默认内容 -->
<info-panel>
<p>这是父组件提供的重要信息!</p>
</info-panel>
`
});
app.component('info-panel', {
template: `
<div class="panel">
<h4>信息面板</h4>
<div class="panel-body">
<slot>
<!-- 这里是插槽的默认内容 -->
<p>当前没有具体信息,请稍后再试。</p>
</slot>
</div>
</div>
`
});
app.mount("#app");
四、具名插槽
在一些复杂的组件中,我们可能需要多个插槽来定义不同的内容区域,例如一个布局组件可能需要独立的头部、侧边栏和主体内容。这时,具名插槽就非常有用。
在子组件中,通过 name 属性为插槽指定名称:<slot name="header"></slot>。
在父组件中,使用 <template v-slot:name></template> 语法将内容发送到指定的具名插槽。v-slot 只能用在 <template> 标签上。
const app = Vue.createApp({
template: `
<page-layout>
<!-- 使用 v-slot:header 指定内容发送到名为 "header" 的插槽 -->
<template v-slot:header>
<header>
<h1>应用标题</h1>
<nav><a href="#">导航链接</a></nav>
</header>
</template>
<!-- 未具名插槽(默认插槽)的内容 -->
<p>这是页面主体内容的一部分。</p>
<template v-slot:footer>
<footer>
<p>版权所有 © 2023</p>
</footer>
</template>
</page-layout>
`
});
app.component('page-layout', {
template: `
<div class="page">
<div class="layout-header">
<slot name="header">默认页眉</slot>
</div>
<div class="layout-main">
<slot>默认主内容</slot> <!-- 未具名插槽 -->
</div>
<div class="layout-footer">
<slot name="footer">默认页脚</slot>
</div>
</div>
`
});
app.mount("#app");
具名插槽的简写
对于 v-slot:name,Vue 提供了一个简写形式 #name,这使得具名插槽的使用更加简洁。
const app = Vue.createApp({
template: `
<page-layout>
<!-- #header 是 v-slot:header 的简写 -->
<template #header>
<h1>简写形式的标题</h1>
</template>
<p>主体内容。</p>
<!-- #footer 是 v-slot:footer 的简写 -->
<template #footer>
<p>简写形式的页脚信息。</p>
</template>
</page-layout>
`
});
app.component('page-layout', {
template: `
<div class="page">
<div class="layout-header">
<slot name="header"></slot>
</div>
<div class="layout-main">
<slot></slot>
</div>
<div class="layout-footer">
<slot name="footer"></slot>
</div>
</div>
`
});
app.mount("#app");
五、作用域插槽
在某些场景下,父组件的插槽内容需要访问子组件内部的数据。例如,一个列表组件负责数据的获取和迭代,但每个列表项的具体渲染方式却希望由父组件来定义。这时,作用域插槽就派上了用场。
子组件可以在 <slot> 标签上绑定属性,这些属性被称为"插槽 prop"。父组件可以通过 v-slot="slotProps"(或者 #default="slotProps" 对于默认插槽,#name="slotProps" 对于具名插槽)来接收这些插槽 prop,并在插槽内容中使用它们。
const app = Vue.createApp({
template: `
<h3>默认渲染列表:</h3>
<data-iterator></data-iterator> <!-- 未使用作用域插槽 -->
<h3>自定义渲染列表:</h3>
<data-iterator v-slot="{ item, index }">
<!-- item 和 index 来自子组件的插槽 prop -->
<div class="custom-item">
<span>序号: {{ index + 1 }}</span>
<strong>值: {{ item }}</strong>
</div>
</data-iterator>
<h3>另一种自定义:</h3>
<data-iterator #default="{ item }">
<em>当前项: {{ item.toUpperCase() }}</em>
</data-iterator>
`
});
app.component('data-iterator', {
data() {
return {
items: ['Apple', 'Banana', 'Cherry']
};
},
template: `
<ul class="item-list">
<li v-for="(dataItem, idx) in items" :key="idx">
<!-- 通过 v-bind 将子组件的数据绑定到插槽,作为插槽 prop 暴露出去 -->
<slot :item="dataItem" :index="idx">
<!-- 默认内容,在父组件未提供插槽内容时显示 -->
<span>默认列表项: {{ dataItem }}</span>
</slot>
</li>
</ul>
`
});
app.mount("#app");
在上面的示例中,data-iterator 组件内部维护了一个 items 数组。通过 <slot :item="dataItem" :index="idx">,子组件将当前迭代的 dataItem 和 idx 作为插槽 prop 传递给父组件。父组件在 <data-iterator v-slot="{ item, index }"> 中解构接收这些 prop,从而能够完全控制每个列表项的渲染逻辑。这极大地增强了组件的灵活性和可定制性。
