一、前言
props 传数据、emit 传事件,但父组件想把**一段模板(HTML 结构)**塞进子组件呢?比如卡片组件:外壳统一,中间装什么内容每个页面都不一样。这就是插槽 slot:子组件挖坑,父组件填内容。默认、具名、作用域三种,一个比一个强,Element Plus 的表格弹窗全是它的深度应用。
二、默认插槽:子组件挖坑,父组件填内容
先看没有插槽的尴尬:内容写死在子组件里。
vue
<!-- 没插槽:MyCard 的中间内容永远是死的 -->
<div class="card">
<h4>固定标题</h4>
<p>固定内容------别的页面想换内容?没门</p>
</div>
用 <slot> 挖个坑:
vue
<!-- src/components/BaseCard.vue -->
<template>
<div class="card">
<h4>卡片标题(子组件固定)</h4>
<!-- 坑:父组件塞什么,这里就渲染什么 -->
<slot>
<!-- 坑里写的这个是"后备内容":父组件不填时才显示 -->
<p style="color: #999">(这里暂时没有内容)</p>
</slot>
</div>
</template>
<style scoped>
.card { border: 1px solid #eee; padding: 12px; border-radius: 8px; margin-bottom: 12px; }
</style>
vue
<!-- App.vue -->
<template>
<!-- 填坑 1:塞普通内容 -->
<BaseCard>
<p>这是父组件塞进来的内容 <b style="color:#42b983">带样式</b></p>
</BaseCard>
<!-- 填坑 2:什么都不填 → 显示后备内容 -->
<BaseCard />
<!-- 填坑 3:塞组件也行(内容可以是任何模板) -->
<BaseCard>
<StarRating :model="4" @change="() => {}" />
</BaseCard>
</template>
<script setup>
import BaseCard from './components/BaseCard.vue'
import StarRating from './components/StarRating.vue'
</script>
编译本质 :父组件写在标签"夹缝"里的内容,会被塞到子组件 <slot /> 的位置。作用域注意:模板里能用谁的变量,看它定义在哪------插槽内容在父组件编译,只能直接用父组件的数据(想用子组件的看第五节作用域插槽)。
【截图位置:填内容、留空显示后备内容两种卡片的效果】
三、具名插槽:多个坑,按名字填
卡片通常有三段:头、身、脚。一个 slot 不够用,给坑起名字:
vue
<!-- src/components/PageCard.vue -->
<template>
<div class="page-card">
<header class="head">
<slot name="header">默认头部</slot>
</header>
<main>
<!-- 不写 name 的坑默认叫 default -->
<slot />
</main>
<footer class="foot">
<slot name="footer">默认底部</slot>
</footer>
</div>
</template>
<style scoped>
.page-card { border: 1px solid #eee; border-radius: 8px; overflow: hidden; }
.head { background: #f8f8f8; padding: 10px 12px; font-weight: bold; }
main { padding: 12px; }
.foot { background: #fafafa; padding: 8px 12px; }
</style>
父组件用 #名字(v-slot:名字 的缩写)定向填坑:
vue
<template>
<PageCard>
<!-- #header = v-slot:header,只填头部坑 -->
<template #header>
用户管理 <small>(共 88 人)</small>
</template>
<!-- 不包 template 的内容全部进默认坑(主体) -->
<p>这里是表格区......</p>
<template #footer>
<button>保存</button>
<button>取消</button>
</template>
</PageCard>
</template>
规则两条:
- 带
#名字的内容去同名坑,裸内容去 default 坑 - 填具名坑必须用
<template #名字>包裹(v-slot 只能用在 template 或组件标签上)
四、编译作用域:插槽内容只认父组件的数据
强调一遍这个高频误区:
vue
<template>
<!-- ❌ url 是子组件的数据,父组件插槽里用不了 -->
<!-- <BaseCard><a :href="url">链接</a></BaseCard> -->
</template>
插槽内容编译作用域在父组件 :能不能显示取决于父组件有没有这个变量,跟子组件无关。那如果内容偏偏需要子组件的数据(比如列表项)?------作用域插槽登场。
五、作用域插槽:子组件把数据"递"给插槽内容
场景:列表外壳(ul、边框、空状态)由子组件统一封装,但每行长什么样要父组件自定义,而且渲染需要子组件遍历出来的 item。
子组件挖坑时把数据绑在坑上递出去:
vue
<!-- src/components/TaskList.vue -->
<template>
<ul class="task-list">
<li v-for="(task, index) in tasks" :key="task.id">
<!-- :item :index 是"递出去的数据",父组件填坑时可接收 -->
<slot name="item" :item="task" :index="index">
<!-- 后备内容:父组件不定制时的默认长相 -->
{{ index + 1 }}. {{ task.text }}
</slot>
</li>
<li v-if="tasks.length === 0" class="empty">暂无任务</li>
</ul>
</template>
<script setup>
defineProps({
tasks: { type: Array, default: () => [] }
})
</script>
父组件填坑时用 #item="{ item, index }" 解构接收(就是 017 篇学的解构语法):
vue
<template>
<TaskList :tasks="tasks">
<template #item="{ item, index }">
<!-- item / index 是子组件递出来的,随便用 -->
<span :class="{ done: item.done }">
{{ index + 1 }}. {{ item.text }}
</span>
<button v-if="!item.done" @click="finish(item.id)">完成</button>
</template>
</TaskList>
</template>
<script setup>
import { ref } from 'vue'
import TaskList from './components/TaskList.vue'
const tasks = ref([
{ id: 1, text: '学插槽', done: true },
{ id: 2, text: '封装列表组件', done: false }
])
function finish(id) {
const t = tasks.value.find(t => t.id === id)
if (t) t.done = true
}
</script>
<style scoped>
.done { text-decoration: line-through; color: #999; }
</style>
这一刻你会突然看懂 Element Plus 的 el-table:
vue
<el-table :data="list">
<el-table-column label="状态">
<template #default="{ row }">{{ row.status }}</template>
</el-table-column>
</el-table>
#default="{ row }" 就是我们刚写的作用域插槽------el-table 递出 row,你定制每行渲染。组件库设计的底层思想,你已经拿到了。
【截图位置:任务列表自定义行渲染(删除线 + 完成按钮)的运行效果】
六、踩坑记录
- 填具名坑忘了包 template :
<PageCard #header>xxx</PageCard>无效------v-slot/# 只能写在<template>或组件标签上,标准写法<template #header> - 插槽内容用子组件的变量:编译作用域在父组件,拿不到就 undefined------需要子组件数据请用作用域插槽
- 作用域插槽解构名写错 :子组件递的是
:item,父组件解构写{ task }就是 undefined------名字以子组件绑定时为准 - 同一坑重复填 :两个
<template #header>后者覆盖前者,只生效一个 - 动态插槽名知道即可 :
<template #[dynamicName]>方括号语法存在,低频,遇到了会认就行
七、今日小结
- 插槽 = 子组件挖坑
<slot>,父组件填模板,坑里可写后备内容 - 具名插槽
#名字定向填坑,裸内容进 default - 插槽内容编译作用域在父组件
- 作用域插槽:坑上
:数据递出去,父组件#名="{ 解构 }"接收------el-table 的灵魂
下篇预告
组件通信与组合还剩两员大将:多 Tab 切换时组件反复销毁重建,用户填一半的表单说没就没怎么办?下一篇 030 动态组件与 keep-alive------<component :is> 切换 + 页面缓存保状态。