ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vue 3 深入学习笔记 08:插槽(Slots)与组件的内容扩展设计

Vue 3 深入学习笔记 08:插槽(Slots)与组件的内容扩展设计

Vue 3 深入学习笔记 08:插槽(Slots)与组件的内容扩展设计

一、为什么需要学习插槽?

在前面第 06 篇中,我们学习了组件设计与职责边界;第 07 篇学习了v-model与受控组件设计。

现在我们继续思考一个真实的业务问题:

如果一个组件的外部结构固定,但是内部内容需要根据业务场景变化,应该怎么设计?

例如,我们需要开发一个通用的弹窗组件。

不同业务使用这个弹窗时,内容可能完全不同:

  • 登录弹窗:显示用户名、密码输入框。
  • 删除确认弹窗:显示删除提示。
  • 订单详情弹窗:显示订单信息。
  • 会员升级弹窗:显示会员权益和升级按钮。

如果不使用插槽,可能会写成这样:

<template> <div class="dialog"> <h3>{{ title }}</h3> <div v-if="type === 'login'"> <!-- 登录表单 --> </div> <div v-else-if="type === 'delete'"> <!-- 删除提示 --> </div> <div v-else-if="type === 'order'"> <!-- 订单详情 --> </div> </div> </template>

这种设计存在几个问题:

  1. 每增加一种业务场景,就需要修改弹窗组件。
  2. 弹窗组件逐渐承担越来越多的业务职责。
  3. 组件与具体业务强耦合,复用性越来越差。
  4. 如果业务内容特别复杂,组件内部就会充满v-if、v-else-if。

我们真正希望实现的是:

弹窗组件负责弹窗的结构和行为,具体显示什么内容,由使用它的父组件决定。

这就是插槽的核心价值。

二、什么是插槽?

插槽(Slot)可以理解为:子组件预留一个内容位置,由父组件决定向这个位置传入什么内容。

先看一个最简单的例子。

2.1 不使用插槽

子组件MyButton.vue:

<template> <button>提交</button> </template>

父组件:

<template> <MyButton /> </template>

最终显示:

提交

问题是,如果其他页面希望按钮显示“取消”,怎么办?

如果按钮文字写死在子组件里,就只能修改子组件,或者额外设计一个text属性。

2.2 使用插槽

子组件MyButton.vue:

<template> <button> <slot></slot> </button> </template>

父组件:

<template> <MyButton> 提交 </MyButton> <MyButton> 取消 </MyButton> <MyButton> 保存 </MyButton> </template>

这里的<slot></slot>就是插槽。

父组件传入的内容,会出现在子组件<slot>所在的位置。

最终相当于:

<button>提交</button> <button>取消</button> <button>保存</button>

注意:这里展示的是便于理解的最终 DOM 结构,Vue 实际还会处理组件渲染等细节。

2.3 插槽和 props 有什么区别?

这是本篇最重要的问题之一。

props主要用于向子组件传递数据;插槽主要用于向子组件传递内容结构。

例如:

<MyButton text="提交" />

使用props:

<script setup> defineProps({ text: String }) </script> <template> <button>{{ text }}</button> </template>

这种方式适合简单、固定类型的数据,例如文字、颜色、尺寸、状态等。

而使用插槽:

<MyButton> <span>提交</span> <strong>订单</strong> </MyButton>

父组件可以传入多个元素,甚至传入其他组件。

对比项propsslots
主要用途传递数据和配置传递内容结构
典型内容文字、数字、布尔值、对象HTML 元素、组件及其组合
灵活性由子组件定义数据接口父组件可以自由组合内容
适合场景按钮尺寸、标题、加载状态弹窗内容、卡片主体、表格单元格

这里并不是说props不能控制内容,或者插槽不能接收数据,而是两者的设计职责不同。

例如,一个弹窗的标题通常适合用props传入;弹窗主体则通常适合使用插槽。

三、默认插槽(Default Slot)

默认插槽是最常见的插槽类型。

3.1 基本用法

子组件BaseCard.vue:

<template> <div class="card"> <slot></slot> </div> </template>

父组件:

<template> <BaseCard> <h3>订单信息</h3> <p>订单编号:10001</p> <p>订单金额:500 元</p> </BaseCard> </template>

最终结构可以理解为:

<div class="card"> <h3>订单信息</h3> <p>订单编号:10001</p> <p>订单金额:500 元</p> </div>

BaseCard只负责卡片容器的结构和样式,内部展示哪些元素,由父组件决定。

这是一种很常见的通用组件设计方式。

3.2 插槽的默认内容

插槽还可以提供默认内容。

子组件:

<template> <button> <slot>默认按钮</slot> </button> </template>

父组件:

<template> <BaseButton /> <BaseButton>保存</BaseButton> </template>

结果:

  • 第一个按钮显示“默认按钮”。
  • 第二个按钮显示“保存”。

只有父组件没有提供有效的插槽内容时,才会使用插槽中的默认内容。

3.3 实际开发中如何理解?

插槽默认内容适合用于提供合理的兜底显示。

例如:

<template> <div class="empty-state"> <slot> 暂无数据 </slot> </div> </template>

调用方可以直接使用默认提示,也可以自定义空状态内容:

<EmptyState> <img src="/empty.png" alt="暂无数据" /> <p>当前没有订单</p> </EmptyState>

这样,通用组件不必知道具体业务需要展示什么空状态。

四、具名插槽(Named Slots)

前面的例子只有一个插槽。

但是,假设我们设计一个弹窗组件,它有三个区域:

  • 标题区域。
  • 主体内容区域。
  • 底部按钮区域。

如果只有一个默认插槽,父组件就很难分别控制这三个区域。

这时候就需要具名插槽。

4.1 子组件定义多个插槽

BaseDialog.vue:

<template> <div class="dialog"> <header class="dialog-header"> <slot name="header"> 默认标题 </slot> </header> <main class="dialog-body"> <slot></slot> </main> <footer class="dialog-footer"> <slot name="footer"></slot> </footer> </div> </template>

这里有三个插槽:

插槽名称用途
header标题区域
默认插槽主体内容区域
footer底部操作区域

4.2 父组件指定插槽内容

<template> <BaseDialog> <template #header> <h3>删除订单</h3> </template> <p>确定要删除这笔订单吗?</p> <template #footer> <button>取消</button> <button>确认删除</button> </template> </BaseDialog> </template>

这里的#header是v-slot:header的简写。

例如:

<template #header> <h3>删除订单</h3> </template>

等价于:

<template v-slot:header> <h3>删除订单</h3> </template>

最终效果是:

  • 标题显示在header插槽的位置。
  • 删除提示显示在默认插槽的位置。
  • 两个操作按钮显示在footer插槽的位置。

具名插槽的本质,是让父组件分别向子组件的不同内容区域提供内容。

4.3 为什么不能直接随便写一个元素?

例如:

<BaseDialog> <h3>删除订单</h3> <p>确定要删除吗?</p> </BaseDialog>

这两个元素都会进入默认插槽,而不会自动进入标题区域。

插槽的分配依据是插槽名称,而不是内容看起来像什么。

4.4 具名插槽的设计价值

实际项目中,很多组件都具有相对固定的区域结构。

例如:

BaseCard ├── header ├── default └── footer
BaseDialog ├── header ├── default └── footer
BaseLayout ├── sidebar ├── header ├── default └── footer

这些组件可以规定页面结构,但不需要规定每个区域具体展示什么内容。

这种设计特别适合通用 UI 组件库。

五、作用域插槽(Scoped Slots)

接下来进入本篇最值得深入理解的知识点。

默认插槽和具名插槽解决了内容扩展的问题,但还存在一个问题:

如果父组件传入的内容,需要使用子组件内部的数据,怎么办?

例如,子组件内部维护了一组商品数据,希望父组件决定每个商品如何展示。

这时候就需要作用域插槽。

5.1 先理解一个重要规则

Vue 中,父组件模板中的表达式,默认在父组件的作用域中解析;子组件模板中的表达式,则在子组件的作用域中解析。

看一个例子。

子组件ProductList.vue:

<script setup> import { ref } from 'vue' const products = ref([ { id: 1, name: '键盘', price: 200 }, { id: 2, name: '鼠标', price: 100 } ]) </script> <template> <ul> <li v-for="product in products" :key="product.id"> {{ product.name }} </li> </ul> </template>

父组件:

<script setup> const title = '商品列表' </script> <template> <ProductList> {{ title }} </ProductList> </template>

这里有一个容易产生的误解:

父组件把title放在了ProductList标签内部,就认为它可以直接访问子组件的products。

实际上并不是这样。

插槽内容属于父组件的模板作用域,不能因为它写在子组件标签里面,就自动访问子组件内部的变量。

如果父组件希望根据子组件内部的product数据,自定义商品的展示方式,就需要让子组件通过插槽向父组件提供数据。

5.2 子组件向插槽暴露数据

修改ProductList.vue:

<script setup> import { ref } from 'vue' const products = ref([ { id: 1, name: '键盘', price: 200 }, { id: 2, name: '鼠标', price: 100 } ]) </script> <template> <ul> <li v-for="product in products" :key="product.id" > <slot :product="product"></slot> </li> </ul> </template>

重点看这一行:

<slot :product="product"></slot>

它的含义是:

子组件将当前商品对象通过插槽参数product暴露给父组件。

注意,:product="product"中:

  • 左侧product是插槽参数的名称。
  • 右侧product是子组件当前作用域中的变量。

5.3 父组件接收插槽数据

父组件:

<template> <ProductList> <template #default="{ product }"> {{ product.name }}:{{ product.price }} 元 </template> </ProductList> </template>

这里的:

<template #default="{ product }">

表示接收默认插槽提供的数据,并从中解构出product。

最终显示:

键盘:200 元 鼠标:100 元

现在,父组件可以自由决定商品的展示形式。

例如,只显示名称:

<ProductList> <template #default="{ product }"> {{ product.name }} </template> </ProductList>

或者显示名称和价格:

<ProductList> <template #default="{ product }"> <strong>{{ product.name }}</strong> <span>价格:{{ product.price }} 元</span> </template> </ProductList>

或者根据价格决定展示样式:

<ProductList> <template #default="{ product }"> <strong>{{ product.name }}</strong> <span v-if="product.price >= 150"> 高价商品 </span> <span v-else> 普通商品 </span> </template> </ProductList>

子组件负责维护数据、遍历数据和提供展示位置;父组件负责决定每一项如何展示。

5.4 作用域插槽的数据流

可以将其理解为:

子组件拥有数据 ↓ 子组件通过 slot 参数暴露数据 ↓ 父组件接收插槽参数 ↓ 父组件决定如何展示数据

这与props的数据传递方向不同。

普通的props是父组件向子组件传递数据,而作用域插槽可以让子组件向父组件提供数据,供父组件定义插槽内容时使用。

这里需要注意:这并不意味着父组件获得了子组件的全部内部状态。子组件只暴露了它明确传出的插槽参数。

六、作用域插槽的真实业务应用:通用表格

在实际业务项目中,作用域插槽的价值非常大。

例如,一个通用表格组件,需要展示订单列表。

不同页面的要求可能不同:

  • 订单管理页面展示订单金额。
  • 售后页面展示售后状态。
  • 财务页面展示结算状态。
  • 管理页面需要增加操作按钮。

如果每种展示方式都写进表格组件,表格组件就会不断增加业务判断。

我们可以利用作用域插槽解决这个问题。

6.1 通用表格组件

BaseTable.vue:

<script setup> defineProps({ rows: { type: Array, required: true } }) </script> <template> <table> <tbody> <tr v-for="row in rows" :key="row.id" > <td> <slot name="default" :row="row" > {{ row.name }} </slot> </td> </tr> </tbody> </table> </template>

这个组件只负责:

  1. 接收表格数据。
  2. 遍历每一行。
  3. 提供每一行的插槽内容位置。
  4. 提供一个默认展示方案。

它不关心当前页面到底是订单管理、售后管理还是财务管理。

6.2 页面 A:展示订单金额

父组件:

<script setup> import { ref } from 'vue' const orders = ref([ { id: 1, name: '订单 A', amount: 200 }, { id: 2, name: '订单 B', amount: 500 } ]) </script> <template> <BaseTable :rows="orders"> <template #default="{ row }"> <span> {{ row.name }}: {{ row.amount }} 元 </span> </template> </BaseTable> </template>

6.3 页面 B:展示操作按钮

同样使用BaseTable:

<template> <BaseTable :rows="orders"> <template #default="{ row }"> <span>{{ row.name }}</span> <button @click="viewOrder(row.id)"> 查看 </button> <button @click="cancelOrder(row.id)"> 取消订单 </button> </template> </BaseTable> </template>

这里的viewOrder和cancelOrder由父组件定义。

父组件可以根据自己的业务需求,决定显示哪些按钮以及点击后执行什么操作。

表格组件不需要知道这些业务行为。

6.4 这个设计好在哪里?

它把两个变化相对独立的部分分离了:

  • 表格结构和数据遍历:由通用组件负责。
  • 每一行的具体展示和业务操作:由调用方负责。

当业务新增一种展示方式时,通常只需要修改父组件,不需要修改通用表格。

不过,实际表格通常还需要支持列配置、表头、排序、分页等能力。这里的示例只是为了说明作用域插槽的核心思想。

七、插槽与组件设计职责边界

学习插槽,不能只停留在语法层面,更重要的是理解它在组件设计中的作用。

我们比较两种设计。

7.1 设计一:组件内部包含所有业务判断

<template> <div class="card"> <div v-if="type === 'order'"> <!-- 订单内容 --> </div> <div v-else-if="type === 'user'"> <!-- 用户内容 --> </div> <div v-else-if="type === 'product'"> <!-- 商品内容 --> </div> </div> </template>

问题:

  • 通用组件必须理解所有业务类型。
  • 新增业务类型时,需要修改通用组件。
  • 业务逻辑与 UI 结构混杂。
  • 测试范围逐渐扩大。

7.2 设计二:组件提供插槽

子组件:

<template> <div class="card"> <slot></slot> </div> </template>

父组件:

<template> <BaseCard> <OrderDetail /> </BaseCard> <BaseCard> <UserDetail /> </BaseCard> <BaseCard> <ProductDetail /> </BaseCard> </template>

这样,BaseCard不需要知道订单、用户、商品的具体业务内容。

它只负责卡片容器。

7.3 什么时候使用 props,什么时候使用插槽?

可以参考以下判断方式。

适合使用 props 的情况:

  • 传递简单配置。
  • 控制组件状态或外观。
  • 子组件可以根据固定的数据接口完成渲染。

例如:

<BaseButton text="保存" size="large" disabled />

适合使用插槽的情况:

  • 需要自定义一块内容区域。
  • 需要传入多个元素或其他组件。
  • 不同业务场景需要不同的展示结构。
  • 希望通用组件不依赖具体业务内容。

例如:

<BaseDialog> <OrderDetail /> </BaseDialog>

适合使用作用域插槽的情况:

  • 子组件掌握数据。
  • 父组件需要决定数据的展示方式。
  • 希望复用数据处理逻辑,同时允许不同页面自定义 UI。

例如:

<ProductList> <template #default="{ product }"> {{ product.name }}:{{ product.price }} </template> </ProductList>

需要注意:插槽并不是越多越好。如果组件只有一个简单标题,使用title属性通常比设计一个标题插槽更直接。

八、插槽与 props、emit 的关系

我们已经学习了props、emit、v-model,现在把它们与插槽放在一起理解。

一个成熟的业务组件,通常需要解决三个不同的问题:

机制主要解决的问题方向或作用
props父组件如何向子组件传递数据父 → 子
emit子组件如何通知父组件发生了某件事子 → 父
slots父组件如何自定义子组件内部的内容结构父定义内容,子提供位置
作用域插槽父组件如何使用子组件提供的数据自定义内容子提供插槽参数,父定义展示

例如,设计一个通用弹窗:

<script setup> defineProps({ title: String, visible: Boolean }) const emit = defineEmits([ 'close', 'confirm' ]) </script> <template> <div v-if="visible" class="dialog"> <header> <h3>{{ title }}</h3> </header> <main> <slot></slot> </main> <footer> <slot name="footer"> <button @click="emit('close')"> 关闭 </button> </slot> </footer> </div> </template>

调用方:

<template> <BaseDialog title="删除订单" :visible="visible" @close="visible = false" @confirm="deleteOrder" > <p>确定要删除这笔订单吗?</p> <template #footer> <button @click="visible = false"> 取消 </button> <button @click="deleteOrder"> 确认删除 </button> </template> </BaseDialog> </template>

这个示例展示了几种机制的分工:

  • title和visible:使用props传递数据。
  • close和confirm:通过事件通知父组件。
  • 默认插槽:允许父组件自定义弹窗主体。
  • footer插槽:允许父组件自定义底部按钮。

不过,这段代码中声明了confirm事件,却没有在子组件模板中主动触发它;父组件示例直接处理了自己的删除按钮。实际开发时,应根据设计选择由弹窗组件触发confirm,还是由父组件的自定义按钮直接执行操作,不要保留没有实际用途的事件接口。

九、插槽的常见误区

9.1 误区一:认为插槽内容属于子组件作用域

错误理解:

<ProductList> {{ products }} </ProductList>

认为这里的products会自动指向子组件内部的变量。

实际上,插槽内容中的表达式默认属于父组件的作用域。如果父组件自身没有定义products,就不能通过这种方式直接使用子组件的变量。

需要通过作用域插槽显式传递:

<ProductList> <template #default="{ product }"> {{ product.name }} </template> </ProductList>

9.2 误区二:把所有内容都设计成插槽

例如,一个按钮组件只有文字不同:

<BaseButton text="保存" />

如果为了显示“保存”就设计一套复杂的插槽接口,反而会增加使用成本。

如果变化只是简单文本,使用props往往更合适。

如果按钮需要图标、文字、加载动画等复杂组合,才更有理由使用插槽。

9.3 误区三:把作用域插槽当作子组件的数据修改入口

作用域插槽的主要用途是暴露数据供父组件展示,并不意味着父组件应该随意修改子组件内部状态。

例如:

<slot :product="product"></slot>

父组件拿到product后,最好按照明确的数据流约定处理修改。

如果需要修改商品,通常应设计明确的事件、v-model或由父组件管理数据,而不是依赖插槽参数进行隐式修改。

9.4 误区四:插槽越灵活,组件设计就越好

插槽可以提升组件的扩展能力,但过多、过细的插槽也可能让组件接口变得复杂。

例如,一个简单的信息提示组件,如果设计了十几个插槽,使用者可能反而不知道应该在哪个位置传入内容。

设计组件时,应优先明确:

  1. 哪些结构应该固定。
  2. 哪些内容需要由调用方决定。
  3. 哪些数据应该通过props提供。
  4. 哪些行为需要通过事件通知。

然后再决定插槽的数量和粒度。

十、课后练习

练习一:默认插槽

设计一个BasePanel.vue,要求:

  • 外层使用div。
  • 固定显示一个标题“面板”。
  • 主体内容由父组件传入。
  • 如果父组件没有传入内容,显示“暂无内容”。

练习二:具名插槽

设计一个BaseDialog.vue,要求:

  • 提供header插槽。
  • 提供默认插槽作为主体。
  • 提供footer插槽。
  • 标题插槽没有内容时,默认显示“提示”。

练习三:作用域插槽

设计一个UserList.vue,内部维护以下数据:

[ { id: 1, name: '张三', age: 20 }, { id: 2, name: '李四', age: 25 } ]

要求:

  • 子组件负责遍历用户。
  • 子组件通过插槽提供当前用户数据。
  • 父组件自行决定展示用户名和年龄。

练习四:组件设计题

你正在开发一个通用的商品卡片组件。

商品卡片包含:

  • 商品图片。
  • 商品名称。
  • 商品价格。
  • 购买按钮。

请思考:

  1. 哪些内容适合通过props传递?
  2. 哪些内容适合使用插槽?
  3. 如果某个页面需要显示“促销标签”,另一个页面需要显示“库存状态”,应该怎么设计?
  4. 如果购买按钮的行为因页面而异,应该如何处理?

十一、课后练习答案

练习一参考答案

BasePanel.vue:

<template> <div class="panel"> <h3>面板</h3> <div class="panel-content"> <slot>暂无内容</slot> </div> </div> </template>

父组件:

<template> <BasePanel> <p>这里是自定义内容。</p> </BasePanel> <BasePanel /> </template>

第一个组件显示自定义内容,第二个组件显示“暂无内容”。

练习二参考答案

BaseDialog.vue:

<template> <div class="dialog"> <header> <slot name="header"> 提示 </slot> </header> <main> <slot></slot> </main> <footer> <slot name="footer"></slot> </footer> </div> </template>

父组件:

<template> <BaseDialog> <template #header> <h3>删除确认</h3> </template> <p>确定要删除吗?</p> <template #footer> <button>取消</button> <button>确认</button> </template> </BaseDialog> </template>

这里使用具名插槽分别控制标题和底部内容,默认插槽负责主体内容。

练习三参考答案

UserList.vue:

<script setup> import { ref } from 'vue' const users = ref([ { id: 1, name: '张三', age: 20 }, { id: 2, name: '李四', age: 25 } ]) </script> <template> <ul> <li v-for="user in users" :key="user.id" > <slot :user="user"> {{ user.name }} </slot> </li> </ul> </template>

父组件:

<template> <UserList> <template #default="{ user }"> <strong>{{ user.name }}</strong> <span>年龄:{{ user.age }}</span> </template> </UserList> </template>

这里子组件通过:user="user"暴露数据,父组件通过{ user }接收数据。

练习四参考答案

一种合理的设计是:

  • 商品 ID、图片地址、名称、价格:通过props传入。
  • 促销标签、库存状态:如果只是简单状态文本,可以使用props;如果展示结构差异较大,可以使用具名插槽。
  • 购买按钮行为:可以通过emit通知父组件,也可以允许父组件通过插槽完全自定义按钮。
  • 商品卡片的基础布局、间距、图片区域:由组件自身负责。

例如:

<script setup> defineProps({ product: { type: Object, required: true } }) const emit = defineEmits(['buy']) </script> <template> <div class="product-card"> <img :src="product.image" :alt="product.name" /> <h3>{{ product.name }}</h3> <p>{{ product.price }} 元</p> <div class="product-extra"> <slot name="extra"></slot> </div> <slot name="actions"> <button @click="emit('buy', product)"> 购买 </button> </slot> </div> </template>

父组件可以这样使用:

<template> <ProductCard :product="product" @buy="handleBuy" > <template #extra> <span>限时促销</span> </template> <template #actions> <button @click="addToCart(product)"> 加入购物车 </button> <button @click="buyNow(product)"> 立即购买 </button> </template> </ProductCard> </template>

这套设计同时使用了props、emit和插槽,各自承担不同职责。

不过,如果父组件已经通过actions插槽完全替换了购买按钮,默认的buy事件就不一定会被触发。实际项目中应明确约定:使用默认按钮时由组件发出buy事件,自定义按钮时由父组件自行处理,避免出现职责不清的问题。

十二、本篇总结

本篇需要真正理解的,不是<slot>的写法,而是它背后的组件设计思想。

  1. 默认插槽:父组件向子组件的默认内容区域传入内容。
  2. 具名插槽:父组件向子组件的指定区域传入内容。
  3. 作用域插槽:子组件通过插槽参数暴露数据,让父组件决定如何展示。
  4. props 适合传递数据和配置;插槽适合扩展内容结构。
  5. emit 用于通知父组件发生了某个事件。
  6. 插槽可以帮助通用组件与具体业务解耦,但插槽设计也需要适度。

最终要建立的思维是:

组件不一定需要知道内部具体展示什么,它可以只负责提供结构、行为和必要的数据接口,再把变化的内容交给调用方。

这就是插槽在大型 Vue 项目中的核心价值。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进