Vue 3 插槽完全指南
一、插槽是什么?为什么需要它?
1.1 生活中的类比
可以把组件想象成一个外壳(比如卡片框、弹窗框、布局框),插槽就是外壳上留的“洞”。
谁用这个组件,谁就往洞里塞内容。同一款外壳,可以塞不同的文字、图片、按钮,实现复用组件、定制内容。
1.2 没有插槽时的问题
假设你写了一个“按钮”组件,文字写死在组件里:
<!-- Button.vue -->
<template>
<button class="my-btn">点击我</button>
</template>
那所有用这个按钮的地方都只能是“点击我”,无法改成“提交”“取消”“下一步”。
要想改文字,只能要么传很多 props,要么写很多类似的组件,很不灵活。
1.3 有了插槽之后
在组件里留一个“洞”(插槽),让父组件决定洞里放什么:
<!-- Button.vue:子组件留一个洞 -->
<template>
<button class="my-btn">
<slot></slot>
</button>
</template>
<!-- 父组件:往洞里塞不同内容 -->
<template>
<Button>提交</Button>
<Button>取消</Button>
<Button>下一步</Button>
</template>
这样同一个 Button 组件就能显示不同文字(甚至放图标、整块 HTML),这就是插槽在做的事:子组件提供“位置”,父组件提供“内容”。
二、插槽的核心概念(必记)
<slot>标签:写在子组件里,表示“这里有一个洞,请把父组件传过来的内容放进来”。- 插槽内容:写在父组件里,是放在子组件标签中间的那部分(即“往洞里塞的东西”)。
- 默认内容:在
<slot>...</slot>里写的子节点,当父组件没有传入插槽内容时,会显示这些默认内容。
下面按类型从易到难说明。
三、默认插槽(单个插槽)
子组件里只写一个 <slot>,不写 name,就是默认插槽。
父组件在子组件标签内写的所有内容(没有用 v-slot 指定名字的),都会放进这个默认插槽。
3.1 基本用法
子组件 Card.vue:
<template>
<div class="card">
<div class="card-header">卡片</div>
<div class="card-body">
<slot></slot>
</div>
</div>
</template>
<style scoped>
.card {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.card-header {
background: #f5f5f5;
padding: 10px;
}
.card-body {
padding: 15px;
}
</style>
父组件使用:
<template>
<Card>
<p>这里是卡片的主体内容,可以是任意 HTML。</p>
<button>按钮也可以</button>
</Card>
</template>
<script setup>
import Card from './Card.vue'
</script>
渲染结果:
“卡片”标题是固定的,下面那一块是父组件传进去的 <p> 和 <button>。
3.2 默认内容(后备内容)
如果父组件没有在子组件里写任何内容,插槽位置就会是空的。
若希望“没传内容时显示一段默认文字”,可以在 <slot> 里写默认内容:
子组件:
<template>
<div class="card">
<div class="card-body">
<slot>
<p>默认提示:暂无内容</p>
</slot>
</div>
</div>
</template>
父组件两种用法:
<template>
<!-- 传了内容:显示“我是自定义内容” -->
<Card>
<p>我是自定义内容</p>
</Card>
<!-- 没传内容:显示“默认提示:暂无内容” -->
<Card></Card>
</template>
规则: 父组件只要写了子组件标签里的内容,就会替换整个默认内容;只有完全没写时,才显示 <slot> 里的默认内容。
四、具名插槽(多个“洞”)
一个组件可能需要多个“洞”,比如:上面一块放标题、中间放主体、下面放底部。
每个洞起一个名字,就是具名插槽。
4.1 子组件:用 name 给插槽起名
子组件 Layout.vue:
<template>
<div class="layout">
<header class="header">
<slot name="header"></slot>
</header>
<main class="main">
<slot></slot>
<!-- 不写 name 就是默认插槽 -->
</main>
<footer class="footer">
<slot name="footer"></slot>
</footer>
</div>
</template>
<style scoped>
.layout {
display: flex;
flex-direction: column;
min-height: 200px;
}
.header {
background: #333;
color: #fff;
padding: 10px;
}
.main {
flex: 1;
padding: 20px;
background: #fff;
}
.footer {
background: #eee;
padding: 10px;
}
</style>
这里一共有三个“洞”:
name="header":头部- 没有
name的<slot>:默认插槽,主体 name="footer":底部
4.2 父组件:用 v-slot 或 # 指定往哪个洞塞内容
插槽内容必须包在 <template> 里,并用 v-slot:插槽名 或 #插槽名 指定名字。
默认插槽可以写 v-slot 或不写(直接写内容即表示默认插槽)。
父组件:
<template>
<Layout>
<template #header>
<h1>网站标题</h1>
<nav>导航链接...</nav>
</template>
<p>这里是页面的主体内容,会出现在默认插槽里。</p>
<template #footer>
<p>© 2025 版权所有</p>
</template>
</Layout>
</template>
<script setup>
import Layout from './Layout.vue'
</script>
#header是v-slot:header的简写,内容放到name="header"的插槽。- 中间那段
<p>...</p>没有包在<template v-slot>里,所以自动放进默认插槽。 #footer的内容放进name="footer"的插槽。
4.3 默认插槽的两种写法
有具名插槽时,默认插槽可以这样写:
写法一:不包 template(只适合默认插槽且只有一个默认插槽内容时)
<Layout>
<template #header>...</template>
<p>直接写这里就是默认插槽</p>
<template #footer>...</template>
</Layout>
写法二:包在 template 里并写 v-slot(推荐,更清晰)
<Layout>
<template #header>...</template>
<template #default>
<p>默认插槽内容</p>
</template>
<template #footer>...</template>
</Layout>
建议:有多个插槽时,默认插槽也用 <template #default> 写,不容易搞混。
五、作用域插槽(子组件把数据“传回”给父组件)
前面两种插槽都是:父组件 → 子组件传内容。
有时你希望:列表、表格、卡片等子组件负责循环数据,但每一行的展示方式由父组件决定(比如有的列要加粗、有的要按钮)。
这就需要子组件把当前这一项的数据通过插槽“传回”给父组件,父组件再按自己的方式渲染。这种“带数据的插槽”就是作用域插槽。
5.1 子组件:在 slot 上绑定属性(插槽 props)
在子组件的 <slot> 上通过 :属性名="值" 把数据传出去,这些属性在父组件里会收到一个“插槽 props”对象。
子组件 UserList.vue:
<template>
<ul>
<li v-for="user in users" :key="user.id">
<slot :user="user" :index="user.id">
<!-- 默认:父组件不传时显示名字 -->
{{ user.name }}
</slot>
</li>
</ul>
</template>
<script setup>
defineProps({
users: {
type: Array,
required: true
}
})
</script>
这里每个 <slot> 传出了两个“插槽 prop”:user(当前项)、index(这里用 id 示意)。
父组件会收到一个对象,例如:{ user: { id: 1, name: '小明', ... }, index: 1 }。
5.2 父组件:用 v-slot 接收并使用
在父组件里用 v-slot="变量" 或 v-slot:default="变量" 接收整个对象;也可以用解构只取需要的字段。
父组件:
<template>
<UserList :users="users">
<template #default="slotProps">
<strong>{{ slotProps.user.name }}</strong>
,年龄 {{ slotProps.user.age }}
</template>
</UserList>
</template>
<script setup>
import { ref } from 'vue'
import UserList from './UserList.vue'
const users = ref([
{ id: 1, name: '小明', age: 18 },
{ id: 2, name: '小红', age: 20 }
])
</script>
解构写法(更常用):
<template>
<UserList :users="users">
<template #default="{ user, index }">
第 {{ index }} 位:<strong>{{ user.name }}</strong>,{{ user.age }} 岁
</template>
</UserList>
</template>
这样父组件就完全控制了“每一行长什么样”,子组件只负责循环和传数据。
5.3 作用域插槽的典型场景:表格列自定义
子组件负责表头和循环行,每列是否可自定义由插槽决定;父组件用作用域插槽定义“姓名列”“操作列”的展示。
子组件 SimpleTable.vue:
<template>
<table class="table">
<thead>
<tr>
<th>姓名</th>
<th>年龄</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in data" :key="row.id">
<td>
<slot name="name" :row="row">{{ row.name }}</slot>
</td>
<td>{{ row.age }}</td>
<td>
<slot name="action" :row="row">
<button>默认按钮</button>
</slot>
</td>
</tr>
</tbody>
</table>
</template>
<script setup>
defineProps({
data: {
type: Array,
required: true
}
})
</script>
<style scoped>
.table {
border-collapse: collapse;
width: 100%;
}
.table th,
.table td {
border: 1px solid #ddd;
padding: 8px;
}
</style>
父组件:
<template>
<SimpleTable :data="list">
<template #name="{ row }">
<strong>{{ row.name }}</strong>
</template>
<template #action="{ row }">
<button @click="edit(row)">编辑</button>
<button @click="del(row)">删除</button>
</template>
</SimpleTable>
</template>
<script setup>
import { ref } from 'vue'
import SimpleTable from './SimpleTable.vue'
const list = ref([
{ id: 1, name: '小明', age: 18 },
{ id: 2, name: '小红', age: 20 }
])
function edit(row) {
console.log('编辑', row)
}
function del(row) {
console.log('删除', row)
}
</script>
这样“姓名列”和“操作列”的展示和逻辑都由父组件决定,表格组件只负责结构和数据,非常灵活。
六、动态插槽名
插槽名可以是变量,用方括号 [] 写。
子组件:
<template>
<div>
<slot name="header"></slot>
<slot name="body"></slot>
<slot name="footer"></slot>
</div>
</template>
父组件:
<template>
<div>
<MyComponent>
<template #[slotName]> 动态插槽内容 </template>
</MyComponent>
</div>
</template>
<script setup>
import { ref } from 'vue'
const slotName = ref('header')
// 改成 'body' 或 'footer' 会显示到对应插槽
</script>
适合根据状态(如 tab、步骤)切换不同插槽内容时使用。
七、插槽的简写与写法小结
| 写法 | 含义 |
|---|---|
<slot></slot> |
默认插槽,可带默认内容 |
<slot name="xxx"></slot> |
具名插槽 |
<slot :user="user"></slot> |
作用域插槽(传数据给父组件) |
<template #default> |
父组件:往默认插槽塞内容 |
<template #header> |
父组件:往 name=”header” 塞内容 |
<template #default="{ user }"> |
父组件:接收默认插槽的作用域并解构 |
<template #[dynamicName]> |
父组件:动态插槽名 |
v-slot可简写为#,例如#header、#default。- 默认插槽可写
#default或不写(直接写内容)。
八、综合示例:带插槽的卡片组件
下面是一个同时用到默认插槽、具名插槽、默认内容的卡片组件,方便整体理解。
子组件 AppCard.vue:
<template>
<div class="app-card">
<div class="app-card__header" v-if="hasHeaderSlot">
<slot name="header"></slot>
</div>
<div class="app-card__body">
<slot>
<p class="text-muted">暂无内容</p>
</slot>
</div>
<div class="app-card__footer" v-if="hasFooterSlot">
<slot name="footer"></slot>
</div>
</div>
</template>
<script setup>
import { useSlots, computed } from 'vue'
const slots = useSlots()
const hasHeaderSlot = computed(() => !!slots.header)
const hasFooterSlot = computed(() => !!slots.footer)
</script>
<style scoped>
.app-card {
border: 1px solid #eee;
border-radius: 8px;
overflow: hidden;
}
.app-card__header {
padding: 12px;
background: #fafafa;
border-bottom: 1px solid #eee;
}
.app-card__body {
padding: 16px;
}
.app-card__footer {
padding: 12px;
background: #fafafa;
border-top: 1px solid #eee;
}
.text-muted {
color: #999;
}
</style>
父组件多种用法:
<template>
<!-- 只用默认插槽 -->
<AppCard>
<p>只有主体内容</p>
</AppCard>
<!-- 头 + 默认 + 底 -->
<AppCard>
<template #header>
<span>标题</span>
</template>
<p>主体内容</p>
<template #footer>
<button>确定</button>
</template>
</AppCard>
<!-- 不传任何内容:显示“暂无内容” -->
<AppCard></AppCard>
</template>
这里用到了 useSlots() 判断是否有 header/footer 插槽内容,从而决定是否渲染头部和底部区域,属于进阶用法;看不懂可先忽略,只要理解三个插槽的用法即可。
九、常见问题与注意点
9.1 插槽内容是在父组件里渲染的
插槽里的内容是在父组件的作用域里编译的,所以:
- 插槽里用的数据、方法,都是父组件的;
- 只有作用域插槽里通过“插槽 props”传过来的数据,才是子组件传的。
9.2 具名插槽必须包在 template 里
下面这样是错的:
<!-- 错误:不能直接给普通元素写 v-slot -->
<Layout>
<p v-slot:header>标题</p>
</Layout>
正确写法:
<Layout>
<template #header>
<p>标题</p>
</template>
</Layout>
9.3 默认插槽与具名插槽混用时的顺序
顺序无所谓,Vue 会根据插槽名把内容放到对应的 <slot name="..."> 里。
但建议按“header → default → footer”顺序写,便于阅读。
9.4 作用域插槽的“作用域”是什么意思
“作用域”指的是:父组件里能拿到的变量范围。
在 <template #default="{ user }"> 里,user 是子组件通过 <slot :user="user"> 传出来的,所以父组件在这个 template 里就拥有了“当前行数据”的作用域,可以按需展示或绑定事件。
十、学习顺序建议
- 先练熟默认插槽:一个
<slot>,父组件中间写内容。 - 再练具名插槽:多个
<slot name="...">,父组件用<template #名字>分别塞内容。 - 最后练作用域插槽:子组件
<slot :xxx="数据">,父组件#default="{ xxx }"用数据自定义展示。 - 实际做一个小项目:用插槽做一个通用卡片、列表或布局组件,会掌握得更牢。
把本文档里的示例在项目里敲一遍、改一改,比只看文档效果更好。祝你学习顺利。