Vue3 插槽机制
大约 9 分钟约 2659 字
Vue3 插槽机制
简介
Vue3 插槽(Slot)是组件内容分发的核心机制,允许父组件向子组件传递模板内容。插槽的本质是组件的"占位符",子组件定义占位区域,父组件决定填充什么内容。理解默认插槽、具名插槽、作用域插槽和动态插槽,有助于设计灵活的组件 API。在 Vue3 中,插槽使用 v-slot 指令(缩写为 #),编译后会生成渲染函数。插槽内容在父组件的作用域中编译,因此可以访问父组件的数据和方法。
特点
实现
默认插槽与具名插槽
<!-- Card.vue — 卡片组件 -->
<template>
<div class="card" :class="{ compact }">
<div v-if="$slots.header" class="card-header">
<slot name="header">
<h3 class="card-title">默认标题</h3>
</slot>
</div>
<div class="card-body">
<slot>
<!-- 默认内容:无插槽内容时显示 -->
<p class="card-placeholder">暂无内容</p>
</slot>
</div>
<div v-if="$slots.footer" class="card-footer">
<slot name="footer" />
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
compact?: boolean;
}>();
</script>
<style scoped>
.card {
border: 1px solid #e8e8e8;
border-radius: 8px;
overflow: hidden;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.card.compact .card-body { padding: 12px; }
.card-header { padding: 16px; border-bottom: 1px solid #e8e8e8; }
.card-body { padding: 16px; }
.card-footer { padding: 12px 16px; border-top: 1px solid #e8e8e8; background: #fafafa; }
</style><!-- 使用 Card 组件 -->
<template>
<!-- 基础用法 -->
<Card>
<p>这是卡片内容</p>
</Card>
<!-- 带头部和底部 -->
<Card>
<template #header>
<div class="flex items-center justify-between">
<h3>设备监控</h3>
<button @click="refresh">刷新</button>
</div>
</template>
<template #default>
<DeviceList :devices="devices" />
</template>
<template #footer>
<span>共 {{ devices.length }} 台设备</span>
</template>
</Card>
<!-- 紧凑模式 -->
<Card compact>
<span>紧凑卡片</span>
</Card>
<!-- 使用默认后备内容 -->
<Card /> <!-- 显示"暂无内容" -->
</template>作用域插槽
<!-- List.vue — 列表组件 -->
<template>
<ul class="list" :class="{ bordered }">
<li
v-for="(item, index) in items"
:key="item.id"
class="list-item"
>
<!-- 作用域插槽 — 向父组件暴露数据 -->
<slot
:item="item"
:index="index"
:isFirst="index === 0"
:isLast="index === items.length - 1"
:isEven="index % 2 === 0"
>
<!-- 默认渲染 -->
<span>{{ item.name }}</span>
</slot>
</li>
</ul>
<div v-if="items.length === 0" class="list-empty">
<slot name="empty">
<p>暂无数据</p>
</slot>
</div>
</template>
<script setup lang="ts">
defineProps<{
items: Array<{ id: string | number; name: string; [key: string]: any }>;
bordered?: boolean;
}>();
</script><!-- 使用 — 自定义每项渲染 -->
<template>
<List :items="devices" bordered>
<!-- 解构作用域插槽 props -->
<template #default="{ item, index, isLast }">
<div class="device-item" :class="{ 'no-border': isLast }">
<span class="device-index">{{ index + 1 }}.</span>
<StatusBadge :status="item.status" />
<span class="device-name">{{ item.name }}</span>
<span class="device-type">{{ item.type }}</span>
</div>
</template>
<!-- 空数据插槽 -->
<template #empty>
<div class="empty-state">
<img src="@/assets/empty.svg" alt="暂无设备" />
<p>还没有添加设备</p>
<button @click="addDevice">添加设备</button>
</div>
</template>
</List>
</template>高级插槽模式
<!-- DataTable.vue — 可组合的数据表格 -->
<template>
<div class="data-table-wrapper">
<table class="data-table">
<thead>
<tr>
<slot name="header">
<!-- 默认表头:自动从 columns 生成 -->
<th v-for="col in columns" :key="col.key" :style="{ width: col.width }">
{{ col.title }}
</th>
</slot>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td :colspan="columns.length" class="table-loading">
<slot name="loading">
<LoadingSpinner />
</slot>
</td>
</tr>
<tr v-else-if="data.length === 0">
<td :colspan="columns.length" class="table-empty">
<slot name="empty">
<p>暂无数据</p>
</slot>
</td>
</tr>
<tr
v-else
v-for="(row, rowIndex) in data"
:key="row.id"
class="table-row"
>
<!-- 行级作用域插槽 -->
<slot name="row" :row="row" :index="rowIndex">
<!-- 默认:自动渲染每列 -->
<td v-for="col in columns" :key="col.key">
{{ row[col.key] }}
</td>
</slot>
</tr>
</tbody>
</table>
<div v-if="$slots.footer" class="table-footer">
<slot name="footer" :total="data.length" />
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
data: any[];
columns: Array<{ key: string; title: string; width?: string }>;
loading?: boolean;
}>();
</script><!-- 使用 DataTable -->
<template>
<DataTable :data="users" :columns="userColumns" :loading="loading">
<!-- 自定义表头 -->
<template #header>
<th>用户信息</th>
<th>状态</th>
<th>操作</th>
</template>
<!-- 自定义行渲染 -->
<template #row="{ row }">
<td>
<div class="user-cell">
<img :src="row.avatar" class="user-avatar" />
<div>
<div class="user-name">{{ row.name }}</div>
<div class="user-email">{{ row.email }}</div>
</div>
</div>
</td>
<td>
<StatusBadge :status="row.status" />
</td>
<td>
<div class="actions">
<button size="small" @click="edit(row)">编辑</button>
<button size="small" variant="danger" @click="remove(row)">删除</button>
</div>
</td>
</template>
<!-- 分页区域 -->
<template #footer="{ total }">
<span>共 {{ total }} 条记录</span>
<Pagination :total="total" v-model:page="page" />
</template>
</DataTable>
</template>动态插槽与条件插槽
<!-- TabPanel.vue — 标签面板 -->
<template>
<div class="tab-panel">
<div class="tab-nav">
<button
v-for="tab in tabs"
:key="tab.name"
class="tab-btn"
:class="{ active: activeTab === tab.name }"
@click="activeTab = tab.name"
>
{{ tab.label }}
</button>
</div>
<div class="tab-content">
<!-- 动态插槽名 -->
<slot :name="activeTab" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
defineProps<{ tabs: Array<{ name: string; label: string }> }>();
const activeTab = ref('overview');
</script>
<!-- 使用动态插槽 -->
<TabPanel :tabs="[
{ name: 'overview', label: '概览' },
{ name: 'detail', label: '详情' },
{ name: 'settings', label: '设置' },
]">
<template #overview>
<OverviewPanel />
</template>
<template #detail>
<DetailPanel />
</template>
<template #settings>
<SettingsPanel />
</template>
</TabPanel>无渲染组件(Renderless Component)
<!-- MouseTracker.vue — 无渲染组件,只提供逻辑 -->
<template>
<slot
:x="x"
:y="y"
:isMoving="isMoving"
/>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
const x = ref(0);
const y = ref(0);
const isMoving = ref(false);
let timeout: ReturnType<typeof setTimeout>;
function onMouseMove(event: MouseEvent) {
x.value = event.clientX;
y.value = event.clientY;
isMoving.value = true;
clearTimeout(timeout);
timeout = setTimeout(() => { isMoving.value = false; }, 100);
}
onMounted(() => window.addEventListener('mousemove', onMouseMove));
onUnmounted(() => window.removeEventListener('mousemove', onMouseMove));
</script>
<!-- 使用无渲染组件 -->
<template>
<MouseTracker v-slot="{ x, y, isMoving }">
<div class="cursor-info">
<p>位置: {{ x }}, {{ y }}</p>
<p v-if="isMoving" class="moving">移动中...</p>
</div>
</MouseTracker>
</template>更多无渲染组件示例
<!-- useIntersectionObserver.vue — 可见性检测无渲染组件 -->
<template>
<div ref="target">
<slot :isVisible="isVisible" :hasBeenVisible="hasBeenVisible" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
const props = defineProps<{
threshold?: number;
rootMargin?: string;
once?: boolean;
}>();
const target = ref<HTMLElement>();
const isVisible = ref(false);
const hasBeenVisible = ref(false);
let observer: IntersectionObserver | null = null;
onMounted(() => {
if (!target.value) return;
observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
isVisible.value = entry.isIntersecting;
if (entry.isIntersecting) {
hasBeenVisible.value = true;
if (props.once) {
observer?.disconnect();
}
}
},
{
threshold: props.threshold ?? 0.1,
rootMargin: props.rootMargin ?? '0px',
}
);
observer.observe(target.value);
});
onUnmounted(() => {
observer?.disconnect();
});
</script>
<!-- 使用:懒加载图片 -->
<template>
<IntersectionObserver :once="true" v-slot="{ isVisible }">
<img v-if="isVisible" :src="imageUrl" :alt="alt" loading="lazy" />
<div v-else class="placeholder">加载中...</div>
</IntersectionObserver>
</template>FormItem 组合式插槽组件
<!-- FormItem.vue — 表单项组件 -->
<template>
<div class="form-item" :class="{ 'has-error': !!errorMessage }">
<label v-if="label" class="form-label">
{{ label }}
<span v-if="required" class="required">*</span>
</label>
<div class="form-control">
<slot
:modelValue="modelValue"
:onChange="handleChange"
:onBlur="handleBlur"
:errorMessage="errorMessage"
:isDirty="isDirty"
:isValid="!errorMessage && isDirty"
/>
</div>
<transition name="fade">
<span v-if="errorMessage" class="form-error">{{ errorMessage }}</span>
</transition>
<span v-if="hint && !errorMessage" class="form-hint">{{ hint }}</span>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const props = defineProps<{
label?: string;
modelValue: any;
required?: boolean;
rules?: Array<(v: any) => string | true>;
hint?: string;
}>();
const emit = defineEmits<{
'update:modelValue': [value: any];
}>();
const errorMessage = ref('');
const isDirty = ref(false);
function handleChange(value: any) {
emit('update:modelValue', value);
if (isDirty.value) validate(value);
}
function handleBlur() {
isDirty.value = true;
validate(props.modelValue);
}
function validate(value: any) {
if (props.required && !value) {
errorMessage.value = `${props.label || '此字段'}不能为空`;
return false;
}
if (props.rules) {
for (const rule of props.rules) {
const result = rule(value);
if (result !== true) {
errorMessage.value = result;
return false;
}
}
}
errorMessage.value = '';
return true;
}
defineExpose({ validate });
</script>
<style scoped>
.form-item { margin-bottom: 16px; }
.form-label { display: block; margin-bottom: 4px; font-weight: 500; }
.required { color: #ff4d4f; }
.form-error { color: #ff4d4f; font-size: 12px; }
.form-hint { color: #999; font-size: 12px; }
</style>
<!-- 使用 -->
<template>
<FormItem
v-model="form.email"
label="邮箱"
required
:rules="[v => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) || '邮箱格式不正确']"
v-slot="{ modelValue, onChange, errorMessage }"
>
<input
:value="modelValue"
@input="onChange($event.target.value)"
:class="{ error: !!errorMessage }"
placeholder="请输入邮箱"
/>
</FormItem>
</template>TypeScript 类型支持
<!-- defineSlots — Vue 3.3+ 插槽类型定义 -->
<script setup lang="ts">
interface ListProps {
items: Array<{ id: number; name: string; status: string }>;
}
interface ListSlots {
default(props: {
item: { id: number; name: string; status: string };
index: number;
isLast: boolean;
}): any;
empty(): any;
header?(props: { total: number }): any;
}
defineProps<ListProps>();
defineSlots<ListSlots>();
</script>
<!-- 或者使用泛型 -->
<script setup lang="ts" generic="T extends { id: string | number }">
defineProps<{
items: T[];
}>();
defineSlots<{
default(props: { item: T; index: number }): any;
empty(): any;
}>();
</script>优点
缺点
总结
插槽是 Vue3 组件内容分发的核心机制。默认插槽传递主内容,具名插槽分发到不同区域,作用域插槽让子组件向父组件暴露数据实现自定义渲染。动态插槽名允许运行时决定渲染哪个插槽。无渲染组件(Renderless Component)是插槽的高级模式,只提供逻辑不提供 UI。建议用插槽设计灵活的布局组件和列表组件,为插槽提供 TypeScript 类型定义。
关键知识点
- v-slot 可缩写为 #(如 #header、#default)。
- 作用域插槽通过 v-slot="{ item }" 解构子组件传递的数据。
- 插槽内容在父组件作用域编译,可以访问父组件数据。
- 插槽默认值写在 slot 标签内部。
- $slots 可以检查插槽是否存在(用于条件渲染)。
- defineSlots(Vue 3.3+)为插槽提供 TypeScript 类型。
项目落地视角
- 为通用布局组件(Card、Dialog、Table)设计清晰的插槽 API。
- 作用域插槽适合列表组件自定义渲染每一项。
- 文档中标注每个插槽的 props 接口和类型。
- 使用 defineSlots 提供完整 TypeScript 支持。
常见误区
- 在子组件中直接修改插槽传递的数据(应通过 emit)。
- 过度使用插槽替代 props 导致组件 API 不清晰。
- 忘记提供插槽默认值导致组件在没有插槽时显示空白。
- 插槽命名不规范导致使用时混淆。
进阶路线
- 学习无渲染组件(Renderless Component)模式。
- 研究高阶组件(HOC)与插槽的对比。
- 了解 Vue3.3+ 的 defineSlots 类型定义。
- 研究 VueUse 中基于插槽的 Composable 模式。
适用场景
- 布局组件(Card、Panel、Dialog)的内容分发。
- 列表组件自定义渲染每一项。
- 表格组件自定义列渲染。
- 无渲染组件提供可复用逻辑。
落地建议
- 为组件的插槽提供文档和类型定义。
- 使用作用域插槽替代 render props 模式。
- 插槽命名保持语义化(header/footer/default/empty/loading)。
- 为复杂插槽提供使用示例。
排错清单
- 检查插槽名是否与子组件 slot 的 name 匹配。
- 确认作用域插槽的 props 解构是否正确。
- 检查 v-slot 是否只能在组件或 template 标签上使用。
- 确认动态插槽名是否正确计算。
复盘问题
- 插槽和 props/render 函数各适合什么场景?
- 如何为插槽提供 TypeScript 类型提示?
- 多层嵌套组件如何避免插槽逐层透传?
- 插槽在性能敏感场景下是否有优化空间?
