插槽 slot:组件版的模板方法模式
为什么需要插槽
组件用 Props 传数据很顺手,但 Props 只能传"值"。想传一段 HTML / 组件 / 复杂结构怎么办?
举个例子:写一个通用的 <Card> 组件。卡片长一样(圆角、阴影、内边距),但内容每次都不一样——首页卡片放笔记摘要、详情页卡片放上一篇下一篇、用户中心卡片放头像和姓名。
如果用 Props:
<!-- ❌ props 传 HTML 字符串,要用 v-html,XSS 风险 -->
<Card content="<h3>标题</h3><p>摘要</p>" />
如果每种内容写一个 Card 变种(NoteCard / UserCard / NavCard)又失去了"Card"的抽象意义。
插槽就是解决这个问题的——Card 负责"壳子",内容留一个"洞",让调用者填。
Java 对照:模板方法模式
public abstract class Card {
public final void render() {
renderHeader();
renderContent(); // 留给子类实现的"洞"
renderFooter();
}
protected abstract void renderContent();
}
class NoteCard extends Card {
@Override
protected void renderContent() { /* 渲染笔记 */ }
}
Card.render() 是骨架,renderContent() 是插槽。Vue 的插槽就是这个模式,但用组合代替继承。
1. 默认插槽:最简单的"洞"
<!-- 子组件 Card.vue -->
<template>
<div class="card">
<div class="card-body">
<slot /> <!-- 留一个洞 -->
</div>
</div>
</template>
<style scoped>
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 4px rgba(0,0,0,.05);
}
</style>
调用方:
<Card>
<h3>ES6 核心语法</h3>
<p>从 Java 视角快速过一遍 ES6 的新特性。</p>
</Card>
<Card>
<img src="/avatar.png" />
<p>张三</p>
</Card>
内容不同、外壳一致——这就是插槽的价值。
默认内容(fallback)
<!-- Card.vue -->
<slot>暂无内容</slot>
调用时不填内容:<Card /> → 显示 "暂无内容"。填了就显示填的。
Java 对照:
protected void renderContent() {
// 子类没覆盖时的默认实现
System.out.println("暂无内容");
}
2. 具名插槽:多个"洞"
一个组件常常需要多个位置让外部填。比如布局组件:
<!-- DefaultLayout.vue -->
<template>
<div class="layout">
<header>
<slot name="header">默认顶栏</slot>
</header>
<main>
<slot /> <!-- 默认插槽,相当于 name="default" -->
</main>
<footer>
<slot name="footer">默认底栏</slot>
</footer>
</div>
</template>
调用方用 <template v-slot:xxx> 或 #xxx 简写:
<DefaultLayout>
<template #header>
<h1>in4vue</h1>
<nav>...</nav>
</template>
<!-- 默认插槽:不用 template 包裹也行 -->
<p>这是主体内容</p>
<template #footer>
<small>© 2026 yingjf</small>
</template>
</DefaultLayout>
项目里的例子:src/layouts/DefaultLayout.vue 就用了默认插槽(只留一个 <slot /> 给页面填内容)。如果以后首页要自定义顶栏右侧的搜索框,就能加一个 <slot name="header-right" /> 具名插槽。
3. 作用域插槽:子组件把数据"递给"父组件填
这是插槽里最强大的一招。子组件持有数据,但让父组件决定怎么渲染。
典型场景:通用列表组件
<!-- DataList.vue -->
<script setup lang="ts">
defineProps<{ items: unknown[] }>()
</script>
<template>
<ul class="data-list">
<li v-for="(item, index) in items" :key="index">
<!-- 把 item 和 index 通过插槽 props 传给父组件 -->
<slot :item="item" :index="index" />
</li>
</ul>
</template>
调用方:
<DataList :items="notes">
<template #default="{ item, index }">
<!-- 解构拿到子组件传出来的 item / index -->
<span>{{ index + 1 }}.</span>
<strong>{{ item.title }}</strong>
<span>{{ item.category }}</span>
</template>
</DataList>
<DataList :items="users">
<template #default="{ item }">
<img :src="item.avatar" />
<span>{{ item.name }}</span>
</template>
</DataList>
亮点:
DataList管循环 + 样式骨架- 每行怎么渲染由使用方决定
- 同一个
DataList既能渲染笔记也能渲染用户,复用到极致
Java 对照:回调 + 数据
// 策略模式的变体:把"怎么渲染一行"作为函数参数传进来
list.forEach(item -> System.out.println(item.title()));
// 作用域插槽 ≈ list.forEach 里的 lambda
Element Plus 的 <el-table> 大量用作用域插槽:
<el-table :data="notes">
<el-table-column label="标题">
<template #default="{ row }">
<a :href="`/notes/${row.slug}`">{{ row.title }}</a>
</template>
</el-table-column>
</el-table>
row 就是子组件(el-table-column)传出来的当前行数据。不会用作用域插槽,就用不好 Element Plus 的表格。
4. 三种插槽速查表
| 类型 | 子组件写法 | 父组件写法 | 用途 |
|---|---|---|---|
| 默认插槽 | <slot /> |
<Child>...</Child> |
一个洞 |
| 具名插槽 | <slot name="x" /> |
<template #x>...</template> |
多个洞 |
| 作用域插槽 | <slot :foo="bar" /> |
<template #default="{ foo }">...</template> |
子数据传给父渲染 |
语法记忆点:
v-slot简写为#- 默认插槽的 name 就是
default:#default或直接省略 - 作用域插槽 = 具名插槽 + props 传参
5. 新手常见误区
误区 1:想从父组件访问子组件作用域
<Card>
<!-- ❌ card 里的 internalData 在父作用域访问不到 -->
<p>{{ internalData }}</p>
</Card>
规则:父组件模板里写的内容,作用域属于父组件。子组件的内部变量想暴露给父,必须通过 slot :xxx="internalData" 作用域插槽的形式。
误区 2:一个作用域插槽传太多东西
<!-- 可以,但不推荐 -->
<slot :a="1" :b="2" :c="3" :d="4" :e="5" />
建议:把多个值打包成一个对象:
<slot :ctx="{ item, index, selected, disabled }" />
<!-- 父组件 -->
<template #default="{ ctx }">
{{ ctx.item.title }}
</template>
调用方可以只解构需要的字段:{ ctx: { item } }。
误区 3:把"应该用组件"的东西塞进插槽
插槽适合内容不固定但骨架固定的场景。如果每次"填"的内容都非常不同,可能本身就该写多个独立组件,而不是硬用一个带插槽的超级组件。
判断标准:看"骨架"是否有复用价值。Card 的骨架(圆角、阴影)复用性高 → 用插槽。要是每个 Card 连边框样式都不一样,那就别勉强。
6. 项目里的落地思考
src/layouts/DefaultLayout.vue 现在只有默认插槽。随着页面变多,可能会演化出:
<template>
<div class="layout">
<header>
<Logo />
<slot name="header-extra" /> <!-- 各页面自定义顶栏操作区 -->
</header>
<main>
<slot />
</main>
<footer>
<slot name="footer">
<!-- 默认底栏 -->
</slot>
</footer>
</div>
</template>
- 首页在
#header-extra塞搜索框 - 笔记详情页在
#header-extra塞分享按钮 - 关于页不填,显示默认(啥也没有)
这就是模板方法模式在 Vue 里的落地——骨架固定,扩展点标准化。
小练习:给 Card 做个抽屉式组件
- 新建
src/components/ContentCard.vue,提供三个插槽:#header,default,#footer - 在首页试着把某张笔记卡片换成:
<ContentCard> <template #header> <span class="tag">{{ note.category }}</span> </template> <h3>{{ note.title }}</h3> <p>{{ note.summary }}</p> <template #footer> <span>{{ note.date }}</span> </template> </ContentCard> - 体会:Card 的边框样式改一次,所有调用方自动跟上