Vue3插槽

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>
  • #headerv-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 里就拥有了“当前行数据”的作用域,可以按需展示或绑定事件。


十、学习顺序建议

  1. 先练熟默认插槽:一个 <slot>,父组件中间写内容。
  2. 再练具名插槽:多个 <slot name="...">,父组件用 <template #名字> 分别塞内容。
  3. 最后练作用域插槽:子组件 <slot :xxx="数据">,父组件 #default="{ xxx }" 用数据自定义展示。
  4. 实际做一个小项目:用插槽做一个通用卡片、列表或布局组件,会掌握得更牢。

把本文档里的示例在项目里敲一遍、改一改,比只看文档效果更好。祝你学习顺利。

发表评论