插槽 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>
内容不同、外壳一致——这就是插槽的价值。
现场跑:一个 Card 外壳 + 三种内容
改改 Card 的内容区,外壳的样式一行都没碰:
<script setup>
</script>
<template>
<div class="page">
<Card>
<h3>ES6 核心语法</h3>
<p>从 Java 视角快速过一遍 ES6。</p>
</Card>
<Card>
<img src="https://avatars.githubusercontent.com/u/499550?v=4" width="48" />
<p>尤雨溪 · Vue 作者</p>
</Card>
<Card>
<button @click="$emit || undefined">我也能放交互</button>
</Card>
</div>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
components: {
Card: {
template: `
<div class="card">
<div class="body"><slot>暂无内容</slot></div>
</div>
`,
},
},
})
</script>
<style scoped>
.page { padding: 20px; font-family: system-ui; display: grid; gap: 12px; }
.card { border: 1px solid #e5e7eb; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.05); background: white; }
.body { padding: 14px 18px; }
.card img { border-radius: 50%; vertical-align: middle; margin-right: 8px; }
button { padding: 4px 12px; border: 1px solid #8b5cf6; background: #8b5cf6; color: white; border-radius: 4px; cursor: pointer; }
</style>
默认内容(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 里的落地——骨架固定,扩展点标准化。
7. 原理深挖:插槽编译后是函数,不是 HTML
模板里看着是"在标签里塞 HTML",编译后插槽内容变成函数:
<!-- 父组件 -->
<Card>
<h3>{{ title }}</h3>
<p>{{ summary }}</p>
</Card>
编译后(简化):
createVNode(Card, null, {
default: () => [ // ★ 不是 vnode 数组,是返回 vnode 数组的函数
createVNode('h3', null, _ctx.title),
createVNode('p', null, _ctx.summary)
],
_: 1 // 编译标记:稳定插槽
})
为什么是函数:
- 延迟求值——子组件决定什么时候渲染、渲染几次。
v-for在子组件里循环 slot,每次迭代时调一次函数,每次拿到一份新 vnode - 作用域捕获——函数闭包捕获了父组件的
_ctx,所以{{ title }}永远是父组件的 title,不是子组件的 - 响应式跟踪——函数被组件 render effect 调用时,里面访问的响应式变量自动收依赖
子组件的 <slot /> 编译后大概长这样:
renderSlot(_ctx.$slots, 'default', { /* slot props */ })
// 内部基本就是 _ctx.$slots.default?.(slotProps)
关键推论:
- 父模板里
<Card>里写的{{ title }}中的title是父组件的变量——子组件的同名变量访问不到。这就是第 5 节"误区 1"的根因 - 作用域插槽(slot props)是子组件调用 slot 函数时传的参数——所以父组件能拿到子组件的内部数据
- 默认插槽的名字真的就是
'default'——<slot />≡<slot name="default" />
8. 原理深挖:作用域插槽 = 反向调用
普通 props 是父→子传值,作用域插槽是子→父传值——但实现机制完全不一样:
普通 props:
父组件 createVNode(Child, { foo: bar })
→ 子实例的 setup(props)
→ props.foo 在子组件里能用
作用域插槽:
父组件给子传一个函数 (slotProps) => vnode[]
子组件 render 时调用这个函数 fn({ item, index })
→ 函数返回的 vnode 用了父组件的 _ctx + 子传过来的参数
两个方向同时存在:
父 _ctx 传 slot fn 给 子 _ctx
│ ──────────► │
│ │
│ 子调 fn(slotProps) │
│ ◄───────────────────── │
▼ ▼
vnode 里既有父的变量也有子传出来的 slotProps
为什么 Element Plus 的 table 必须用作用域插槽:
<el-table :data="notes">
<el-table-column label="标题">
<template #default="{ row }"> <!-- row 是子传给父的 -->
<a :href="`/notes/${row.slug}`">{{ row.title }}</a>
</template>
</el-table-column>
</el-table>
table 子组件循环 data 时,每行调用 slot 函数 slot({ row: notes[i], $index: i })。外部不传作用域插槽,table 不知道每行的内容是什么——这把"骨架(循环、布局、虚拟滚动)"和"每行长什么样"彻底分开。
Java 对照:作用域插槽相当于把 Function<Item, ReactNode> 当参数传进来——但 Vue 用模板表达更直观。
9. 原理深挖:动态插槽 + 编译期标记
<template>
<Card>
<template #[dynamicName]>动态名字的插槽</template>
</Card>
</template>
编译器无法静态确定 dynamicName 的值,所以:
createVNode(Card, null, {
[_ctx.dynamicName]: () => [...],
_: 2 // 标记:动态插槽
})
_ 是 slotFlag,三个值:
| 值 | 含义 | 影响 |
|---|---|---|
| 1 STABLE | 静态插槽(编译期已知所有 name) | 子组件可以缓存 slot fn 引用 |
| 2 DYNAMIC | 动态插槽(有 #[xxx]) |
每次重渲染都重建 |
| 3 FORWARDED | 这个插槽是从父继承下来的 | 转发场景 |
实战影响:动态插槽性能比静态插槽差——编译器没法做静态分析、子组件不能缓存。除非真有动态需求,优先静态命名。
10. 原理深挖:$slots 是个对象,里面全是函数
getCurrentInstance() 拿到的实例上有个 slots 字段:
<script setup>
import { useSlots } from 'vue'
const slots = useSlots()
console.log(slots.default) // ƒ() { ... } 插槽内容的渲染函数
console.log(slots.header) // ƒ() { ... }
console.log(typeof slots.default) // 'function'
// 用代码判断有没有内容
if (slots.default) {
// 有默认插槽
}
</script>
实用场景——条件渲染插槽容器:
<!-- 子组件:只有当真的传了 footer 插槽时才渲染外层 div -->
<template>
<div class="card">
<slot />
<div v-if="$slots.footer" class="card-footer">
<slot name="footer" />
</div>
</div>
</template>
不写 v-if="$slots.footer" 的话,没传插槽时也会渲染一个空 card-footer 容器——浪费 DOM 还可能有边距问题。
手写渲染函数(render function)里用插槽:
import { h, useSlots } from 'vue'
export default {
setup() {
const slots = useSlots()
return () => h('div', [
slots.default?.(), // 调函数拿 vnode
slots.footer?.({ time: Date.now() }) // 作用域插槽:传 slotProps
])
}
}
?.() 是因为没传插槽时 slots.xxx 是 undefined。
11. 原理深挖:插槽和组件树的关系
一个常见疑惑:插槽内容是父组件的还是子组件的?
<!-- 父 -->
<Card>
<Counter /> <!-- Counter 组件实例属于谁? -->
</Card>
答案:Counter 是父组件的子,不是 Card 的子——尽管 DOM 上它在 Card 的 div 里。
组件树(逻辑) DOM 树(物理)
父 父
├─ Card ├─ <div class="card">
│ │ ├─ <div class="card-body">
└─ Counter │ │ └─ <button>...</button> ← Counter 渲染的 DOM
│ └─ </div>
└─ </div>
关键推论:
- provide/inject 走组件树,不走 DOM 树——父在 Card 外面 provide 的值,Counter 也能 inject 到(因为 Counter 是父的子)
- errorCaptured 也走组件树——Counter 抛错先冒到父,再冒到 Card 的话父级都抓不到(Card 不是 Counter 的祖先)
- Counter 的 onMounted 时机——和它在父组件里直接渲染时一样,不受 Card 影响
Java 对照:类似 Spring 的 Bean 容器图——Bean 的依赖关系不一定和文件目录结构一致。Vue 的组件树是调用关系图,DOM 树是渲染结果图。
12. 原理深挖:为什么 v-for 里塞 slot 行得通
<!-- 子组件 DataList -->
<template>
<ul>
<li v-for="(item, i) in items" :key="i">
<slot :item="item" :index="i" />
</li>
</ul>
</template>
这里 <slot> 在 v-for 里——同一个 slot 函数被调用了多次:
// 编译后大概
items.value.map((item, i) =>
h('li', { key: i }, [
slots.default?.({ item, index: i }) // ★ 每次迭代调一次
])
)
这就是作用域插槽强大的本质——slot 是函数,可以被调用任意多次,每次传不同的参数,于是父组件提供的渲染逻辑可以被批量复用。
对比错误的"想当然":
// ❌ 错误的心智模型:以为 slot 是 vnode 数组
<ul>
<li v-for="item in items">
{{ slot 内容 }} // 只写一次,怎么循环?
</li>
</ul>
slot 是函数 → 可以被循环调用 → 每次返回一份独立的 vnode → 树不会"共享"。
小练习:给 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 的边框样式改一次,所有调用方自动跟上