外观
第3章:把页面拆成会协作的组件
约 18735 字大约 62 分钟
Vue 3组件化PropsEmits
2026-07-28
学习计划表已经能新增、筛选和统计,但所有状态、事件和模板都在 App.vue。现在增加“任务优先级、编辑弹窗、进度卡片”后,单文件会迅速变成一块难以修改的巨石。
组件化不是“看到一个 <div> 就拆一个文件”。真正的问题是:哪些内容一起变化、谁拥有状态、子组件需要什么输入、用户动作应该向谁报告?
项目目标
把学习计划表重构为页面、筛选栏、任务列表、任务项和统计卡片;使用 props 下发只读数据,使用 emits 上报用户动作,并能在生命周期结束时清理副作用。
配套资源
本章示例工程位于 resources/ch03/classroom-demo/,章末任务起始工程位于 resources/ch03/after-class-starter/。进入对应项目目录后执行 npm install、npm run dev。
🎯 学习梯度指引(分层通关)
- 核心必学(保底通关 · 组件通信协作):3.2、3.2.1、3.3、3.4、3.5、3.6、3.7、3.7.1、3.8、3.9.1 节。重点是掌握组件职责拆分与局部引用、
defineProps只读数据下发、defineEmits动作上报、父子组件协作实训、onMounted/onUnmounted生命周期定时器清理、<style scoped>样式隔离以及默认插槽<slot />通用卡片外壳。学完即可独立完成父子协作任务板! - 进阶选学(自主拓展探索):3.3.1 节(选项式 API 对比)、3.5.1 节(
provide/inject跨级传递)、3.7.2 节(私有自定义指令)、3.8 节(:deep()深度选择器)、3.8.1 节(public与src/assets路径规则)、3.9.2~3.9.3 节(具名插槽与作用域插槽)、3.9.4 节(动态组件与KeepAlive页面缓存)。供学有余力或想深入了解进阶组件封装的同学自主选学,完全不挂钩任何考核分数。 - 练习分层:每节练习均设【必做任务】与【选做挑战🌟】。必做任务面向全员基础通关;选做挑战完全由学生个人根据兴趣自主探索,不包含也不挂钩任何分数。
3.1 本章任务清单
本章继续改造第 2 章的学习计划表。功能先保持可用,再把单体 App.vue 逐步拆成职责清楚的组件。
| 任务 | 具体怎么做 | 完成后应该看到 |
|---|---|---|
| 画出组件边界 | 列出 TaskBoard、TaskFilter、TaskList、TaskItem、ProgressCard 的输入、输出和状态归属 | 得到一张组件责任表,能指出任务数组的唯一所有者 |
| 重构任务板 | 在 src/components/ 创建五个组件,把业务状态移入 TaskBoard | 页面效果与第 2 章一致,但 Devtools 中能看到清晰组件树 |
| 完成父子通信 | 用 defineProps 下发数据,用 defineEmits 上报切换、删除和筛选动作 | 子组件点击后父组件状态正确变化,没有直接修改 prop 的警告 |
| 完成计时器清理 | 创建 FocusTimer.vue,挂载时启动计时,卸载时清理定时器 | 进入页面开始计时,离开后计时停止,再进入时从初始状态开始 |
| 使用插槽复用面板 | 创建带标题区和内容区的通用面板,比较缓存与不缓存的动态组件 | 两个面板复用同一外壳,切换时能说明状态是否保留 |
开工顺序
- 先备份当前可运行版本,记录第 2 章的正常操作路径。
- 创建组件文件,但每次只迁移一块模板,迁移后立即运行。
- 把任务数组和筛选条件保留在
TaskBoard,逐个接通 props 与 emits。 - 在 Vue Devtools 检查组件层级和每个组件可见的状态。
- 最后加入
FocusTimer、插槽面板和动态组件对比。
完成后应该是什么样
页面仍然是一块可新增、筛选、勾选和删除的任务板,但代码已经拆成多个组件。Vue Devtools 中能看到 TaskBoard → TaskList → TaskItem 层级;专注计时器可以进入、暂停和离开,离开后不会继续计时。
3.2 先画职责,再创建文件
学习目标
掌握组件职责的判断方法,能够在创建文件前说清每个组件负责什么、需要什么和会报告什么。
语法/概念
组件可以理解成“页面中的一个小岗位”。拆组件不是把页面切得越碎越好,而是让每个文件只管一类事情。判断一块内容是否值得成为组件,先问三个问题:
- 职责:能不能用一句动词说清它做什么;
- 复用:它会不会在别处以同样规则再次出现;
- 变化:它会不会因为自己的需求单独修改。
组件职责表就是创建文件前的“分工单”。语法模板如下:
| 组件 | 负责显示什么 | 需要的输入 | 报告的动作 | 状态由谁保存 |
| --- | --- | --- | --- | --- |
| ComponentName | 一句话职责 | props | emits | 状态所有者 |本章任务板的分工如下:
| 组件 | 可见职责 | 输入 | 输出 | 状态归属 |
|---|---|---|---|---|
TaskBoard | 组织整块任务业务 | 无 | 无 | 任务数组、筛选条件 |
TaskFilter | 显示筛选控件 | 当前筛选值 | change-filter | 不持有业务真源 |
TaskList | 列出任务并转发动作 | 可见任务数组 | toggle-task、remove-task | 不持有任务数组 |
TaskItem | 显示单条任务 | 单个任务 | toggle、remove | 仅局部界面状态 |
ProgressCard | 显示完成进度 | 总数、完成数 | 无 | 无 |
不按行数机械拆分
文件长不等于必须拆。一个职责单一的 150 行表单,可能比五个只会互相转发数据的小文件更清楚。先写分工,再决定文件边界。
课堂演示
本节先不急着创建五个组件,只把第 2 章页面里的三块职责标出来。
步骤 1:打开课堂演示项目
在终端进入本章课堂演示项目并启动开发服务器:
cd docs/courses/web-frontend-framework/resources/ch03/classroom-demo
npm install
npm run dev运行后,终端应显示一个本地访问地址。用浏览器打开它,应该能看到当前的任务板。
步骤 2:用最小页面观察三种职责
把 src/App.vue 暂时替换成下面的代码:
<script setup>
import { computed, ref } from 'vue'
// 任务数组真源:包含每条任务的唯一 ID、标题与是否完成状态
const tasks = ref([
{ id: 1, title: '画出组件职责表', done: true },
{ id: 2, title: '创建任务组件', done: false },
])
// 计算属性:依赖 tasks 自动计算已完成任务数量
const completed = computed(() => {
return tasks.value.filter(task => task.done).length
})
</script>
<template>
<main class="task-board">
<!-- 职责 1:页面头部说明区 -->
<header class="board-header">
<h1>学习任务板</h1>
<p>这一块负责说明任务板是什么。</p>
</header>
<!-- 职责 2:任务列表展示区 -->
<section class="task-list">
<h2>任务列表</h2>
<p v-for="task in tasks" :key="task.id">
{{ task.done ? '✅' : '⬜' }} {{ task.title }}
</p>
</section>
<!-- 职责 3:完成进度统计卡片区 -->
<aside class="progress-card">
已完成 {{ completed }} / {{ tasks.length }}
</aside>
</main>
</template>
<style scoped>
.task-board {
max-width: 680px;
margin: 40px auto;
font-family: system-ui, sans-serif;
}
.board-header,
.task-list,
.progress-card {
margin-top: 16px;
padding: 20px;
border: 1px solid #d8e0ea;
border-radius: 12px;
}
</style>保存后,浏览器应出现“任务板说明、任务列表、完成统计”三块内容。此时它们仍在同一个文件里,但三种职责已经能被看出来。
步骤 3:给三块内容写岗位说明
在纸上或注释中分别写下:
board-header:说明任务板;task-list:显示多条任务;progress-card:显示完成数量。
写完后,应该能用一句话解释每一块为什么可能独立变化。能解释职责,才进入创建组件文件这一步。
当堂练习
【必做任务(基础通关)】 在课堂演示代码中增加一块“筛选栏”,放“全部、未完成、已完成”三个静态按钮,暂时不需要写筛选交互。在组件注释中写下一行职责说明:“筛选栏只负责提供筛选入口,不保存任务数组业务真源”。保存后确认页面能正常显示该筛选栏结构。
【选做挑战(🌟 自主拓展)】 尝试在草稿纸或编辑器注释中画出当前页面的“组件职责树”,标注出“谁持有 tasks 数组”、“谁负责通知筛选条件改变”,用箭头标出它们之间的数据流向。
必做任务预计 10 分钟完成。
3.2.1 组件的注册和引用
学习目标
掌握 <script setup> 中组件的导入和引用,能够把一个子组件放进父组件页面。
语法/概念
组件文件创建好以后,父组件必须先“认识它”,才能在模板里使用。<script setup> 的规则很直接:导入就是局部注册,不需要再写 components: {}。
<script setup>
// 局部导入子组件:在 <script setup> 中导入后,即可在模板中作为自定义标签使用
import TaskItem from './components/TaskItem.vue'
</script>
<template>
<!-- 推荐使用 PascalCase 大驼峰单标签,清晰区分原生 HTML 标签与自定义组件 -->
<TaskItem />
</template>这里的“局部”表示:只有导入了 TaskItem 的这个组件能直接使用它。全局注册适合全站很多页面都要用的基础按钮、图标等极少数组件,可以在入口文件使用 app.component();本课程的业务组件统一使用局部导入。
课堂演示
步骤 1:创建子组件文件
在课堂演示项目中创建 src/components/TaskItem.vue。创建完成后,编辑器目录里应出现 components 文件夹和 TaskItem.vue 文件;浏览器暂时不会变化,因为父组件还没有引用它。
步骤 2:导入并显示子组件
把两个文件改成下面这样:
导入并引用 TaskItem
src
App.vue
components
TaskItem.vue
src/App.vue
<script setup>
// 1. 导入子组件 TaskItem(注意使用相对路径 ./ 并指明 .vue 扩展名)
import TaskItem from './components/TaskItem.vue'
</script>
<template>
<main>
<h1>学习任务板</h1>
<!-- 2. 在模板中直接引用子组件标签 -->
<TaskItem />
</main>
</template>
<style scoped>
/* scoped 限制样式仅在当前 App.vue 容器内生效 */
main {
max-width: 680px;
margin: 40px auto;
font-family: system-ui, sans-serif;
}
</style>src/components/TaskItem.vue
<template>
<!-- 任务项子组件模板:负责渲染单条任务的卡片外观 -->
<article class="task-item">
<span>⬜</span>
<strong>认识组件的导入和引用</strong>
</article>
</template>
<style scoped>
/* 子组件专属独立样式,与外界完全隔离 */
.task-item {
display: flex;
gap: 10px;
padding: 16px;
border: 1px solid #d8e0ea;
border-radius: 10px;
}
</style>保存后,浏览器应在“学习任务板”下面显示一条任务。删掉 import TaskItem ... 再保存,页面会报“组件无法解析”一类的警告;恢复导入后页面应重新正常显示。
步骤 3:确认运行命令
如果开发服务器已经停止,在课堂演示项目目录重新执行:
npm run dev运行后看到任务条目,并且 Console 没有组件解析警告,就说明“导入即局部注册”已经生效。
当堂练习
【必做任务(基础通关)】 新建 src/components/ProgressCard.vue,组件内仅展示静态文字“完成进度:0 / 1”。在 App.vue 的 <script setup> 中导入并在 <template> 中放置于 TaskItem 上方。保存后确认浏览器无组件解析错误,页面同时显示进度卡与任务条目。
【选做挑战(🌟 自主拓展)】 故意把 App.vue 中的组件导入路径写错一个字母(如 ./components/progressCard.vue),在浏览器控制台观察并记录 Vue 抛出的告警信息,随后将其纠正。
必做任务预计 10 分钟完成。
3.3 单向数据流:数据下去,事件上来
学习目标
掌握 Vue 父子组件的单向数据流,能够判断数据应该由谁保存、动作应该向谁报告。
语法/概念
在组件化开发中,单体页面被拆成了互相独立的组件。多个组件之间要协同工作,必须有一套严格的“跨组件沟通机制”。这套机制由两个核心概念组成:props 与 emits。
1. 概念启蒙:认识组件通信的两大基石
初学者面对这两个英文缩写,往往容易产生陌生感。其实它们的本质与原生 HTML 和 JavaScript 函数完全一致:
| 通信工具 | 英文词源与本义 | 概念本质 | 数据流动方向 | 典型语法表现 |
|---|---|---|---|---|
props | Properties(属性) | 父组件传给子组件的入参。 如同给原生标签写 <img src="logo.png">,父组件使用 <TaskItem :task="task" /> 传递动态数据。子组件接收后用来决定如何渲染。 | ⬇️ 从父流向子(单向向下) | 父组件模板::task="task"子组件脚本: defineProps(...) |
emits | Emit(发射、上报) | 子组件向父组件发出的事件通知。 除了浏览器原生的 @click,子组件可以向外发射专属的业务事件(如通知任务状态变更)。本质是向父组件上报操作意图。 | ⬆️ 从子汇报给父(单向上报) | 子组件脚本:emit('toggle', id)父组件模板: @toggle="toggleTask" |
2. 权责机制:单向数据流的设计原则与工程约束
Vue 组件通信的最根本铁律是:数据只能单向流动——“数据向下传,事件向上报”。
<!-- 父组件模板:把数据当成 prop 传下去,把事件当成 @监听器 迎上来 -->
<TaskItem
:task="task"
@toggle="toggleTask"
/>初学者最常产生一个疑问:“既然子组件已经通过 props 拿到了任务对象,为什么不能在子组件内部直接写 task.done = !task.done?”
这种直接修改被称为“突变(Mutation)”。如果不加限制地允许子组件篡改父组件传入的数据,会引发严重的工程灾难:
- “多头篡改,全员背锅”的调试困境:若父组件把同一份任务列表同时下发给了列表组件、统计卡片组件和图表组件,一旦其中一个子组件在私下把数据改了,其他所有组件的显示会莫名其妙跟着错乱。当业务出现 Bug 时,开发者必须逐个排查所有子组件的每一行代码,根本无法判断数据究竟是在何时、被谁改坏的。
- 工业权责对标(主账户与只读账单):银行系统持有账户真实资金(父组件持有数据真源),副卡持有人(子组件)手中拿到的只是一张“只读账单(prop)”。若副卡用户想要完成消费或调整额度,绝不能自己拿笔在账单上修改数字,而是必须在终端发起“扣款申请(emit)”,由银行后台集中校验权限并统一扣减账额。
单向数据流核心铁律
- 唯一修改权:数据真源保存在谁那里,就只能由谁负责修改。
- 只读镜像:子组件拿到的
props是只读的展示副本,绝不允许直接赋值修改。 - 意图汇报:子组件需要变更业务状态时,唯一的合法途径是通过
emit向上汇报动作意图与必要参数,由父组件执行实际的状态更新。
观察下方组件流程图,分别在 props 与 emit 节点暂停,重点对比两条相反回路的传递方向与权责分工:
交互演示
父子组件的单向数据流
父组件拥有业务状态,子组件接收数据并报告用户动作。
- 1父级持有state
- 2属性下发props
- 3动作上报emit
- 4父级更新handler
当前发生
商品数组由页面组件持有,作为当前业务真源。
观察证据
新增、删除后的列表都来自同一数组。
课堂演示
步骤 1:准备父组件和子组件
在课堂演示项目中保留 src/App.vue,并创建 src/components/TaskItem.vue。目录准备好后,浏览器效果还不会完整,下一步再接通数据和事件。
步骤 2:接通“数据下去、事件上来”
最小单向数据流
src
App.vue
components
TaskItem.vue
src/App.vue
<script setup>
import { ref } from 'vue'
import TaskItem from './components/TaskItem.vue'
// 业务数据真源:由父组件统一持有并维护
const task = ref({
id: 1,
title: '说清单向数据流',
done: false,
})
// 状态切换业务函数:唯有数据所有者(父组件)有权执行真实状态更新
function toggleTask(id) {
if (task.value.id === id) {
task.value.done = !task.value.done
}
}
</script>
<template>
<main>
<h1>学习任务板</h1>
<p>任务数据保存在 App.vue</p>
<!-- :task 向下传递只读数据,@toggle 接收子组件向上传递的动作意图 -->
<TaskItem :task="task" @toggle="toggleTask" />
</main>
</template>src/components/TaskItem.vue
<script setup>
// 1. 声明接收来自父组件的数据入参(必须为对象且必填)
defineProps({
task: {
type: Object,
required: true,
},
})
// 2. 声明对外发射的自定义事件列表(登记可上报的事件代号)
const emit = defineEmits(['toggle'])
</script>
<template>
<article>
<!-- 仅展示只读数据,绝不在子组件内直接赋值修改 task.done -->
<span>{{ task.done ? '✅' : '⬜' }} {{ task.title }}</span>
<!-- 按钮点击触发 emit,把当前任务 id 作为载荷凭证向上报告给父组件 -->
<button type="button" @click="emit('toggle', task.id)">
切换状态
</button>
</article>
</template>保存后,页面应显示一条未完成任务。点击“切换状态”,方框应在 ⬜ 和 ✅ 之间变化。真正修改 done 的代码在父组件,子组件只报告任务 id。
步骤 3:重新运行并检查
在项目目录执行:
npm run dev运行后连续点击两次按钮,任务应先完成再恢复未完成,Console 不应出现“修改 prop”的警告。
当堂练习
【必做任务(基础通关)】 在父组件中新增 selectedId 响应式状态(初始为 null),子组件新增 select 自定义事件。当点击任务文字时,子组件将 task.id 报告给父组件,父组件更新 selectedId 并在页面顶部展示“当前选中任务 ID:{{ selectedId }}”。
【选做挑战(🌟 自主拓展)】 在父组件中为当前被选中的任务条目添加一个高亮边框样式(通过 :class="{ active: selectedId === task.id }"),直观体验父组件状态变化反向驱动子组件外观更新。
必做任务预计 10 分钟完成。
3.3.1 选项式 API 和组合式 API
🌟 进阶选学:选项式 API 与组合式 API 对比
Vue 3 全面支持“组合式 API(Composition API)”与“选项式 API(Options API)”。选项式 API 是 Vue 2 的经典写法,本课程及现代前端开发全员统一使用更直观、更易复用的 <script setup> 组合式 API。本节的对比主要用于帮助同学们在查阅外部资料或维护历史代码时看懂结构,无需花费精力记忆选项式语法。
学习目标
掌握选项式 API 与组合式 API 的基本区别,能够认出本课程统一使用的 <script setup> 组合式写法。
语法/概念
Vue 常见的两种写法都能做页面,区别主要在“代码怎么摆放”:
| 写法 | 简单理解 | 常见标志 |
|---|---|---|
| 选项式 API | 按代码类型分抽屉:数据放 data,方法放 methods | data()、methods、computed |
| 组合式 API | 按业务功能放在一起:任务状态和任务函数靠在一起 | ref()、普通函数、生命周期函数 |
本课程统一使用 <script setup> 的组合式 API。这样写更短,也方便把同一项业务的状态和函数放在一起。这里只需要会认选项式 API,不要求写完整的选项式组件。
语法模板:
<script setup>
import { ref } from 'vue'
// 组合式 API:响应式数据与操作函数天然就近组织在一起
const count = ref(0)
function increase() {
count.value += 1 // 在脚本逻辑中修改 ref 必须通过 .value
}
</script>记住这一句
在 <script setup> 的 JavaScript 中读取或修改 ref 要写 .value;在模板中使用时直接写变量名。
课堂演示
步骤 1:替换为组合式最小案例
把课堂演示项目的 src/App.vue 替换为:
<script setup>
import { ref } from 'vue'
// 状态定义:记录今日已完成的任务项数量
const completed = ref(0)
// 业务函数:每次执行将完成数量累加 1
function completeOne() {
completed.value += 1
}
</script>
<template>
<main>
<h1>今日学习进度</h1>
<!-- 模板中直接绑定变量,Vue 自动完成响应式解包 -->
<p>已完成:{{ completed }} 项</p>
<!-- 点击按钮触发就近定义的业务函数 -->
<button type="button" @click="completeOne">完成一项</button>
</main>
</template>保存后,页面应显示“已完成:0 项”。点击按钮,数字应每次增加 1。
步骤 2:对应状态和函数
找到 completed 和 completeOne。它们都在处理“完成数量”,所以放在一起阅读。对比选项式 API 时,只需要指出选项式会把数据和方法放到不同选项中。
说明完成后,应该能回答:“本课程为什么统一用 <script setup>?”——因为代码更短,并且同一业务的状态和函数可以靠在一起。
步骤 3:确认运行
在课堂演示项目目录执行:
npm run dev页面能连续累加数字,就说明组合式写法正常运行。
当堂练习
【必做任务(基础通关)】 在当前 <script setup> 案例中增加一个“重置进度”按钮,点击后调用 resetCount 函数将 completed 重置为 0,体验组合式 API 中响应式变量与操作函数天然就近组织的直观性。
【选做挑战(🌟 自主拓展)】 阅读教材中选项式 API 的代码片段,在草稿或注释中尝试用自己的话总结:为什么当项目逻辑变复杂时,组合式 API 将同一功能的“数据”与“函数”聚合在一起,比选项式 API 拆散在 data() 与 methods 抽屉中更易定位问题?
必做任务预计 5 分钟完成。
3.4 defineProps:声明组件需要什么
学习目标
掌握 defineProps 的 type、required 和 default,能够给子组件声明清楚的只读输入。
语法/概念
子组件是一个独立的界面构件。如果说子组件是一台标准化的“工业仪器”,那么 props 就是仪器面板上的“接入端口”,而 defineProps 则是子组件对外公开发布的“入参规格说明书(接口合同)”。
1. 入参合同:对象式校验的工程防护价值
许多初学者在网络资料中会看到 defineProps(['title', 'total']) 这种简略的字符串数组写法。但在真实工程开发中,一律推荐使用对象配置语法:
// 推荐:提供完整类型与约束规则的“工程合同”
const props = defineProps({
title: {
type: String,
required: true, // 必填项,不传则开发环境报错
},
label: {
type: String,
default: '任务', // 选填项,缺省时的保底默认值
},
tags: {
type: Array,
default: () => [], // 数组/对象默认值必须使用工厂函数
},
})对象式声明不仅是写给协作同事看的接口文档,更是 Vue 运行时的“安全气囊”:
| 校验字段 | 概念本质 | 工程作用与防御效果 |
|---|---|---|
type | 类型契约 | 限制入参必须是 String、Number、Boolean、Array、Object 等。一旦父组件传错类型(如该传数字却传了字符串 '3'),控制台会立刻打印清晰的警告:[Vue warn]: Invalid prop: type check failed。 |
required | 必选约束 | 标记为 true 表示缺少该参数组件将无法正常工作。若父组件调用时遗漏,Vue 会立即抛出必填项缺失警报。 |
default | 缺省兜底 | 当父组件未传该属性时采用的保底值。确保即使父组件没有给值,子组件的模板渲染也不会崩溃。 |
2. 双重环境访问界限:模板解包与脚本访问
初学者在使用 props 时,最容易遭遇 xxx is not defined 的报错,原因是没有分清两种上下文环境:
- 在
<template>模板中:Vue 会自动对顶层 props 进行解包。你可以直接使用属性名{{ title }},也可以写成{{ props.title }},两者完全等价。 - 在
<script setup>脚本中:必须先使用一个变量接收const props = defineProps(...),然后必须通过props.title读取!直接在 JavaScript 逻辑中写title会直接抛出未定义异常。
3. 关键避坑指南与使用红线
引用类型默认值的工厂函数陷阱
定义 Array 或 Object 类型的默认值时,绝不能直接写字面量(如 default: [] 或 default: {}),必须写成返回新对象的工厂函数:
// ❌ 错误示范:会导致所有子组件实例共用同一个内存地址,相互污染
default: []
// ✅ 正确规范:每次创建组件实例时,返回一个全新的独立数组
default: () => []Props 是只读数据,严禁直接篡改
在子组件内执行 props.title = '新标题' 是严重的违规操作,Vue 会在控制台抛出不可修改的警告。如果子组件确实需要一个能够随意改动的本地副本,应当将其初始化为一个独立的本地响应式状态:
// ✅ 将 prop 作为初始值存入本地 ref,后续只改本地状态
const localTitle = ref(props.title)此外,严禁在解构 defineProps(如 const { title } = defineProps(...)),解构会直接将响应式追踪链条剪断,父组件后续的更新将无法自动同步。
课堂演示
步骤 1:创建统计卡组件
创建 src/components/ProgressCard.vue。文件出现后,浏览器暂时不会显示统计卡,下一步由父组件传值。
步骤 2:声明并传入 props
ProgressCard 的 props
src
App.vue
components
ProgressCard.vue
src/App.vue
<script setup>
// 导入展示进度统计的卡片子组件
import ProgressCard from './components/ProgressCard.vue'
</script>
<template>
<main>
<h1>学习任务板</h1>
<!-- :total 动态绑定数值 3;未传入 label,子组件将自动使用 default 默认值 -->
<ProgressCard :total="3" />
</main>
</template>src/components/ProgressCard.vue
<script setup>
// 使用对象语法严格定义组件接收的入参规格(类型校验与兜底机制)
const props = defineProps({
total: {
type: Number, // 必须为数字类型
required: true, // 必传属性,不传则控制台报警
},
label: {
type: String, // 必须为字符串类型
default: '任务总数', // 缺省时的保底文案
},
})
</script>
<template>
<aside class="progress-card">
<!-- 在模板中展示接收到的入参数据 -->
<strong>{{ props.label }}</strong>
<span>{{ props.total }}</span>
</aside>
</template>
<style scoped>
/* 统计卡片的独立局部外观 */
.progress-card {
display: flex;
justify-content: space-between;
max-width: 360px;
padding: 18px;
border-radius: 12px;
background: #eef6ff;
}
</style>保存后,页面应显示“任务总数 3”。父组件没有传 label,所以子组件使用默认值“任务总数”。
步骤 3:观察父组件改值
把父组件写成 <ProgressCard :total="5" label="今日任务" /> 并保存。页面应立即变成“今日任务 5”,说明 props 的值由父组件决定。
如需重新启动,在项目目录执行:
npm run dev当堂练习
【必做任务(基础通关)】 在 ProgressCard.vue 中为 defineProps 新增一个必传数字属性 completed(type: Number, required: true)。父组件传入 :completed="2",子组件展示为“已完成:2 / 5”。保存后确认页面正常展示且控制台无类型警告。
【选做挑战(🌟 自主拓展)】 故意在父组件中给 :completed 传入一个非数字类型(例如字符串 'two'),打开浏览器控制台观察 Vue 抛出的详细运行时类型警告(type check failed),体会 defineProps 强类型校验对协作规范的保障作用。
必做任务预计 10 分钟完成。
3.5 defineEmits:把动作报告给所有者
学习目标
掌握 defineEmits 的声明、触发和载荷传递,能够让子组件把用户动作报告给父组件。
语法/概念
在单向数据流体系中,子组件不能私自修改数据。当用户在子组件中点击了“删除”或“勾选”按钮时,子组件必须通过 defineEmits 将动作意图向上报告给拥有状态的父组件。
1. 业务对讲机:组件解耦与事件上报机制
子组件处于交互的第一线,它清楚地知道“按钮被点击了”这一事实;但它并不掌握全局数据,无权也不应知道“数组应该如何做变更过滤”。
defineEmits 的本质就是在子组件内部安装一部“业务对讲机”:子组件通过呼叫特定的“事件代号”(如 toggle、remove),向外发射信号,交由监听该频道的父组件去执行具体的业务变更。
// 1. 在子组件中显式登记可发射的事件清单
const emit = defineEmits(['toggle', 'remove'])
// 2. 在业务触发时向外发射事件,并附带数据包裹(载荷)
function reportRemove() {
emit('remove', props.taskId)
}2. 载荷传递与最小必要凭证原则
在计算机网络与进程通信中,每次发射所附带的有效数据正文被称为“载荷(Payload)”:
emit('事件代号', 载荷数据)- 第一实参(事件代号):必须使用语义明确的动作词,如
toggle、remove、change-filter。 - 第二实参(载荷 Payload):向父组件汇报动作所必需的最小关键凭证。
- 最小传输原则:例如删除任务时,只需将具有唯一性的
props.taskId上报即可,父组件即可利用id在数组中精准过滤;切忌把庞大的整个对象甚至不相干的数据一股脑打包回传,造成通信冗余和权责混乱。
3. 父组件接收事件的三种语法形态
父组件使用 @事件代号 监听子组件发出的通知。在不同业务场景下,父组件接收载荷有三种标准形态:
| 接收形态 | 语法示范 | 适用场景与机制说明 |
|---|---|---|
| 方法名直接绑定(推荐) | @remove="handleRemove" | 最标准规范写法。父组件定义 function handleRemove(id),子组件传出的载荷会自动作为第一个形参传入,无需手写括号。 |
内联简写与 $event | @toggle="activeId = $event" | 仅用于极简的一行赋值操作。在模板内联表达式中,$event 代表子组件上报出来的载荷值。 |
| 箭头函数多参透传 | @remove="(id) => handle(id, '额外参数')" | 当既需要接收子组件上报的 id,又需要混搭父组件当前作用域的私有变量时使用。 |
4. 显式声明约束与命名规范
显式登记自定义事件的防御机制
在 Vue 3 中,如果在子组件中使用了未在 defineEmits 登记的事件名(例如直接在模板中写 $emit('click')),Vue 可能会将其误判为原生 DOM 事件并穿透渗透到子组件的最外层根标签,导致事件被重复触发两次。显式登记是防止事件穿透混淆的强制性规范。
- 命名规范:在 JavaScript 中声明事件时,可使用小驼峰(如
taskChange);但在父组件的<template>模板中监听时,一律推荐使用短横线连字符(如@task-change),完全遵循 HTML 属性大小写不敏感的标准规范。
课堂演示
步骤 1:准备父子组件
保留 src/App.vue,创建或覆盖 src/components/TaskItem.vue。下一步会让按钮只负责报告动作。
步骤 2:声明、触发并接收事件
TaskItem 报告用户动作
src
App.vue
components
TaskItem.vue
src/App.vue
<script setup>
import { ref } from 'vue'
import TaskItem from './components/TaskItem.vue'
// 记录收到的子组件动作消息
const message = ref('还没有收到动作')
// 接收切换事件通知,载荷 id 会自动作为第一形参传入
function handleToggle(id) {
message.value = `父组件收到:切换任务 ${id}`
}
// 接收删除事件通知
function handleRemove(id) {
message.value = `父组件收到:删除任务 ${id}`
}
</script>
<template>
<main>
<h1>组件事件演示</h1>
<!-- 监听子组件自定义事件:@toggle 与 @remove -->
<TaskItem
:task-id="7"
title="练习 defineEmits"
@toggle="handleToggle"
@remove="handleRemove"
/>
<p>{{ message }}</p>
</main>
</template>src/components/TaskItem.vue
<script setup>
// 1. 声明入参:任务 ID 与任务标题
const props = defineProps({
taskId: {
type: Number,
required: true,
},
title: {
type: String,
required: true,
},
})
// 2. 显式登记组件对外发射的事件代号清单
const emit = defineEmits(['toggle', 'remove'])
</script>
<template>
<article>
<strong>{{ props.title }}</strong>
<!-- 点击“切换”:呼叫 toggle 频道,将 taskId 作为载荷上报 -->
<button type="button" @click="emit('toggle', props.taskId)">
切换
</button>
<!-- 点击“删除”:呼叫 remove 频道,将 taskId 作为载荷上报 -->
<button type="button" @click="emit('remove', props.taskId)">
删除
</button>
</article>
</template>保存后,点击“切换”应看到“父组件收到:切换任务 7”;点击“删除”应看到“父组件收到:删除任务 7”。子组件没有修改父组件的 message,只发送了事件和 id。
步骤 3:检查事件名是否一致
把父组件的 @toggle 临时改成 @change 并保存。点击“切换”后文字不会变化,因为父子事件名没有对上。恢复 @toggle 后应重新正常。
在项目目录执行以下命令可重新启动:
npm run dev当堂练习
【必做任务(基础通关)】 在 TaskItem.vue 中新增一个“稍后处理”按钮,点击时触发自定义事件 postpone,并将 props.taskId 作为载荷上报给父组件。父组件监听 @postpone 并在页面提示:“任务已标记为稍后处理”。
【选做挑战(🌟 自主拓展)】 尝试在 defineEmits 中使用对象形式增加载荷验证函数(例如校验 id 必须为大于 0 的数字),体验 Vue 对自定义事件参数的规范化校验机制。
必做任务预计 10 分钟完成。
3.5.1 跨级组件传递:provide 和 inject
🌟 进阶选学:跨级组件传递(provide 与 inject)
在标准单向数据流中,父子通信优先使用 props 与 emits,数据流向清晰明确。只有在层级极深且中间层完全不关心数据的极少数场景下,才会使用 provide 与 inject 依赖注入。在企业级中大型应用中,多组件共享复杂状态通常直接采用全局状态管理库 Pinia(将在第 7 章系统讲授)。本节跨级传递作为进阶拓展,供学有余力者选学。
学习目标
掌握 provide 和 inject 的基础用法,能够把课程名称从上层组件传给较深的后代组件。
语法/概念
如果数据要从爷爷组件传到孙子组件,而中间组件完全不用它,一层层写 props 会让中间组件只剩“传话”。provide 像上层放进一个共享取件柜,后代组件用 inject 按名字取出。
// 上层组件提供
provide('keyName', value)
// 任意后代组件读取
const value = inject('keyName', '没有提供时的默认值')它适合课程名称、主题设置这类很多后代都可能读取的数据。普通父子通信仍优先使用 props 和 emits;不要把所有状态都塞进 provide。
课堂演示
步骤 1:创建三级组件
创建 src/components/TaskBoard.vue 和 src/components/BoardTitle.vue。结构是 App → TaskBoard → BoardTitle。创建后浏览器还不会显示完整内容,下一步再接通注入。
步骤 2:上层提供,后代读取
provide 与 inject 最小演示
src
App.vue
components
TaskBoard.vue
BoardTitle.vue
src/App.vue
<script setup>
import { provide } from 'vue'
import TaskBoard from './components/TaskBoard.vue'
// 顶层祖先组件通过 provide 提供数据:标识为 'courseName'
provide('courseName', 'Web前端框架开发')
</script>
<template>
<main>
<TaskBoard />
</main>
</template>src/components/TaskBoard.vue
<script setup>
import BoardTitle from './BoardTitle.vue'
// 中间层组件无需声明 props,也不需要手动逐层传递 courseName
</script>
<template>
<section class="task-board">
<BoardTitle />
<p>TaskBoard 没有接收和转发 courseName。</p>
</section>
</template>src/components/BoardTitle.vue
<script setup>
import { inject } from 'vue'
// 深层后代组件通过 inject 按键名直接提取数据,第二个参数提供兜底默认值
const courseName = inject('courseName', '未设置课程')
</script>
<template>
<h1>{{ courseName }}学习任务板</h1>
</template>保存后,页面应显示“Web前端框架开发学习任务板”。TaskBoard 中没有 courseName prop,说明数据越过了中间层。
步骤 3:观察默认值
临时注释掉 App.vue 的 provide 并保存,标题应变成“未设置课程学习任务板”;恢复后应显示原课程名。
在项目目录执行以下命令可重新启动:
npm run dev当堂练习
【必做任务(基础通关)】 在 App.vue 中使用 provide('className', '前端实训 1 班') 提供班级名称;在深层后代组件 BoardTitle.vue 中使用 inject('className', '默认班级') 注入并展示为副标题。确认中间组件 TaskBoard.vue 不需要声明或转发该属性。
【选做挑战(🌟 自主拓展)】 尝试在深层组件中直接修改通过 inject 获取到的变量值,并在控制台观察现象。思考并说明:为什么 Vue 官方更推荐由“提供数据的组件”统一提供修改方法,而不是让任意后代组件直接修改?
必做任务预计 10 分钟完成。
3.6 实训一:重构学习计划表
目标结构包含五个互相协作的文件:
任务板组件结构
src
App.vue# 应用入口,只负责放置业务模块
components
TaskBoard.vue# 任务状态所有者
TaskFilter.vue# 筛选输入与事件
TaskList.vue# 列表组织与事件转发
TaskItem.vue# 单条任务展示与动作
ProgressCard.vue# 纯展示统计卡
先在纸上写出每个组件的输入和输出。状态所有者应是能够同时完成新增、筛选、切换和删除的最小公共父级。
问题:任务数组应该由哪个组件持有?筛选栏如何请求父级修改筛选值?
验证时打开 Vue Devtools:
TaskBoard能看到任务数组与筛选状态;TaskItem只能看到自己的taskprop;- 点击按钮后,组件事件向上出现,最终由
TaskBoard修改状态; - 删除和筛选后列表没有 key 警告。
3.7 生命周期:组件进入和离开时做什么
学习目标
掌握 onMounted 和 onUnmounted 的使用场景,能够在组件离开时清理定时器或事件监听。
语法/概念
组件也有“上班”和“下班”。组件第一次出现在页面上叫挂载,离开页面并被移除叫卸载:
onMounted:页面已经显示出来后执行,适合启动定时器、读取 DOM 或注册浏览器事件;onUnmounted:组件离开时执行,适合关闭定时器、删除事件监听;
语法模板:
import { onMounted, onUnmounted } from 'vue'
// 组件挂载完成(浏览器 DOM 真实呈现):适合启动定时器、请求网络数据或绑定全局事件
onMounted(() => {
// 启动需要长期运行的任务
})
// 组件准备卸载(即将从 DOM 树中移除):务必成对清理定时器或全局监听,杜绝内存泄漏
onUnmounted(() => {
// 把上面启动的任务彻底清理释放
})下面的流程展示组件从创建到离开的过程。重点停在“挂载”和“卸载”,观察启动与清理为什么必须成对出现。
交互演示
组件生命周期与资源清理
观察“创建—挂载—更新—卸载”各阶段适合做什么。
- 1创建状态setup
- 2完成挂载onMounted
- 3响应更新state change
- 4卸载清理onUnmounted
当前发生
声明响应式状态、计算属性和事件函数。
观察证据
此时不依赖真实 DOM 尺寸。
| 阶段 | 简单理解 | 本章常见操作 |
|---|---|---|
setup | 准备数据和函数 | 声明 ref、普通函数 |
onMounted | 组件已经显示 | 启动计时器、注册监听 |
onUpdated | 页面刚更新完 | 只了解,不展开 |
onUnmounted | 组件准备离开 | 清理计时器、删除监听 |
启动和清理要成对
如果在 onMounted 中启动了定时器或注册了 window 事件,就要在 onUnmounted 中清理。否则多次进入页面后,同一段代码可能同时运行很多份。
课堂演示
本节课堂演示就是下面的专注计时器。代码可以直接粘贴到课堂演示项目的 src/App.vue 运行。
3.7.1 实训:做一个会被正确清理的专注计时器
任务要求:组件挂载后每秒增加专注时长,用户可以暂停;组件卸载后定时器必须停止。
问题:启动与清理分别对应哪个生命周期钩子?负责清理资源的完整 API 名称是 onUnmounted。
验证时反复进入和离开包含该组件的页面。计时速度不能越来越快,Console 不能出现卸载后仍更新组件的提示。
步骤 1:运行计时器
解锁代码后,把它保存到 src/App.vue,然后在课堂演示项目目录执行:
npm run dev页面应显示“本次专注:0 秒”,数字每秒增加 1。
步骤 2:检查暂停与继续
点击“暂停”,数字应停止;再点击“继续”,数字应接着增加。保存代码并刷新后,数字应从 0 重新开始。
步骤 3:检查清理代码
指出 window.setInterval() 与 window.clearInterval() 的位置。说明完成后,应该能回答:组件离开时为什么不能把定时器留在后台继续运行?
当堂练习
【必做任务(基础通关)】 在计时器组件中增加键盘空格键控制暂停与继续:
- 在
onMounted中添加键盘事件监听:window.addEventListener('keydown', handleKeydown); - 在
handleKeydown中判断如果按下空格键(event.code === 'Space'),切换running.value状态; - 在
onUnmounted中使用同一个函数清理监听:window.removeEventListener('keydown', handleKeydown)。 保存后测试按下空格能正常暂停和继续,且在组件卸载后无残留事件。
【选做挑战(🌟 自主拓展)】 故意在 onUnmounted 中漏写 clearInterval(timer),在控制台打印每次定时器触发的日志。通过条件渲染多次隐藏并重新显示该组件,观察控制台打印频率是否成倍加速,切身体会不清理定时器带来的“内存泄漏与僵尸任务”危害。
必做任务预计 10 分钟完成。
3.7.2 私有自定义指令
🌟 进阶选学:私有自定义指令(DOM 底层操作)
现代 Vue 开发以“数据驱动”为核心理念,绝大部分界面交互都可以通过响应式状态绑定完成,直接操作原生 DOM 节点的需求极低。私有自定义指令主要用于极少数特定的 DOM 行为封装(如自动聚焦、第三方原生插件集成等)。本节作为进阶探索,了解其命名与生命周期钩子机制即可。
学习目标
掌握私有自定义指令的基本写法,能够使用 v-focus 让输入框显示后自动获得焦点。
语法/概念
指令是写在 HTML 标签上的小功能,例如已经学过的 v-if、v-model。当 Vue 自带指令不够用时,可以写自定义指令。
在 <script setup> 中,变量名以 v 开头就能作为当前组件的私有指令使用:
// 私有指令:在 <script setup> 中变量名以小写 v 开头即被自动识别为指令
const vFocus = {
// 元素挂载到真实 DOM 后自动执行该钩子
mounted(element) {
element.focus() // 调用原生 DOM 方法使输入框获得焦点
},
}模板写法:
<!-- 在模板中直接通过小写短横线语法绑定指令 -->
<input v-focus>“私有”表示只有当前组件能用。全局指令可以在应用入口注册,适合全站很多页面都要用的统一行为;本课程只练私有指令。
课堂演示
步骤 1:写一个自动聚焦指令
把课堂演示项目的 src/App.vue 替换为:
<script setup>
import { ref } from 'vue'
const taskTitle = ref('')
// 定义私有指令:挂载完成后自动执行聚焦
const vFocus = {
mounted(element) {
element.focus()
},
}
</script>
<template>
<main>
<h1>新增学习任务</h1>
<!-- 绑定 v-focus 指令,页面打开时无需鼠标点击即可自动激活光标 -->
<input
v-focus
v-model="taskTitle"
placeholder="输入任务标题"
>
<p>正在输入:{{ taskTitle }}</p>
</main>
</template>保存后,浏览器刷新时光标应自动出现在输入框中,不需要先用鼠标点击。
步骤 2:确认指令变量和模板名称
把 v-focus 临时改成 v-auto-focus 并保存。因为脚本中没有对应的 vAutoFocus,自动聚焦会失效。恢复 v-focus 后应重新生效。
步骤 3:运行项目
在课堂演示项目目录执行:
npm run dev打开或刷新页面后,直接输入文字;如果输入框马上收到内容,就说明指令已经执行。
当堂练习
【必做任务(基础通关)】 在当前组件中声明私有指令 v-focus(变量名为 vFocus),在 mounted(element) 钩子中执行 element.focus()。给任务输入框绑定 v-focus,保存并刷新页面,确认光标无需鼠标点击即可自动激活聚焦。
【选做挑战(🌟 自主拓展)】 尝试新增第二个私有指令 v-highlight:在元素挂载时将背景色设置为浅黄色(element.style.background = '#fef3c7'),并同时应用在同一个输入框上,观察多个指令协同工作的效果。
必做任务预计 10 分钟完成。
3.8 scoped 样式与边界
学习目标
掌握 <style scoped> 和 :deep() 的基础用法,能够限制当前组件样式并在确有需要时影响子组件内部元素。
语法/概念
普通 CSS 可能影响整个页面。<style scoped> 会给当前组件的元素加上范围标记,让选择器主要作用在当前组件里。它像在样式上贴了“本组件专用”标签。
<style scoped>
/* scoped 属性会在编译时为当前组件的选择器追加唯一的哈希属性选择器(如 .card[data-v-xxxx]),实现样式隔离 */
.card {
border: 1px solid #d8e0ea;
}
</style>🌟 进阶选学:深度选择器 :deep() 与样式穿透
组件化开发的核心原则之一是“样式封装隔离”,优先让每个组件维护自己的外观。:deep() 属于进阶覆盖手段,通常仅在引入 Element Plus 等第三方组件库且必须定制其内部深层结构时使用。初学阶段重点掌握 <style scoped> 的局部作用机制,避免随意穿透破坏封装性。
父组件的 scoped 样式默认不会直接选中子组件深处的元素。如果确实要从父组件影响子组件内部,可以使用 :deep():
/* :deep() 深度选择器:仅在定制第三方组件库深层内部节点时使用,突破 scoped 限制进行样式穿透 */
.panel :deep(.label) {
color: #2563eb;
}:deep() 会让父组件依赖子组件内部类名,所以只在明确需要时使用。组件能通过 prop、CSS 变量或公开 class 解决时,优先使用这些更清楚的接口。
课堂演示
步骤 1:准备父组件和状态标签
创建 src/components/StatusBadge.vue,保留 src/App.vue。下一步会对比普通 scoped 选择器和 :deep()。
步骤 2:写入父子组件
scoped 与 deep 最小演示
src
App.vue
components
StatusBadge.vue
src/App.vue
<script setup>
import StatusBadge from './components/StatusBadge.vue'
</script>
<template>
<main class="task-page">
<h1>学习任务板</h1>
<section class="panel">
<!-- 渲染状态徽标子组件 -->
<StatusBadge />
</section>
</main>
</template>
<style scoped>
.task-page {
max-width: 680px;
margin: 40px auto;
font-family: system-ui, sans-serif;
}
.panel {
padding: 20px;
border: 1px solid #d8e0ea;
}
/* 使用 :deep() 穿透子组件边界,强制将 StatusBadge 内部的 .label 文字修改为蓝色 */
.panel :deep(.label) {
color: #2563eb;
}
</style>src/components/StatusBadge.vue
<template>
<!-- 子组件维护自身的 DOM 结构与类名 -->
<span class="label">进行中</span>
</template>
<style scoped>
/* 子组件局部样式:提供圆角与浅蓝背景底色 */
.label {
padding: 4px 10px;
border-radius: 999px;
background: #e8f1ff;
}
</style>保存后,“进行中”应显示浅蓝背景和蓝色文字。背景来自子组件自己的 scoped 样式,蓝色文字来自父组件的 :deep(.label)。
步骤 3:用浏览器确认样式来源
打开开发者工具,选中“进行中”。在 Styles 面板中应看到两条来源不同的规则。临时删除 :deep() 只保留 .panel .label,保存后蓝色文字应失效。
如需重新启动,在项目目录执行:
npm run dev当堂练习
【必做任务(基础通关)】 在 StatusBadge.vue 中体验 <style scoped> 的样式隔离效果:给 .label 添加自定义圆角与背景色,确认在父组件中写同名 .label 样式不会污染子组件的原生显示。
【选做挑战(🌟 自主拓展)】 给 StatusBadge 增加一个内部类名为 dot 的小圆点,默认在子组件中显示为灰色;在父组件中使用 .panel :deep(.dot) 将其穿透覆盖为绿色。保存后观察编译生成的带属性哈希选择器(如 [data-v-xxx] .dot)。
必做任务预计 10 分钟完成。
3.8.1 引用静态资源:public 与 src/assets
🌟 进阶选学:静态资源打包机制与路径细节
在现代前端工程中,静态资源有两种常用处理模式:不需要经过打包编译的原样文件放在 public/(如第三方插件资源、站点图标),需要 Vite 参与哈希版本管理与代码打包的放在 src/assets/(如业务图标、界面切图)。日常开发遵循这一规范分工即可,无需过度深究底层的 Rollup 构建细节。
学习目标
掌握 public 与 src/assets 两种静态资源引用方式,能够根据文件是否需要参与构建选择正确路径。
语法/概念
图片、图标这类不会自己变化的文件叫静态资源。Vite 项目常见两个位置:
| 位置 | 简单理解 | 使用方式 |
|---|---|---|
public/ | 原样放到网站根目录,不经过打包处理 | 直接写 /file-name.svg |
src/assets/ | 跟着源码一起参与构建,路径由 Vite 处理 | 先 import,再绑定到 src |
语法模板:
<script setup>
// 方式二:src/assets 资源必须使用 import 显式导入,由 Vite 统一进行打包、压缩与哈希重命名
import taskIcon from './assets/task-icon.svg'
</script>
<template>
<!-- 方式一:public 目录下的资源在构建后直达网站根目录,直接使用根绝对路径 / 引用 -->
<img src="/course-badge.svg" alt="课程标志">
<!-- 动态绑定 import 导入的资源模块对象 -->
<img :src="taskIcon" alt="任务图标">
</template>不要在 JavaScript 中写电脑上的绝对路径,例如 /Users/name/Desktop/image.png。这种路径换一台电脑就失效。
课堂演示
步骤 1:创建两个英文资源文件
创建 public/course-badge.svg 和 src/assets/task-icon.svg。目录名、文件名保持纯英文。文件创建后页面暂时不会显示图片,下一步再引用。
步骤 2:分别使用根路径和导入路径
public 与 src/assets 引用
src
App.vue
assets
task-icon.svg
public
course-badge.svg
src/App.vue
<script setup>
// 导入业务图标资源:Vite 会为该资源生成带有哈希后缀的最终访问路径
import taskIcon from './assets/task-icon.svg'
</script>
<template>
<main>
<!-- 引用 public 目录静态资源:直接以 / 开头从站点根路径获取 -->
<img
class="course-badge"
src="/course-badge.svg"
alt="课程标志"
>
<h1>
<!-- 引用 src/assets 静态资源:通过属性绑定 :src 使用导入的对象 -->
<img class="task-icon" :src="taskIcon" alt="">
学习任务板
</h1>
</main>
</template>
<style scoped>
main {
max-width: 680px;
margin: 40px auto;
font-family: system-ui, sans-serif;
}
.course-badge {
width: 120px;
}
.task-icon {
width: 28px;
vertical-align: middle;
}
</style>public/course-badge.svg
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="48" viewBox="0 0 160 48">
<rect width="160" height="48" rx="12" fill="#e8f1ff"/>
<text x="80" y="30" text-anchor="middle" font-size="16" fill="#1d4ed8">
WEB COURSE
</text>
</svg>src/assets/task-icon.svg
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<rect x="5" y="4" width="22" height="24" rx="4" fill="#2563eb"/>
<path d="M10 11h12M10 16h12M10 21h8" stroke="white" stroke-width="2"/>
</svg>保存后,页面上方应出现“WEB COURSE”标志,标题前应出现蓝色任务图标。
步骤 3:确认运行路径
在课堂演示项目目录执行:
npm run dev如果 public 图片不显示,先检查模板路径是否以 / 开头;如果 src/assets 图片不显示,检查导入路径和文件名大小写是否一致。
当堂练习
【必做任务(基础通关)】 在 public/ 目录下放置或新建一张静态图片 empty-state.svg,在页面任务列表为空时使用 <img src="/empty-state.svg" alt="暂无任务"> 正常展示。
【选做挑战(🌟 自主拓展)】 尝试在 src/assets/ 中放置一张小图标,并在 <script setup> 中使用 import logo from './assets/task-icon.svg' 导入后绑定到 <img :src="logo">,在浏览器 Elements 面板中对比它与 public/ 图片最终渲染出的 URL 路径差异(观察打包后的资源引用机制)。
必做任务预计 10 分钟完成。
3.9 插槽、动态组件与 KeepAlive
插槽解决“外壳相同、内容不同”的复用问题;动态组件解决“同一个位置切换不同组件”的问题;KeepAlive 只在切换后需要保留组件状态时使用。下面把四个知识点分开练习。
3.9.1 默认插槽
学习目标
掌握默认插槽的基本写法,能够复用面板外壳并由父组件填写主体内容。
语法/概念
默认插槽像一个没有名字的“内容空位”。子组件负责边框、间距等固定外壳,父组件写在组件标签中间的内容会放到 <slot /> 的位置。
<!-- 子组件 BasePanel.vue:使用 <slot /> 为父组件预留内容插槽出口 -->
<section class="panel">
<slot />
</section>
<!-- 父组件 App.vue:写在组件双标签之间的内容会被直接注入到子组件的 slot 处渲染 -->
<BasePanel>这里的内容会被送入 slot 渲染</BasePanel>课堂演示
步骤 1:创建通用面板
创建 src/components/BasePanel.vue。创建后浏览器暂时不会显示它,下一步由父组件填写内容。
步骤 2:使用默认插槽
默认插槽最小演示
src
App.vue
components
BasePanel.vue
src/App.vue
<script setup>
// 导入通用的面板容器组件
import BasePanel from './components/BasePanel.vue'
</script>
<template>
<main>
<!-- 向 BasePanel 默认插槽中注入自定义的标题与段落内容 -->
<BasePanel>
<h2>今日任务</h2>
<p>完成组件插槽练习。</p>
</BasePanel>
</main>
</template>src/components/BasePanel.vue
<template>
<section class="panel">
<!-- 默认插槽出口:父组件传入的内容将在此处渲染展示 -->
<slot />
</section>
</template>
<style scoped>
/* 通用面板外壳样式:边框、内边距与圆角 */
.panel {
max-width: 520px;
margin: 24px auto;
padding: 20px;
border: 1px solid #d8e0ea;
border-radius: 12px;
}
</style>保存后,页面应显示带边框的“今日任务”面板。标题和正文来自父组件,边框和间距来自 BasePanel。
步骤 3:运行项目
在课堂演示项目目录执行:
npm run dev页面能显示面板内容,就说明父组件内容已经进入默认插槽。
当堂练习
【必做任务(基础通关)】 在同一个页面再使用一次 BasePanel,第二个面板显示“完成进度”和“2 / 3”。不修改 BasePanel.vue,保存后确认页面出现两个外壳完全相同、内部主体内容不同的面板,体会默认插槽带来的容器结构复用。
【选做挑战(🌟 自主拓展)】 为 BasePanel.vue 中的 <slot /> 设置后备默认内容(例如 <slot><p>暂无面板内容</p></slot>),在父组件中放置一个空的 <BasePanel />,验证在父组件未传入任何子节点时后备内容能否正常兜底显示。
必做任务预计 10 分钟完成。
3.9.2 具名插槽
🌟 进阶选学:具名插槽(多内容区定制)
当一个通用组件有多个独立区域(如卡片标题区、主体内容区、底部操作按钮区)需要由父组件分别填入不同的自定义内容时,可以使用具名插槽。初学阶段重点先掌握无名字的默认插槽,具名插槽多用于复杂弹窗与通用布局骨架的封装,作为进阶拓展选学。
学习目标
掌握具名插槽的基本写法,能够分别填写面板的标题区和内容区。
语法/概念
当一个组件有多个空位时,需要给空位起名字。子组件用 <slot name="header" /> 声明标题区,父组件用 <template #header> 指定内容放到哪里。没有名字的仍然是默认插槽。
<!-- 子组件:使用 name 属性给插槽赋予名字,未指定 name 的为默认插槽 -->
<slot name="header" />
<slot />
<!-- 父组件:使用 <template #插槽名> 将内容定向投递到对应插槽中(# 是 v-slot: 的简写) -->
<template #header>标题内容</template>
<p>默认内容</p>课堂演示
步骤 1:给面板增加标题区
把 BasePanel.vue 和 App.vue 改成下面的代码:
具名插槽最小演示
src
App.vue
components
BasePanel.vue
src/App.vue
<script setup>
import BasePanel from './components/BasePanel.vue'
</script>
<template>
<BasePanel>
<!-- 定向投递到子组件的 header 具名插槽中 -->
<template #header>
<h2>任务统计</h2>
</template>
<!-- 未被 <template #xxx> 包裹的内容自动归入默认插槽渲染 -->
<p>已完成 2 / 3</p>
</BasePanel>
</template>src/components/BasePanel.vue
<template>
<section class="panel">
<!-- 标题区:具名插槽 header 出口 -->
<header class="panel-header">
<slot name="header" />
</header>
<!-- 主体内容区:默认插槽出口 -->
<div class="panel-body">
<slot />
</div>
</section>
</template>
<style scoped>
.panel {
max-width: 520px;
margin: 24px auto;
border: 1px solid #d8e0ea;
border-radius: 12px;
overflow: hidden;
}
.panel-header,
.panel-body {
padding: 16px 20px;
}
.panel-header {
background: #eef6ff;
}
</style>保存后,“任务统计”应出现在浅蓝色标题区,“已完成 2 / 3”应出现在内容区。
步骤 2:检查插槽名字
把父组件的 #header 临时改成 #title 并保存,标题会消失,因为子组件没有名为 title 的插槽。恢复 #header 后标题应重新出现。
步骤 3:运行项目
在项目目录执行:
npm run dev标题区和内容区都正确显示,说明具名插槽和默认插槽已经对应。
当堂练习
【必做任务(基础通关)】 在 BasePanel.vue 中为标题区右侧增加一个名为 actions 的插槽(<slot name="actions" />);父组件在该插槽中使用 <template #actions> 放入一个“刷新”按钮。保存后确认标题与操作按钮排布在同一行。
【选做挑战(🌟 自主拓展)】 尝试在父组件中分别使用简写插槽语法 #actions 和标准语法 v-slot:actions 传递内容,体验 Vue 模板语法糖的设计一致性。
必做任务预计 10 分钟完成。
3.9.3 作用域插槽
🌟 进阶选学:作用域插槽(子组件数据回传定制)
普通插槽由父组件“自备数据并填入”。而作用域插槽允许子组件在循环或处理数据时,“将子组件内部的数据对象回传给父组件插槽”,由父组件定制每一项的具体标签与渲染样式(这是 Element Plus 等 UI 库中自定义表格列 <template #default="{ row }"> 的底层机制)。因语法包含向内传参和解构,初学者作为进阶选学了解即可。
学习目标
掌握作用域插槽的基础用法,能够让列表组件提供任务数据,再由父组件决定每条任务怎么显示。
语法/概念
普通插槽只让父组件“填内容”。作用域插槽还会把子组件内部正在处理的数据交给父组件。可以理解成:子组件负责循环,父组件负责样式。
<!-- 子组件:在 <slot> 标签上通过 :item="currentItem" 把内部循环数据包裹并向外抛出 -->
<slot :item="currentItem" />
<!-- 父组件:使用 <template #default="{ item }"> 接收子组件抛出的数据,并定制渲染结构 -->
<template #default="{ item }">
{{ item.title }}
</template>课堂演示
步骤 1:创建列表组件
创建 src/components/TaskList.vue,下一步让它循环任务并把每条任务交给插槽。
步骤 2:用父组件决定显示方式
作用域插槽最小演示
src
App.vue
components
TaskList.vue
src/App.vue
<script setup>
import TaskList from './components/TaskList.vue'
// 任务源数据数组
const tasks = [
{ id: 1, title: '认识默认插槽', done: true },
{ id: 2, title: '练习作用域插槽', done: false },
]
</script>
<template>
<!-- 父组件调用 TaskList 容器,并使用作用域插槽决定每一项的显示样式 -->
<TaskList :tasks="tasks">
<!-- 接住子组件每次循环抛出的 task 变量,父组件全权决定其渲染结构与样式 -->
<template #default="{ task }">
<strong :class="{ done: task.done }">
{{ task.done ? '✅' : '⬜' }} {{ task.title }}
</strong>
</template>
</TaskList>
</template>
<style scoped>
.done {
color: #64748b;
text-decoration: line-through;
}
</style>src/components/TaskList.vue
<script setup>
// 声明接收由父组件传入的任务列表数组
defineProps({
tasks: {
type: Array,
required: true,
},
})
</script>
<template>
<ul>
<!-- 子组件负责列表的遍历循环,把数据结构和框架搭好 -->
<li v-for="task in tasks" :key="task.id">
<!-- 作用域插槽:将当前循环项以 :task="task" 形式打包传递给父组件插槽定制 -->
<slot :task="task">
{{ task.title }}
</slot>
</li>
</ul>
</template>保存后,页面应显示两条任务;已完成任务应带删除线。循环写在 TaskList,具体样式写在父组件。
步骤 3:确认作用域变量
把父组件中的 { task } 临时改成 { item },保存后内容会报错或不显示,因为子组件提供的名字是 task。恢复后页面应正常。
在项目目录执行:
npm run dev当堂练习
【必做任务(基础通关)】 保持 TaskList.vue 结构不变,在父组件的作用域插槽 <template #default="{ task }"> 中为任务增加状态徽标(如根据 task.done 展示“已完成 / 进行中”),体验父组件自定子列表项内部显示样式的能力。
【选做挑战(🌟 自主拓展)】 在任务数据中增加 priority 字段(如 'high'、'normal'),在父组件插槽中根据优先级渲染不同颜色的高亮标签,体会“子组件负责循环与结构、父组件负责定制业务样式”的分工优势。
必做任务预计 10 分钟完成。
3.9.4 动态组件与 KeepAlive
🌟 进阶选学:动态组件与 KeepAlive 状态缓存
<component :is="..."> 适合在同屏区域按状态动态切换不同组件;而 <KeepAlive> 可以在组件被切走时不销毁实例,切回时恢复输入数据与状态。该特性属于标签页导航或路由级别的进阶体验优化,在组件初学阶段作为拓展体验即可。
学习目标
掌握动态组件和 KeepAlive 的最基础用法,能够在切换组件时保留组件里的输入状态。
语法/概念
动态组件就是在同一个位置,根据变量决定现在显示哪个组件:
<!-- 动态组件:根据变量 currentView 动态切换渲染的目标组件 -->
<component :is="currentView" />组件切走后通常会被移除,再切回来会重新创建。KeepAlive 的作用很简单:把动态组件包起来,切走时先保留它,回来时之前的输入状态还在。
<!-- 使用 KeepAlive 包裹动态组件:切走时不销毁组件实例,切回时完好恢复输入内容与状态 -->
<KeepAlive>
<component :is="currentView" />
</KeepAlive>本节只练这一种最简单写法,不展开其他配置。
课堂演示
步骤 1:创建两个可切换组件
创建 src/components/TaskView.vue 和 src/components/ProgressView.vue。下一步把它们放在同一个位置切换。
步骤 2:使用动态组件和 KeepAlive
KeepAlive 保留输入状态
src
App.vue
components
TaskView.vue
ProgressView.vue
src/App.vue
<script setup>
import { ref } from 'vue'
import ProgressView from './components/ProgressView.vue'
import TaskView from './components/TaskView.vue'
// 记录当前处于激活状态的视图代号
const currentView = ref('task')
// 视图映射表:通过代号映射到对应的组件对象
const views = {
task: TaskView,
progress: ProgressView,
}
// 切换视图的业务函数
function showView(viewName) {
currentView.value = viewName
}
</script>
<template>
<main>
<!-- 顶栏视图切换按钮组 -->
<nav>
<button type="button" @click="showView('task')">任务</button>
<button type="button" @click="showView('progress')">进度</button>
</nav>
<!-- KeepAlive 缓存容器:切走时不销毁组件,保留其内部输入框内容与计数状态 -->
<KeepAlive>
<component :is="views[currentView]" />
</KeepAlive>
</main>
</template>src/components/TaskView.vue
<script setup>
import { ref } from 'vue'
// 任务备注文本:在 KeepAlive 缓存保护下,切换走再切换回来文本不会丢失
const note = ref('')
</script>
<template>
<section>
<h2>任务备注</h2>
<input v-model="note" placeholder="输入一段备注">
<p>当前备注:{{ note }}</p>
</section>
</template>src/components/ProgressView.vue
<script setup>
import { ref } from 'vue'
// 任务完成数统计:在 KeepAlive 缓存保护下,切换走再切换回来计数不会重置为 0
const completed = ref(0)
</script>
<template>
<section>
<h2>完成进度</h2>
<button type="button" @click="completed += 1">
已完成 {{ completed }} 项
</button>
</section>
</template>保存后,先在“任务”中输入备注,再切到“进度”,然后切回“任务”。刚才的备注应仍然存在。
步骤 3:对比不使用 KeepAlive
临时删除 <KeepAlive> 和 </KeepAlive> 两行,保存后重新输入备注并来回切换。切回任务时输入内容应恢复为空。把两行加回来,状态应再次被保留。
在项目目录执行:
npm run dev当堂练习
【必做任务(基础通关)】 在 ProgressView 中点击按钮把完成数增加到 3,再切到任务页输入一段备注,随后切回进度页。确认由于 <KeepAlive> 的存在,两边的输入与计数均被完好保留。
【选做挑战(🌟 自主拓展)】 临时去掉 <KeepAlive> 标签包裹,重新测试来回切换视图,观察组件状态为何被重新初始化清空。用两句话在注释中记录“有无缓存的区别与适用场景”。
必做任务预计 10 分钟完成。
3.10 常见误区
| 误区 | 可见信号 | 修正问题 |
|---|---|---|
| 子组件直接改 prop | 控制台警告、数据来源不清 | 状态真正属于谁? |
| 为了少传 props 全放 Pinia | 局部 UI 状态也全局化 | 谁需要读取,存活多久? |
| 事件层层原样转发 | 多层组件只做“传声筒” | 是否需要调整组件边界或依赖注入? |
| 挂载时注册但不清理 | 多次进入后重复执行 | 卸载时应释放什么? |
scoped 后仍乱加深度选择器 | 组件强依赖内部 DOM | 能否设计显式样式接口? |
| 所有组件都做全局注册 | 不看导入也能到处使用,来源难找 | 它真的在全站反复使用吗? |
所有数据都用 provide | 修改入口不清楚,父子关系被藏起来 | 普通父子通信能否直接用 props 和 emits? |
public 与 src/assets 路径混用 | 图片在开发时或构建后不显示 | 这个文件是根路径引用还是源码导入? |
| 动态组件切换后状态丢失 | 输入的备注切回来变空 | 这个位置是否确实需要最基础的 KeepAlive? |
学习自检
停一下:组件接口能解释吗
检查组件边界、数据流和生命周期清理是否形成闭环。
3.11 本章小测
章节测评
3 题第3章小测:组件与数据流
检查组件边界、父子通信和生命周期清理。
3.12 本章验收
| 能力 | 达标标准 |
|---|---|
| 组件边界 | 每个组件可用一句职责描述 |
| 组件引用 | 能在 <script setup> 中导入并使用局部组件 |
| 数据流 | props 下发、事件上报,状态所有者清晰 |
| 事件接口 | 名称表达业务动作,载荷使用稳定 id |
| 跨级传递 | 能说明何时使用最基础的 provide 与 inject |
| 生命周期 | 定时器和监听在卸载时清理 |
| 自定义指令 | 私有 v-focus 能让输入框挂载后自动聚焦 |
| 样式与资源 | 能说明 scoped、:deep()、public 与 src/assets 的区别 |
| 复用设计 | 能判断 prop、三类插槽与拆组件的适用边界 |
| 状态保留 | 能用最简单的 KeepAlive 对比切换前后状态 |
课后任务:完成组件化学习任务板
任务目标
从 resources/ch03/after-class-starter/ 起始项目出发,把单文件学习任务板改造成职责清楚、能够筛选、切换状态、删除任务和显示进度的组件化任务板。本实训分为“基础通关要求(全员必做)”与“进阶拓展任务(自主选做)”,选做完全取决于个人兴趣,不设任何分数绑定。
基础通关要求(全员必做)
- 核心组件协作:重点实现
TaskBoard(父容器)、TaskItem(单条任务)和ProgressCard(进度统计)3 个核心组件的闭环协作; - 状态所有权清晰:任务列表数据
tasks只能保存在父组件TaskBoard中,其他子组件不保留第二份副本; - 单向数据流跑通:
- 父组件使用
defineProps将任务数据和统计数字下发给子组件; - 子组件使用
defineEmits向父组件上报勾选切换(toggle)和删除(remove)动作,由父组件真正修改数组;
- 父组件使用
- 插槽外壳复用:实现一个通用卡片组件
BasePanel,至少包含一个默认插槽<slot />用于承载任务面板或统计区域; - 生命周期资源清理:实现专注计时器
FocusTimer,在onMounted中启动计时,在onUnmounted中清理定时器,确保离开页面时无后台残留; - 组件样式封装:所有组件样式严格使用
<style scoped>,控制台无修改 prop 或列表缺 key 的警告。
进阶拓展任务(自主选做)
以下拓展任务由学有余力的同学自主选择探索,完全不与任何考核分数绑定:
- 挑战一【独立筛选组件】:将任务过滤按钮抽离为独立的
TaskFilter组件,通过props接收当前筛选状态,通过emits上报切换结果,实现完整 5 组件分工; - 挑战二【作用域插槽定制】:在任务列表组件中利用作用域插槽,将每项任务回传给父组件,定制不同的优先级徽标(如高、中、低彩色标签);
- 挑战三【动态视图无损切换】:尝试使用
<component :is="...">动态组件配合<KeepAlive>,实现“任务视图”与“进度视图”的平滑切换并保留本地临时输入。
必须使用的知识
<script setup>中组件的局部导入与引用;- 单向数据流:
defineProps声明只读输入,defineEmits报告动作; onMounted与onUnmounted生命周期定时器启动与清理;- 默认插槽
<slot />与<style scoped>样式隔离。
自主拓展可进一步尝试 provide/inject、具名插槽、作用域插槽、私有指令及 KeepAlive。
完成效果
- 打开页面能看到任务列表、完成进度和专注计时器;
- 勾选任务或删除任务时,完成数量和进度自动实时更新;
- 离开页面或隐藏计时器时,计时器停止计时,再次进入不发生频率翻倍;
- 控制台无报错、无修改 prop 警告;完成拓展挑战者还可体验动态组件无损保留输入与自定义插槽定制。
验收步骤
- 运行项目,确认页面能正常启动且 Console 无报错;
- 点击勾选一条任务并删除一条任务,确认统计数字同步变化;
- 连续隐藏与显示计时器三次,确认数字每秒匀速自增 1,无重复累加;
- 打开 Vue Devtools,检查组件层级与 props 传递;
- (选做)测试动态视图切换时的输入内容持久保留。
提交内容
- 基础提交(全员必交):完整工程文件夹(不含
node_modules)与运行状态截图; - 拓展提交(自选提交):若完成了选做挑战,可额外附带动态组件保留截图或作用域插槽效果图。
本章小结
- 组件边界来自职责、复用和变化原因,不来自标签数量。
<script setup>中导入组件就完成了局部注册;本课程统一使用组合式 API。- 父组件拥有业务状态,props 下发数据,emits 上报动作;跨级只读信息可以使用基础的
provide和inject。 - 生命周期要记住“挂载时启动、卸载时清理”,私有自定义指令可以封装简单的 DOM 行为。
scoped控制样式范围,:deep()只在确有需要时使用;静态资源要分清public和src/assets。- 插槽用于开放内容结构,最基础的
KeepAlive用于切换组件时保留状态。 - 下一章会把多个页面放进同一应用,并用 URL、路由和守卫组织访问入口。
