插槽的作用域与分发:如何让组件更灵活、可定制?
- 前言
- 插槽设计的初衷
-
- 组件的可定制性需求
- 插槽解决了什么问题
- 三种插槽的使用场景
-
- 默认插槽:简单的内容占位
- 具名插槽:多个位置的定制
- 作用域插槽:让父组件访问子组件的数据
- 作用域插槽的深入理解
-
- 数据流向:子组件暴露数据,父组件决定渲染
- 解构插槽 prop 的技巧
- 渲染作用域:父组件模板只能访问父组件的数据
- 高级技巧:动态插槽与递归组件
-
- 动态插槽名的使用场景
- 递归组件中插槽的处理
- 渲染函数中的插槽使用
- 性能考量
-
- 插槽内容的重渲染机制
- 使用 v-slot 的缓存优化
- 避免不必要的插槽重绘
- 插槽设计的最佳实践
-
- 插槽使用决策树
- 插槽设计的最佳实践清单
- 结语
前言
在 Vue 组件设计中,我们经常面临这样一个困境:组件需要足够通用,但又要在不同场景下展现不同的内容。这种情况怎么处理呢?通过 Props 传递模板内容?那会让组件变得臃肿不堪。通过事件让父组件控制渲染?那又违背了组件的封装性原则。
插槽(Slots)的出现完美解决了这个问题。它让组件既能保持核心逻辑的内聚,又能将部分渲染的控制权交给使用者。本文将深入探讨插槽的三种类型、作用域插槽的数据传递机制,以及如何通过插槽构建高度可定制的组件。
插槽设计的初衷
组件的可定制性需求
想象一下我们正在开发一个卡片组件,不同的页面需要不同的卡片内容:
<!– 产品页面需要的卡片 –>
<Card>
<img :src="product.image" />
<h3>{{ product.name }}</h3>
<p>{{ product.price }}</p>
<button @click="addToCart">加入购物车</button>
</Card>
<!– 文章页面需要的卡片 –>
<Card>
<h2>{{ article.title }}</h2>
<p>{{ article.summary }}</p>
<div class="meta">
<span>{{ article.author }}</span>
<span>{{ article.date }}</span>
</div>
</Card>
在没有插槽的情况下,我们可能会设计出这样的组件:
<!– ❌ 反模式:通过 Props 控制内容 –>
<Card
:show-image="true"
:image-src="product.image"
:title="product.name"
:description="product.price"
:show-button="true"
button-text="加入购物车"
@button-click="addToCart"
/>
这种设计的问题显而易见:
- Props 爆炸:为了覆盖所有场景,Props 会越来越多
- 灵活性差:无法实现 Props 没覆盖到的布局
- 逻辑复杂:组件内部需要大量条件判断
插槽解决了什么问题
三种插槽的使用场景
默认插槽:简单的内容占位
默认插槽是最简单的形式,适合单一内容区域:
<!– Button.vue – 一个可定制的按钮 –>
<template>
<button class="btn" :class="[`btn-${type}`, `btn-${size}`]">
<slot>
<!– 默认内容:如果没有提供插槽内容,显示这个 –>
<span>按钮</span>
</slot>
</button>
</template>
<script setup>
defineProps<{
type?: 'primary' | 'secondary' | 'danger'
size?: 'small' | 'medium' | 'large'
}>()
</script>
<!– 使用 –>
<Button type="primary" size="large">
<Icon name="plus" />
<span>创建新项目</span>
</Button>
<Button type="secondary">
取消
</Button>
<Button>
<!– 使用默认内容 –>
</Button>
适用场景:
- 模态框的内容区域
- 卡片的主要内容
- 按钮的文本内容
具名插槽:多个位置的定制
当组件有多个需要定制的位置时,使用具名插槽:
<!– Modal.vue – 一个灵活的模态框组件 –>
<template>
<div class="modal-overlay" @click.self="$emit('close')">
<div class="modal">
<!– 头部 –>
<header class="modal-header">
<slot name="header">
<h3>默认标题</h3>
</slot>
<button class="close-btn" @click="$emit('close')">×</button>
</header>
<!– 主体 –>
<main class="modal-body">
<slot name="body">
<p>默认内容</p>
</slot>
</main>
<!– 底部 –>
<footer class="modal-footer">
<slot name="footer">
<button @click="$emit('close')">关闭</button>
</slot>
</footer>
</div>
</div>
</template>
<script setup>
defineEmits(['close'])
</script>
<!– 使用 –>
<Modal @close="showModal = false">
<template #header>
<h2>确认删除</h2>
<p class="warning">此操作不可恢复</p>
</template>
<template #body>
<p>确定要删除 "{{ item.name }}" 吗?</p>
</template>
<template #footer>
<button class="cancel" @click="showModal = false">取消</button>
<button class="confirm" @click="handleDelete">确认删除</button>
</template>
</Modal>
适用场景:
- 模态框的头部/主体/底部
- 页面的侧边栏/主内容/底部
- 表格的操作列/状态列
作用域插槽:让父组件访问子组件的数据
这是插槽最强大的形式,它允许父组件访问子组件内部的数据:
<!– List.vue – 一个可定制的列表组件 –>
<template>
<div class="list">
<div v-if="loading" class="loading">
<slot name="loading">
<span>加载中…</span>
</slot>
</div>
<div v-else-if="error" class="error">
<slot name="error" :error="error">
<span>出错了: {{ error.message }}</span>
</slot>
</div>
<div v-else-if="items.length === 0" class="empty">
<slot name="empty">
<span>暂无数据</span>
</slot>
</div>
<div v-else class="list-items">
<div
v-for="(item, index) in items"
:key="item.id"
class="list-item"
>
<!– 作用域插槽:将 item 数据暴露给父组件 –>
<slot
:item="item"
:index="index"
:is-first="index === 0"
:is-last="index === items.length – 1"
>
<!– 默认渲染方式 –>
<span>{{ item.name }}</span>
</slot>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Item {
id: number
name: string
[key: string]: any
}
defineProps<{
items: Item[]
loading?: boolean
error?: Error | null
}>()
</script>
<!– 使用:完全自定义每个项的渲染方式 –>
<template>
<List :items="users" :loading="isLoading">
<!– 自定义每个用户的显示方式 –>
<template #default="{ item, index }">
<div class="user-item" :class="{ 'is-even': index % 2 === 0 }">
<img :src="item.avatar" class="avatar" />
<div class="info">
<h4>{{ item.name }}</h4>
<p>{{ item.email }}</p>
</div>
<button @click="follow(item.id)">关注</button>
</div>
</template>
<!– 自定义空状态 –>
<template #empty>
<div class="custom-empty">
<EmptyIcon />
<p>还没有用户,<a href="#">立即创建</a></p>
</div>
</template>
<!– 自定义加载状态 –>
<template #loading>
<SkeletonLoader />
</template>
<!– 自定义错误状态,可以访问 error 对象 –>
<template #error="{ error }">
<div class="custom-error">
<ErrorIcon />
<p>{{ error.message }}</p>
<button @click="retry">重试</button>
</div>
</template>
</List>
</template>
适用场景:
- 列表/表格的自定义行渲染
- 下拉选项的自定义选项显示
- 树形组件的节点渲染
- 任何需要根据数据定制显示的场景
作用域插槽的深入理解
数据流向:子组件暴露数据,父组件决定渲染
理解作用域插槽的关键是明白其数据流向:
#mermaid-svg-Vdwlj91k04GjadNz{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Vdwlj91k04GjadNz .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Vdwlj91k04GjadNz .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Vdwlj91k04GjadNz .error-icon{fill:#552222;}#mermaid-svg-Vdwlj91k04GjadNz .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Vdwlj91k04GjadNz .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Vdwlj91k04GjadNz .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Vdwlj91k04GjadNz .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Vdwlj91k04GjadNz .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Vdwlj91k04GjadNz .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Vdwlj91k04GjadNz .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Vdwlj91k04GjadNz .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Vdwlj91k04GjadNz .marker.cross{stroke:#333333;}#mermaid-svg-Vdwlj91k04GjadNz svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Vdwlj91k04GjadNz p{margin:0;}#mermaid-svg-Vdwlj91k04GjadNz .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-Vdwlj91k04GjadNz .cluster-label text{fill:#333;}#mermaid-svg-Vdwlj91k04GjadNz .cluster-label span{color:#333;}#mermaid-svg-Vdwlj91k04GjadNz .cluster-label span p{background-color:transparent;}#mermaid-svg-Vdwlj91k04GjadNz .label text,#mermaid-svg-Vdwlj91k04GjadNz span{fill:#333;color:#333;}#mermaid-svg-Vdwlj91k04GjadNz .node rect,#mermaid-svg-Vdwlj91k04GjadNz .node circle,#mermaid-svg-Vdwlj91k04GjadNz .node ellipse,#mermaid-svg-Vdwlj91k04GjadNz .node polygon,#mermaid-svg-Vdwlj91k04GjadNz .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Vdwlj91k04GjadNz .rough-node .label text,#mermaid-svg-Vdwlj91k04GjadNz .node .label text,#mermaid-svg-Vdwlj91k04GjadNz .image-shape .label,#mermaid-svg-Vdwlj91k04GjadNz .icon-shape .label{text-anchor:middle;}#mermaid-svg-Vdwlj91k04GjadNz .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Vdwlj91k04GjadNz .rough-node .label,#mermaid-svg-Vdwlj91k04GjadNz .node .label,#mermaid-svg-Vdwlj91k04GjadNz .image-shape .label,#mermaid-svg-Vdwlj91k04GjadNz .icon-shape .label{text-align:center;}#mermaid-svg-Vdwlj91k04GjadNz .node.clickable{cursor:pointer;}#mermaid-svg-Vdwlj91k04GjadNz .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Vdwlj91k04GjadNz .arrowheadPath{fill:#333333;}#mermaid-svg-Vdwlj91k04GjadNz .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Vdwlj91k04GjadNz .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Vdwlj91k04GjadNz .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Vdwlj91k04GjadNz .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Vdwlj91k04GjadNz .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Vdwlj91k04GjadNz .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Vdwlj91k04GjadNz .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Vdwlj91k04GjadNz .cluster text{fill:#333;}#mermaid-svg-Vdwlj91k04GjadNz .cluster span{color:#333;}#mermaid-svg-Vdwlj91k04GjadNz div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Vdwlj91k04GjadNz .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Vdwlj91k04GjadNz rect.text{fill:none;stroke-width:0;}#mermaid-svg-Vdwlj91k04GjadNz .icon-shape,#mermaid-svg-Vdwlj91k04GjadNz .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Vdwlj91k04GjadNz .icon-shape p,#mermaid-svg-Vdwlj91k04GjadNz .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Vdwlj91k04GjadNz .icon-shape rect,#mermaid-svg-Vdwlj91k04GjadNz .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Vdwlj91k04GjadNz .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Vdwlj91k04GjadNz .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Vdwlj91k04GjadNz :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
通过插槽 Props 暴露
决定如何渲染
渲染到
子组件数据
父组件
插槽内容
关键点:
- 数据由子组件提供(暴露给父组件)
- 渲染逻辑由父组件控制(如何使用这些数据)
- 最终内容仍渲染在子组件内部(保持 DOM 结构)
解构插槽 prop 的技巧
作用域插槽的 props 可以使用解构,让代码更简洁:
<template>
<!– 基础用法 –>
<List :items="products">
<template #default="slotProps">
<div>{{ slotProps.item.name }} – {{ slotProps.item.price }}</div>
</template>
</List>
<!– ✅ 解构用法 –>
<List :items="products">
<template #default="{ item, index }">
<div class="product-item">
<span class="index">{{ index + 1 }}.</span>
<span class="name">{{ item.name }}</span>
<span class="price">¥{{ item.price }}</span>
</div>
</template>
</List>
<!– ✅ 重命名解构 –>
<List :items="products">
<template #default="{ item: product, index: position }">
<div>{{ position }}: {{ product.name }}</div>
</template>
</List>
<!– ✅ 设置默认值 –>
<List :items="products">
<template #default="{ item = {}, index = -1 }">
<div>{{ item.name || '未知商品' }}</div>
</template>
</List>
</template>
渲染作用域:父组件模板只能访问父组件的数据
这是一个容易混淆的概念:插槽内容在父组件中编写,所以只能访问父组件的作用域:
<!– 父组件 –>
<template>
<ChildComponent>
<template #default="{ childData }">
<!– ✅ 可以访问父组件数据 –>
<div>{{ parentData }}</div>
<!– ✅ 可以访问插槽 prop –>
<div>{{ childData }}</div>
<!– ❌ 不能访问子组件的其他数据 –>
<div>{{ someOtherChildData }}</div>
</template>
</ChildComponent>
</template>
<script setup>
import { ref } from 'vue'
const parentData = ref('这是父组件数据')
</script>
渲染作用域图解:
#mermaid-svg-vdqoy8fkrs5D5Opi{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vdqoy8fkrs5D5Opi .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vdqoy8fkrs5D5Opi .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vdqoy8fkrs5D5Opi .error-icon{fill:#552222;}#mermaid-svg-vdqoy8fkrs5D5Opi .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vdqoy8fkrs5D5Opi .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vdqoy8fkrs5D5Opi .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vdqoy8fkrs5D5Opi .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vdqoy8fkrs5D5Opi .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vdqoy8fkrs5D5Opi .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vdqoy8fkrs5D5Opi .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vdqoy8fkrs5D5Opi .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vdqoy8fkrs5D5Opi .marker.cross{stroke:#333333;}#mermaid-svg-vdqoy8fkrs5D5Opi svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vdqoy8fkrs5D5Opi p{margin:0;}#mermaid-svg-vdqoy8fkrs5D5Opi .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-vdqoy8fkrs5D5Opi .cluster-label text{fill:#333;}#mermaid-svg-vdqoy8fkrs5D5Opi .cluster-label span{color:#333;}#mermaid-svg-vdqoy8fkrs5D5Opi .cluster-label span p{background-color:transparent;}#mermaid-svg-vdqoy8fkrs5D5Opi .label text,#mermaid-svg-vdqoy8fkrs5D5Opi span{fill:#333;color:#333;}#mermaid-svg-vdqoy8fkrs5D5Opi .node rect,#mermaid-svg-vdqoy8fkrs5D5Opi .node circle,#mermaid-svg-vdqoy8fkrs5D5Opi .node ellipse,#mermaid-svg-vdqoy8fkrs5D5Opi .node polygon,#mermaid-svg-vdqoy8fkrs5D5Opi .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vdqoy8fkrs5D5Opi .rough-node .label text,#mermaid-svg-vdqoy8fkrs5D5Opi .node .label text,#mermaid-svg-vdqoy8fkrs5D5Opi .image-shape .label,#mermaid-svg-vdqoy8fkrs5D5Opi .icon-shape .label{text-anchor:middle;}#mermaid-svg-vdqoy8fkrs5D5Opi .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-vdqoy8fkrs5D5Opi .rough-node .label,#mermaid-svg-vdqoy8fkrs5D5Opi .node .label,#mermaid-svg-vdqoy8fkrs5D5Opi .image-shape .label,#mermaid-svg-vdqoy8fkrs5D5Opi .icon-shape .label{text-align:center;}#mermaid-svg-vdqoy8fkrs5D5Opi .node.clickable{cursor:pointer;}#mermaid-svg-vdqoy8fkrs5D5Opi .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-vdqoy8fkrs5D5Opi .arrowheadPath{fill:#333333;}#mermaid-svg-vdqoy8fkrs5D5Opi .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-vdqoy8fkrs5D5Opi .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-vdqoy8fkrs5D5Opi .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vdqoy8fkrs5D5Opi .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-vdqoy8fkrs5D5Opi .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vdqoy8fkrs5D5Opi .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-vdqoy8fkrs5D5Opi .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-vdqoy8fkrs5D5Opi .cluster text{fill:#333;}#mermaid-svg-vdqoy8fkrs5D5Opi .cluster span{color:#333;}#mermaid-svg-vdqoy8fkrs5D5Opi div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-vdqoy8fkrs5D5Opi .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-vdqoy8fkrs5D5Opi rect.text{fill:none;stroke-width:0;}#mermaid-svg-vdqoy8fkrs5D5Opi .icon-shape,#mermaid-svg-vdqoy8fkrs5D5Opi .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vdqoy8fkrs5D5Opi .icon-shape p,#mermaid-svg-vdqoy8fkrs5D5Opi .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-vdqoy8fkrs5D5Opi .icon-shape rect,#mermaid-svg-vdqoy8fkrs5D5Opi .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vdqoy8fkrs5D5Opi .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-vdqoy8fkrs5D5Opi .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-vdqoy8fkrs5D5Opi :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
子组件
父组件
可以访问
可以访问
只能访问
不能访问
不能访问
父组件数据
父组件方法
插槽模板
子组件数据
子组件方法
插槽 Props
高级技巧:动态插槽与递归组件
动态插槽名的使用场景
有时我们需要根据数据动态决定使用哪个插槽:
<!– DynamicLayout.vue –>
<template>
<div class="dynamic-layout">
<component
:is="`h${level}`"
v-if="$slots[`title-${level}`]"
>
<slot :name="`title-${level}`" />
</component>
<div
v-for="section in sections"
:key="section.name"
class="section"
>
<!– 动态插槽名 –>
<slot
:name="`section-${section.type}`"
:data="section.data"
/>
</div>
</div>
</template>
<script setup>
defineProps<{
level?: 1 | 2 | 3 | 4 | 5 | 6
sections: Array<{ type: string; name: string; data: any }>
}>()
</script>
<!– 使用 –>
<template>
<DynamicLayout :level="3" :sections="pageSections">
<!– 动态匹配 title-3 插槽 –>
<template #title-3>
页面标题
</template>
<!– 根据 section.type 动态匹配插槽 –>
<template #section-hero="{ data }">
<HeroSection :data="data" />
</template>
<template #section-features="{ data }">
<FeaturesGrid :items="data" />
</template>
<template #section-cta="{ data }">
<CallToAction :data="data" />
</template>
</DynamicLayout>
</template>
递归组件中插槽的处理
递归组件(如树形控件)需要特殊处理插槽:
<!– Tree.vue – 递归树组件 –>
<template>
<div class="tree-node">
<div class="node-content" @click="toggle">
<slot
name="node"
:node="node"
:level="level"
:expanded="expanded"
>
<span class="default-node">
<span class="toggle-icon">{{ expanded ? '▼' : '▶' }}</span>
{{ node.label }}
</span>
</slot>
</div>
<div v-if="expanded && node.children" class="node-children">
<Tree
v-for="(child, index) in node.children"
:key="index"
:node="child"
:level="level + 1"
>
<!– 传递插槽到子节点 –>
<template #node="slotProps">
<slot name="node" v-bind="slotProps" />
</template>
<template #leaf="slotProps">
<slot name="leaf" v-bind="slotProps" />
</template>
</Tree>
</div>
</div>
</template>
<script setup>
defineProps<{
node: any
level?: number
}>()
const expanded = ref(false)
const toggle = () => expanded.value = !expanded.value
</script>
<!– 使用 –>
<template>
<Tree :node="treeData">
<!– 自定义节点渲染 –>
<template #node="{ node, level, expanded }">
<div class="custom-node" :class="`level-${level}`">
<FolderIcon v-if="node.children" :open="expanded" />
<FileIcon v-else />
<span class="label">{{ node.label }}</span>
<span class="count" v-if="node.children">
({{ node.children.length }})
</span>
</div>
</template>
</Tree>
</template>
渲染函数中的插槽使用
在渲染函数(TSX/JSX)中使用插槽:
// Table.tsx
import { defineComponent } from 'vue'
export default defineComponent({
props: {
data: Array,
columns: Array
},
setup(props, { slots }) {
return () => (
<table class="table">
<thead>
<tr>
{props.columns.map(col => (
<th key={col.key}>{col.title}</th>
))}
</tr>
</thead>
<tbody>
{props.data.map((row, rowIndex) => (
<tr key={rowIndex}>
{props.columns.map(col => (
<td key={col.key}>
{/* 使用作用域插槽 */}
{slots[`column-${col.key}`]?.({
value: row[col.key],
row,
column: col,
index: rowIndex
}) || row[col.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}
})
性能考量
插槽内容的重渲染机制
插槽内容的渲染遵循 Vue 的响应式更新机制:
<template>
<ChildComponent>
<!– 这个内容会随着 parentData 变化而重新渲染 –>
<div>{{ parentData }}</div>
</ChildComponent>
</template>
关键点:
- 插槽内容属于父组件,所以当父组件数据变化时,插槽内容会重新渲染
- 插槽内容的重新渲染不会影响子组件的其他部分
- 使用 v-memo 可以缓存插槽内容
使用 v-slot 的缓存优化
对于频繁切换的插槽内容,可以使用 v-memo 优化:
<template>
<ExpensiveList :items="items">
<template #default="{ item }">
<!– 这个内容只有在 item.id 变化时才重新渲染 –>
<div v-memo="[item.id, item.updatedAt]">
<h3>{{ item.title }}</h3>
<p>{{ item.description }}</p>
<img :src="item.image" loading="lazy" />
</div>
</template>
</ExpensiveList>
</template>
避免不必要的插槽重绘
<script setup>
import { computed } from 'vue'
// ❌ 反模式:每次渲染都创建新对象
const listItems = computed(() =>
items.value.map(item => ({
…item,
timestamp: Date.now() // 每次都会变化
}))
)
// ✅ 优化:只在实际变化时更新
const listItems = computed(() => items.value)
// ✅ 使用 shallowRef 避免深层响应
import { shallowRef } from 'vue'
const largeDataset = shallowRef([])
</script>
<template>
<List :items="listItems">
<template #default="{ item }">
<!– 使用静态内容减少重绘 –>
<ListItem
:data="item"
:key="item.id"
/>
</template>
</List>
</template>
插槽设计的最佳实践
插槽使用决策树
#mermaid-svg-JDnhuSKnvbmBfLDk{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-JDnhuSKnvbmBfLDk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-JDnhuSKnvbmBfLDk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-JDnhuSKnvbmBfLDk .error-icon{fill:#552222;}#mermaid-svg-JDnhuSKnvbmBfLDk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-JDnhuSKnvbmBfLDk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-JDnhuSKnvbmBfLDk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-JDnhuSKnvbmBfLDk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-JDnhuSKnvbmBfLDk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-JDnhuSKnvbmBfLDk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-JDnhuSKnvbmBfLDk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-JDnhuSKnvbmBfLDk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-JDnhuSKnvbmBfLDk .marker.cross{stroke:#333333;}#mermaid-svg-JDnhuSKnvbmBfLDk svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-JDnhuSKnvbmBfLDk p{margin:0;}#mermaid-svg-JDnhuSKnvbmBfLDk .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-JDnhuSKnvbmBfLDk .cluster-label text{fill:#333;}#mermaid-svg-JDnhuSKnvbmBfLDk .cluster-label span{color:#333;}#mermaid-svg-JDnhuSKnvbmBfLDk .cluster-label span p{background-color:transparent;}#mermaid-svg-JDnhuSKnvbmBfLDk .label text,#mermaid-svg-JDnhuSKnvbmBfLDk span{fill:#333;color:#333;}#mermaid-svg-JDnhuSKnvbmBfLDk .node rect,#mermaid-svg-JDnhuSKnvbmBfLDk .node circle,#mermaid-svg-JDnhuSKnvbmBfLDk .node ellipse,#mermaid-svg-JDnhuSKnvbmBfLDk .node polygon,#mermaid-svg-JDnhuSKnvbmBfLDk .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-JDnhuSKnvbmBfLDk .rough-node .label text,#mermaid-svg-JDnhuSKnvbmBfLDk .node .label text,#mermaid-svg-JDnhuSKnvbmBfLDk .image-shape .label,#mermaid-svg-JDnhuSKnvbmBfLDk .icon-shape .label{text-anchor:middle;}#mermaid-svg-JDnhuSKnvbmBfLDk .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-JDnhuSKnvbmBfLDk .rough-node .label,#mermaid-svg-JDnhuSKnvbmBfLDk .node .label,#mermaid-svg-JDnhuSKnvbmBfLDk .image-shape .label,#mermaid-svg-JDnhuSKnvbmBfLDk .icon-shape .label{text-align:center;}#mermaid-svg-JDnhuSKnvbmBfLDk .node.clickable{cursor:pointer;}#mermaid-svg-JDnhuSKnvbmBfLDk .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-JDnhuSKnvbmBfLDk .arrowheadPath{fill:#333333;}#mermaid-svg-JDnhuSKnvbmBfLDk .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-JDnhuSKnvbmBfLDk .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-JDnhuSKnvbmBfLDk .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JDnhuSKnvbmBfLDk .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-JDnhuSKnvbmBfLDk .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JDnhuSKnvbmBfLDk .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-JDnhuSKnvbmBfLDk .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-JDnhuSKnvbmBfLDk .cluster text{fill:#333;}#mermaid-svg-JDnhuSKnvbmBfLDk .cluster span{color:#333;}#mermaid-svg-JDnhuSKnvbmBfLDk div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-JDnhuSKnvbmBfLDk .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-JDnhuSKnvbmBfLDk rect.text{fill:none;stroke-width:0;}#mermaid-svg-JDnhuSKnvbmBfLDk .icon-shape,#mermaid-svg-JDnhuSKnvbmBfLDk .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JDnhuSKnvbmBfLDk .icon-shape p,#mermaid-svg-JDnhuSKnvbmBfLDk .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-JDnhuSKnvbmBfLDk .icon-shape rect,#mermaid-svg-JDnhuSKnvbmBfLDk .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JDnhuSKnvbmBfLDk .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-JDnhuSKnvbmBfLDk .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-JDnhuSKnvbmBfLDk :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
一个位置
多个位置
是
否
需要让父组件定制内容
有几个定制位置?
使用默认插槽
使用具名插槽
需要访问子组件数据?
使用作用域插槽
使用普通插槽
暴露最小必要数据
提供合理的默认内容
插槽设计的最佳实践清单
- 为所有插槽提供默认内容,让组件在没有插槽时也能正常工作
- 使用 TypeScript 定义插槽类型,提供更好的开发体验
- 作用域插槽只暴露必要的数据,避免暴露整个组件实例
- 插槽命名使用清晰的语义,如 header、item、actions
- 考虑插槽的层级关系,使用 v-bind 传递多个 props
- 使用动态插槽名处理不确定数量的插槽
- 递归组件中正确传递插槽
- 注意插槽内容的性能优化,使用 v-memo 等指令
结语
插槽设计的核心思想是 “控制反转”:组件负责核心逻辑和结构,父组件负责具体内容的渲染。这种设计带来了几个关键优势:
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!



