哈曼卡顿Harman Kardon One(哈曼卡顿智能音箱)
156.1MB · 2026-03-12
在 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 没覆盖到的布局默认插槽是最简单的形式,适合单一内容区域:
<!-- 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>
适用场景:
理解作用域插槽的关键是明白其数据流向:
graph LR
A[子组件数据] -->|通过插槽 Props 暴露| B[父组件]
B -->|决定如何渲染| C[插槽内容]
C -->|渲染到| A
关键点:
作用域插槽的 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>
渲染作用域图解:
graph TD
subgraph 父组件
A[父组件数据]
B[父组件方法]
C[插槽模板]
end
subgraph 子组件
D[子组件数据]
E[子组件方法]
F[插槽 Props]
end
C -->|可以访问| A
C -->|可以访问| B
C -->|只能访问| F
C -->|不能访问| D
C -->|不能访问| E
有时我们需要根据数据动态决定使用哪个插槽:
<!-- 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-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>
graph TD
A[需要让父组件定制内容] --> B{有几个定制位置?}
B -->|一个位置| C[使用默认插槽]
B -->|多个位置| D[使用具名插槽]
C --> E{需要访问子组件数据?}
D --> E
E -->|是| F[使用作用域插槽]
E -->|否| G[使用普通插槽]
F --> H[暴露最小必要数据]
G --> I[提供合理的默认内容]
header、item、actionsv-bind 传递多个 propsv-memo 等指令插槽设计的核心思想是 "控制反转":组件负责核心逻辑和结构,父组件负责具体内容的渲染。这种设计带来了几个关键优势:
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!
156.1MB · 2026-03-12
31.0MB · 2026-03-12
117.49M · 2026-03-12