第8次 · 插槽 slot 与动态组件
第8次 · 插槽 slot 与动态组件
第1/7页 · 先知道今天要做什么
上一课你把卡片拆成了子组件。但马上就遇到新问题:子组件的“内容”是写死的。比如你做了一个 <Panel title="成绩">,里面固定显示成绩列表;现在另一处想用同一个盒子装“课程安排”,只能再复制一个组件出来改。
插槽(slot)就是解决这个问题的:把组件做成一个“盒子”,盒子的外观由组件负责,盒子里面装什么由使用者决定。 这是从“会写组件”到“会设计组件”的关键一步,也是 Element Plus 里 el-card、el-dialog、el-table 这些组件能千变万化的原因。
今天还要顺带解决另一个问题:同一个位置想显示三种不同的视图(卡片 / 表格 / 原始数据),难道写三个 v-if?——用动态组件 <component :is> 更合适。
今天的场景
一个数据面板。你写两个通用组件:Panel(带标题和右上角操作区的盒子)和 ListBox(负责循环、并把每条数据“暴露”给使用者自己渲染)。然后用这两个组件拼出三种视图,用一个单选按钮切换视图。
下课时拿出这三样结果
| 做到什么 | 在哪里看 | 完成的证据 |
|---|---|---|
| 会用默认插槽与具名插槽 | Panel 组件 |
标题栏右上角显示自定义内容,盒子内部显示你写的内容。 |
| 会用作用域插槽 | ListBox 组件 |
循环的每一项由父组件决定长什么样,且能拿到该项的数据和下标。 |
会用动态组件与 keep-alive |
视图切换区 | 切换视图时界面整体替换;切回来时组件内部的状态被保留。 |
你只需要带着这些基础来
会 props、emits、v-for、v-if;第 7 课的工程还能跑。
本课时 45 分钟怎么上
| 时间 | 老师带着做 | 学生留下什么 |
|---|---|---|
| 0—5 分钟 | 提问:el-card 的 #header 是怎么把标题塞进去的? |
意识到“标签中间的内容”也是被组件接收的。 |
| 5—20 分钟 | 讲默认插槽、具名插槽、作用域插槽,三者各解决什么问题。 | 笔记里写下三者的使用场景与写法。 |
| 20—32 分钟 | 讲动态组件 <component :is> 与 keep-alive,说明“切换被销毁”的问题。 |
能说出 keep-alive 解决什么。 |
| 32—42 分钟 | 粘贴本课 App.vue,切换三种视图,观察状态是否保留。 |
三种视图都能切换。 |
| 42—45 分钟 | 抽查:问“作用域插槽里那个 { item } 是谁给的”。 |
全员提交。 |
从哪里开始
复制第 7 课的工程,改名 lesson-vue-08。不需要新增依赖。
今天先掌握到这里
今天讲的是组件的“内容定制能力”。第 9 课讲生命周期与模板引用,第 10 课做阶段综合练习。
来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(插槽 / 动态组件章节)· 组件依据 Element Plus 2.8 官方文档
第2/7页 · 把新词说清楚
新词与本课含义
| 名字 | 用熟悉的话理解 | 本课里长什么样 |
|---|---|---|
插槽 slot |
组件里预留的“空位”,使用者往里填内容。 | <slot /> |
| 默认插槽 | 没有名字的那个空位,标签中间的内容会填进去。 | <Panel>这里的内容</Panel> |
| 具名插槽 | 有名字的空位,用 <template #名字> 指定填到哪个位置。 |
<template #extra>...</template> |
| 作用域插槽 | 空位由子组件负责传数据,父组件负责渲染。 | <template #default="{ item }"> |
| 动态组件 | 用一个特殊标签,根据数据决定“现在显示哪个组件”。 | <component :is="currentComp" /> |
keep-alive |
把切走的组件“留着”,不要销毁,回来时状态还在。 | <keep-alive><component :is="c" /></keep-alive> |
三种插槽的对比
| 谁决定位置 | 谁决定内容 | 典型用途 | |
|---|---|---|---|
| 默认插槽 | 子组件 | 父组件 | 盒子里装什么 |
| 具名插槽 | 子组件(多个位置各有名字) | 父组件 | 头部标题、右上角按钮、页脚 |
| 作用域插槽 | 子组件(决定循环与位置) | 父组件(决定长什么样) | 表格列、列表项、树节点 |
写法对照
<!-- 子组件(真实项目中往往是单独文件) -->
<template>
<div class="panel">
<div class="panel-hd">
<slot name="title">默认标题</slot> <!-- 具名插槽,带默认内容 -->
<slot name="extra" /> <!-- 具名插槽 -->
</div>
<div class="panel-bd">
<slot /> <!-- 默认插槽 -->
</div>
</div>
</template>
<!-- 父组件使用 -->
<Panel>
<template #title>学员成绩</template>
<template #extra>
<el-tag size="small">共 5 人</el-tag>
</template>
<p>这里的内容会进默认插槽</p>
</Panel>
三个要点:
- 插槽可以写默认内容(
<slot>默认标题</slot>),使用者不填就显示默认值; - 具名插槽用
#名字,等价于v-slot:名字; - 使用具名插槽时,
<template>的顺序不影响显示位置——位置由子组件里的<slot>决定,这一点和你想的正好相反,一定要记住。
作用域插槽:为什么需要它
普通的默认插槽有一个致命限制:父组件往插槽里放内容时,拿不到子组件的数据。
比如 ListBox 负责循环 items,可是每一项长什么样,父组件想自己决定(有的地方显示姓名,有的地方显示价格)。父组件拿不到 item,就写不了。于是子组件在提供空位的同时,把数据一起递出来:
<!-- 子组件里 -->
<div v-for="(item, index) in items" :key="index">
<slot :item="item" :index="index" /> <!-- 把数据递给插槽 -->
</div>
<!-- 父组件里:用解构接住 -->
<ListBox :items="list">
<template #default="{ item, index }">
<span>{{ index + 1 }}. {{ item.name }}({{ item.score }} 分)</span>
</template>
</ListBox>
一句话区分:普通插槽是“我给你个位置”;作用域插槽是“我给你个位置,顺手把数据也给你”。
动态组件与 keep-alive
<script setup>
import { ref } from 'vue'
const current = ref('card')
</script>
<template>
<el-radio-group v-model="current">
<el-radio-button value="card">卡片</el-radio-button>
<el-radio-button value="table">表格</el-radio-button>
</el-radio-group>
<component :is="views[current]" />
<!-- 等价写法:<component :is="current" /> 需要组件被全局注册 -->
</template>
<component :is> 的取值可以是:注册过的组件名字符串,或组件对象本身。本课用第二种(更明确,不容易出错)。
不加 keep-alive 会发生什么? 每次切换,旧组件被销毁、新组件被重新创建。组件内部的状态(例如你在视图里输入的字、滚动位置)会丢失。加上 keep-alive 就会缓存起来。
| 写法 | 切走再回来 | 什么时候用 |
|---|---|---|
<component :is="c" /> |
重新创建,状态丢失 | 视图很重、不需要保留状态 |
<keep-alive><component :is="c" /></keep-alive> |
从缓存恢复,状态保留 | 标签页式切换、表单填写中切换 |
只看这几行:插槽的最小骨架
<!-- 子组件:预留一个位置,并把数据递出去 -->
<script setup>
defineProps({ items: { type: Array, default: () => [] } })
</script>
<template>
<div v-for="(item, i) in items" :key="i">
<slot :item="item" :index="i">默认显示 {{ item }}</slot>
</div>
</template>
先口头预测:上面这个子组件如果父组件什么插槽都不填,页面上会显示什么?
显示“默认显示 X”。因为 <slot> 标签中间的那段内容就是兜底内容,只有在使用者没提供插槽内容时才出现。这和上一课 props 的 default 是同一个思路:给一个合理的默认值。
来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(插槽 / 动态组件章节)· 组件依据 Element Plus 2.8 官方文档
第3/7页 · 先看一个小例子
本课使用的 App.vue(单文件,含两个内联子组件)
和上一课一样,为了满足“每课只有一个 App.vue、粘贴即用”,两个子组件用 h() 渲染函数定义在同一个文件里。插槽在 h() 里表现为 slots.xxx(),与模板里的 <slot name="xxx"> 一一对应,讲义第 7 页会有对照表。
<script setup>
import { ref, computed, defineComponent, h } from 'vue'
import {
ElCard, ElRadioGroup, ElRadioButton, ElTag, ElTable, ElTableColumn,
ElEmpty, ElAlert, ElButton, ElMessage
} from 'element-plus'
import 'element-plus/dist/index.css'
/* ================= 子组件 1:Panel(具名插槽 + 默认插槽) ================= */
const Panel = defineComponent({
name: 'Panel',
props: { title: { type: String, default: '未命名面板' } },
setup(props, { slots }) {
return () => h('div', { class: 'panel' }, [
h('div', { class: 'panel-hd' }, [
h('span', { class: 'panel-title' }, props.title),
// 具名插槽 extra:使用者不填就是空
h('span', { class: 'panel-extra' }, slots.extra ? slots.extra() : null)
]),
h('div', { class: 'panel-bd' },
// 默认插槽:没有默认内容,不填就是空白
slots.default ? slots.default() : null)
])
}
})
/* ================= 子组件 2:ListBox(作用域插槽) ================= */
const ListBox = defineComponent({
name: 'ListBox',
props: { items: { type: Array, default: () => [] } },
setup(props, { slots }) {
return () => {
if (!props.items.length) {
return h('div', { class: 'list-empty' }, '暂无数据')
}
return h('div', { class: 'list-box' },
props.items.map((item, index) => {
// 把 item 与 index 递给父组件的插槽
const children = slots.default ? slots.default({ item, index }) : String(item)
return h('div', { class: 'list-item', key: item.id ?? index }, children)
})
)
}
}
})
/* ================= 三个视图组件(用于动态组件演示) ================= */
// 视图 1:卡片列表
const CardView = defineComponent({
props: { items: Array },
setup(props) {
return () => h('div', { class: 'grid' },
props.items.map(it => h(ElCard, { class: 'mini', shadow: 'never', key: it.id }, {
default: () => [
h('b', null, it.name),
h('p', { class: 'muted' }, `${it.cls} · ${it.score ?? '未提交'}`)
]
})))
}
})
// 视图 2:表格
const TableView = defineComponent({
props: { items: Array },
setup(props) {
return () => h(ElTable, { data: props.items, size: 'small' }, {
default: () => [
h(ElTableColumn, { prop: 'name', label: '姓名', width: 100 }),
h(ElTableColumn, { prop: 'cls', label: '班级', width: 130 }),
h(ElTableColumn, { prop: 'score', label: '得分', width: 90 }),
h(ElTableColumn, { label: '状态' }, {
default: ({ row }) => h(ElTag, { size: 'small', type: row.done ? 'success' : 'info' },
() => (row.done ? '已提交' : '未提交'))
})
]
})
}
})
// 视图 3:带内部状态的组件,用来演示 keep-alive 是否保留状态
const CounterView = defineComponent({
setup() {
const n = ref(0)
return () => h('div', { class: 'counter' }, [
h('p', null, `这是没有 keep-alive 的普通视图。我在这个组件里点了几次:${n.value}`),
h(ElButton, { type: 'primary', onClick: () => { n.value++ } }, () => '点我 +1')
])
}
})
const CachedCounterView = defineComponent({
setup() {
const n = ref(0)
return () => h('div', { class: 'counter' }, [
h('p', null, `这个视图被 keep-alive 缓存了。我点了几次:${n.value}`),
h(ElButton, { type: 'success', onClick: () => { n.value++ } }, () => '点我 +1')
])
}
})
/* ================= 父组件 ================= */
const list = ref([
{ id: 1, name: '张三', cls: '计算机1班', done: true, score: 92 },
{ id: 2, name: '李四', cls: '计算机1班', done: false, score: null },
{ id: 3, name: '王五', cls: '计算机2班', done: true, score: 78 },
{ id: 4, name: '赵六', cls: '计算机2班', done: true, score: 45 }
])
const current = ref('CardView')
const views = { CardView, TableView, CounterView, CachedCounterView }
const stat = computed(() => {
const done = list.value.filter(s => s.done).length
return { total: list.value.length, done }
})
</script>
<template>
<div class="page">
<h1 class="title">插槽与动态组件</h1>
<!-- ① 具名插槽 #title / #extra,默认插槽装内容 -->
<Panel title="学员成绩">
<template #extra>
<el-tag size="small" type="success">已提交 {{ stat.done }} / {{ stat.total }}</el-tag>
<el-button size="small" text type="primary" @click="list.push({ id: Date.now(), name: '新同学', cls: '计算机3班', done: false, score: null })">
加一个人
</el-button>
</template>
<!-- ② 作用域插槽:每一项长什么样,由这里决定 -->
<ListBox :items="list">
<template #default="{ item, index }">
<div class="row">
<span class="idx">{{ index + 1 }}</span>
<b class="nm">{{ item.name }}</b>
<span class="muted">{{ item.cls }}</span>
<el-tag size="small" :type="item.done ? 'success' : 'info'">
{{ item.done ? (item.score ?? '已提交') : '未提交' }}
</el-tag>
</div>
</template>
</ListBox>
</Panel>
<!-- ③ 同样的 ListBox,换一种渲染方式:只显示名字,用逗号隔开 -->
<Panel title="只有名字的一种渲染(同一个 ListBox)">
<ListBox :items="list">
<template #default="{ item }">
<span class="chip">{{ item.name }}</span>
</template>
</ListBox>
</Panel>
<!-- ④ 动态组件 + keep-alive -->
<Panel title="动态组件演示">
<template #extra>
<el-radio-group v-model="current" size="small">
<el-radio-button value="CardView">卡片视图</el-radio-button>
<el-radio-button value="TableView">表格视图</el-radio-button>
<el-radio-button value="CounterView">未缓存</el-radio-button>
<el-radio-button value="CachedCounterView">已缓存</el-radio-button>
</el-radio-group>
</template>
<!-- 没有 keep-alive:切走再回来,内部计数会归零 -->
<component v-if="current === 'CounterView'" :is="views.CounterView" />
<!-- 有 keep-alive:切走再回来,内部计数还在 -->
<keep-alive>
<component v-if="current === 'CachedCounterView'" :is="views.CachedCounterView" />
</keep-alive>
<component v-if="current === 'CardView'" :is="views.CardView" :items="list" />
<component v-if="current === 'TableView'" :is="views.TableView" :items="list" />
</Panel>
<el-alert
title="实验:切到“未缓存”点几次 +1,切到别的视图再切回来,数字归零;换成“已缓存”重复一次,数字保留。"
type="info" :closable="false" show-icon class="tip"
/>
</div>
</template>
<style>
html, body, #app {
margin: 0; padding: 0; max-width: none; width: auto; height: auto;
display: block; place-items: initial; text-align: left; background: #f5f7fa;
}
</style>
<style scoped>
.page { max-width: 1000px; margin: 28px auto; padding: 0 16px; }
.title { font-size: 22px; color: #303133; margin: 0 0 18px; }
.panel { border: 1px solid #e4e7ed; border-radius: 8px; background: #fff; margin-bottom: 16px; overflow: hidden; }
.panel-hd {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 16px; border-bottom: 1px solid #ebeef5; background: #fafafa;
}
.panel-title { font-weight: 600; color: #303133; }
.panel-extra { display: flex; gap: 8px; align-items: center; }
.panel-bd { padding: 14px 16px; }
.list-item { border-bottom: 1px dashed #ebeef5; padding: 8px 0; }
.list-item:last-child { border-bottom: none; }
.row { display: flex; align-items: center; gap: 12px; }
.idx { width: 20px; color: #a8abb2; }
.nm { color: #303133; }
.muted { color: #909399; font-size: 13px; }
.chip {
display: inline-block; background: #ecf5ff; color: #409eff;
padding: 2px 10px; border-radius: 12px; font-size: 12px;
}
.grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.mini { border: 1px solid #ebeef5; }
.list-empty { color: #909399; padding: 12px 0; }
.counter p { margin: 0 0 10px; color: #303133; }
.tip { margin-top: 6px; }
</style>
预期看见什么
- 第一个面板:标题“学员成绩”,右上角有一个绿色统计标签和一个“加一个人”按钮,主体是带序号、姓名、班级、状态标签的行列表。
- 第二个面板:标题“只有名字的一种渲染”,主体是同一批数据,但显示成了一排蓝色圆角小标签。注意:两个面板用的是同一个
ListBox组件。 - 第三个面板:右上角四个单选按钮,切换后主体区域整体换成卡片、表格、计数器。
- 切到“未缓存”点 3 次 +1,切到“已缓存”再切回来——“未缓存”的计数归零,“已缓存”的计数保留。
- 点“加一个人”,两个面板的数据同时多一行(因为它们共用同一个
list)。
先猜一猜,读完代码再回答
第二个面板里,ListBox 收到的插槽内容是:
<template #default="{ item }">
<span class="chip">{{ item.name }}</span>
</template>
第一个面板里则是:
<template #default="{ item, index }">
<div class="row">...四列内容...</div>
</template>
同一个组件,为什么能渲染出完全不同的东西?
因为 ListBox 只负责两件事:循环数据、给每一项留一个位置。至于“每一项长什么样”,它完全不关心,交给使用者在插槽里写。 而 item、index 这些数据是 ListBox 在调用 slots.default({ item, index }) 时“递”出来的——这就是作用域插槽。它把“循环的控制权”和“渲染的控制权”分给了两个不同的组件,这正是通用组件的设计方式。
来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(插槽 / 动态组件章节)· 组件依据 Element Plus 2.8 官方文档
第4/7页 · 一步一步做出来
第1步:先看默认插槽
<!-- 子组件 Box.vue(真实项目中的独立文件) -->
<template>
<div class="box"><slot /></div>
</template>
<!-- 父组件 -->
<Box>我塞进来的内容</Box>
预期:Box 的边框里显示“我塞进来的内容”。把内容去掉,盒子变成空壳。
第2步:给插槽一个默认值
<!-- 子组件 -->
<div class="box"><slot>我是默认内容</slot></div>
父组件不填时,预期:显示“我是默认内容”。
第3步:加一个具名插槽
<!-- 子组件 Box.vue -->
<template>
<div class="box">
<div class="hd"><slot name="title">无标题</slot></div>
<div class="bd"><slot /></div>
</div>
</template>
<!-- 父组件:注意两个 template 的写法 -->
<Box>
<template #title>我的标题</template>
<p>正文</p>
</Box>
预期:标题出现在头部,正文出现在下面。
再做一次反直觉实验:把父组件里两个 template 的顺序调换,把 <p>正文</p> 写在 #title 前面。预期:显示位置完全不变。因为位置是由子组件里的 <slot> 决定的,父组件只负责“填哪一格”。
第4步:用作用域插槽做循环
<!-- 子组件 ListBox.vue -->
<script setup>
defineProps({ items: { type: Array, default: () => [] } })
</script>
<template>
<div v-for="(item, index) in items" :key="item.id ?? index">
<slot :item="item" :index="index" />
</div>
</template>
<!-- 父组件 -->
<ListBox :items="list">
<template #default="{ item, index }">
{{ index + 1 }} - {{ item.name }}
</template>
</ListBox>
预期:数据按行渲染,序号和姓名都在。
第5步:体验动态组件被销毁
<script setup>
import { ref } from 'vue'
import A from './A.vue'
const current = ref('A')
</script>
<template>
<button @click="current = current === 'A' ? 'B' : 'A'">切换</button>
<component :is="current === 'A' ? A : B" />
</template>
在 A 里放一个计数器,点几次 +1,切到 B 再切回来。预期:数字归零。
第6步:加上 keep-alive 再试
<keep-alive>
<component :is="current === 'A' ? A : B" />
</keep-alive>
预期:切回来数字还在。
第7步:整段替换为本课完整版
把第 3 页的完整 App.vue 覆盖 src/App.vue,把四个视图和两个插槽实验都跑一遍。
本课文件速查
| 位置 | 本次作用 |
|---|---|
Panel 的 slots.extra() |
具名插槽 #extra 的渲染位置。 |
Panel 的 slots.default() |
默认插槽的渲染位置。 |
ListBox 的 slots.default({ item, index }) |
把数据递给父组件,实现作用域插槽。 |
<component :is="views[current]" /> |
动态组件:按变量切换显示哪个组件。 |
<keep-alive> |
缓存被切走的组件状态。 |
完成修改后检查
| 操作或输入 | 预期观察 |
|---|---|
不填 #extra |
面板右上角为空白(没有报错)。 |
交换两个 template 顺序 |
显示位置不变。 |
清空 list |
ListBox 显示“暂无数据”。 |
在同一页用两次 ListBox |
两种渲染方式都生效。 |
| 切走再切回“已缓存”视图 | 计数保留。 |
来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(插槽 / 动态组件章节)· 组件依据 Element Plus 2.8 官方文档
第5/7页 · 换成自己的代码
你的任务
- 写一个自己的盒子组件:
Panel的具名插槽再加一个#footer,并在父组件里给它填上“最后更新于 xx”的说明文字。 - 用作用域插槽做一个完全不同的渲染:同一个
ListBox,第三处使用改成显示“头像圆圈 + 姓名首字”(例如<div class="avatar">{{ item.name[0] }}</div>)。 - 加一个视图:给动态组件再加一个视图
EmptyView,显示一句“这块还没做”,并加入单选按钮。 - 验证 keep-alive 的边界:把两个计数器视图都放进同一个
keep-alive里(用:is切换),观察两个组件的状态是否各自被保留。写下结论。 - 插槽默认值实验:给
ListBox的插槽加兜底内容(在h()里写成slots.default ? slots.default({item,index}) : String(item.id)),然后写一处不填插槽的ListBox用法,观察兜底内容出现。
按这张操作单完成
| 步骤 | 改哪里 | 运行后应该看到什么 |
|---|---|---|
| 1 加页脚插槽 | Panel + 使用处 |
面板底部出现说明文字。 |
| 2 第三种渲染 | 第三个 ListBox 使用处 |
显示头像圆圈与首字。 |
| 3 加视图 | 新增 EmptyView + 单选按钮 |
切到该视图显示提示文字。 |
| 4 缓存边界 | keep-alive 包裹 :is |
两个计数器各自保留自己的数字。 |
| 5 插槽兜底 | ListBox + 不填插槽的用法 |
显示兜底内容。 |
最低完成标准:先把这三项做实
| 检查项 | 通过条件 |
|---|---|
| 三种插槽 | 默认插槽、具名插槽、作用域插槽各至少用到一次且能说清区别。 |
| 动态组件 | 至少 3 个视图能切换,且 :is 绑的是组件对象。 |
| 能解释 keep-alive | 能用自己的话说明“切走时发生了什么、加了 keep-alive 后有什么不同”。 |
在本地写练习记录
- 写下三种插槽各自的“谁决定位置、谁决定内容”。
- 记录 keep-alive 实验的两组数据(未缓存 / 已缓存 切走再回来分别是多少)。
- 抄下你写的第三个
ListBox使用处代码。
最后交什么
提交 src/App.vue 与 练习记录.md。
来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(插槽 / 动态组件章节)· 组件依据 Element Plus 2.8 官方文档
第6/7页 · 出错时从哪里查
把现象和检查动作对上
| 看到什么现象 / 报错 | 先检查哪里 | 修好后怎样确认 |
|---|---|---|
| 插槽内容不显示 | 用了 #default 但子组件里没有 <slot>;或子组件忘了渲染插槽。 |
子组件里补上 <slot /> 后正常。 |
| 具名插槽填错了位置 | 名字不一致(子组件 name="title",父组件写成 #titles)。 |
名字统一后正常。 |
| 点“切换视图”后输入的内容没了 | 没加 keep-alive。 |
加上后保留。 |
<component :is="'card'"> 不显示 |
传的是字符串但组件没有全局注册。 | 改成传组件对象,例如 views[current]。 |
| 控制台提示组件被做成响应式 | 组件对象放进了 reactive / ref。 |
把它放在普通变量里,或用 markRaw。 |
作用域插槽里 item 是 undefined |
子组件调用 slots.default() 时忘了把数据传出去。 |
改成 slots.default({ item, index })。 |
用 h() 写插槽时报 slots.default is not a function |
使用者没填这个插槽,而代码里直接调用。 | 加判断 slots.default ? slots.default() : null。 |
排错口诀
插槽位置子组件定,插槽内容父组件填; 具名插槽名字要对,写错一个字母就不显示; 作用域插槽靠“递”,子组件不递父组件拿不到; 切走状态丢,是没加 keep-alive。
不要用这些办法“修复”
- 不要为了“让插槽有内容”在子组件里写一份默认 UI,同时父组件又填一份——两份内容会同时出现。要默认值就用
<slot>默认值</slot>的写法。 - 不要用一堆
props(例如headerText、footerText、bodyText)来替代插槽。插槽能传结构,props 只能传值,一旦需要传按钮、图标、复杂布局,props 就撑不住了。 - 不要给所有动态组件都套
keep-alive。缓存会占内存,只在确实需要保留状态的标签页式切换里用。
需要老师帮忙时交这些
子组件里插槽的声明代码、父组件里使用处的代码、Console 报错原文、你期望在哪里看到什么、实际出现在哪里。
老师巡视时只问三句话
- 这个位置是默认插槽还是具名插槽?怎么判断的?(能指出子组件里的
<slot name>/slots.xxx) - 作用域插槽里的
item是谁给的?(能答出子组件递出来的) - 你的视图切换有没有保留状态?为什么?(能答出
keep-alive)
来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(插槽 / 动态组件章节)· 组件依据 Element Plus 2.8 官方文档
第7/7页 · 用自己的话讲一遍
回到第 3 页:你刚才猜对了吗?
为什么同一个 ListBox 能渲染出两种完全不同的东西?
参考解释:ListBox 只承担“循环”和“留位置”的职责,每一项的内容完全由使用者在插槽中决定。数据则由 ListBox 通过插槽作用域递出去。这就是“逻辑在组件里、样式在调用处”的通用组件设计方式——Element Plus 的 el-table、el-select 都是这个套路。
模板写法与 h() 写法对照
| 模板写法 | h() 渲染函数写法 |
|---|---|
<slot /> |
slots.default() |
<slot name="extra" /> |
slots.extra() |
<slot :item="item" :index="i" /> |
slots.default({ item, index }) |
<template #default="{ item }">...</template> |
由父组件在 h() 里用 { default: ({item}) => [...] } 提供 |
<template #extra>...</template> |
{ extra: () => [...] } |
回答本课的两个问题
| 问题 | 参考解释 |
|---|---|
| 普通插槽和作用域插槽的区别是什么? | 普通插槽只提供位置;作用域插槽在这个位置上还把子组件的数据递出来,让父组件能基于数据渲染。 |
keep-alive 解决什么问题? |
组件切换时默认会被销毁重建,内部状态随之丢失。keep-alive 把切走的组件缓存下来,切回来直接复用,状态得以保留。 |
五个必须能答上来的问题
| 考考自己 | 可以这样通俗回答 |
|---|---|
| 具名插槽的简写是什么? | #名字,完整写法是 v-slot:名字。 |
模板里两个 <template> 的书写顺序会影响显示位置吗? |
不会,位置由子组件里的 <slot> 决定。 |
| 插槽可以写默认内容吗? | 可以,写在 <slot> 标签中间,使用者不填时才显示。 |
<component :is="x"> 里的 x 可以是什么? |
已注册组件的名字字符串,或组件对象本身(推荐后者)。 |
什么时候不该用 keep-alive? |
视图很重、数量很多,或者根本不需要保留状态时。 |
一分钟现场演示指南
- 展示通用性:同一个
ListBox两种渲染方式并排,说明“组件只给骨架,皮肤由调用方决定”。 - 展示插槽位置:现场交换两个
template顺序,指出显示顺序不变。 - 展示 keep-alive:两个计数器对比,切走再回来,一个归零一个保留。
- 收尾一句:“插槽让组件能被定制,keep-alive 让切换不丢状态。”
交作业前最后检查
src/App.vue与练习记录.md已保存。- 三种插槽各有至少一处使用。
- 动态组件至少 3 个视图可切换。
keep-alive实验现象记录完整。- Console 无红色报错。
课后再做一个小对照
打开 Element Plus 官方文档的 el-card 组件页,找出它有哪些插槽(提示:header、footer、默认插槽),然后用 el-card 的插槽重写本课第一个面板的头部与底部,看看能不能少写一个自定义 Panel。写下一句话:什么时候该用现成组件的插槽,什么时候值得自己写一个盒子组件。
来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(插槽 / 动态组件章节)· 组件依据 Element Plus 2.8 官方文档