组件基础与通信
优秀的软件架构师不会把整栋楼画在一张图纸上,而是将它拆分成承重墙、隔断、管线系统,各司其职又彼此协作。Vue 的组件化开发遵循同样的理念——将复杂界面拆分为职责清晰的独立单元,再通过定义良好的接口让它们协同工作。
开篇自测
在正式学习之前,请先检验自己对以下概念的掌握程度:
- 为什么一个页面不应该把所有 HTML 都写在同一个文件里?如果你已经在其他语言(如 Java、Python)中接触过”模块化”或”封装”的概念,尝试用自己的话解释它带来的好处。
- 父组件如何向子组件传递数据?子组件能否直接修改父组件给它的数据?
- 你是否了解 Vue 中”插槽”的含义?它解决了什么问题?
如果三道题都能清晰作答,本章可作为查漏补缺快速浏览;否则请逐节精读。
一、组件化思维——从蓝图到砖块
1.1 为什么要拆分组件
想象你正在搭建一套「任务管理应用」。最终界面包含:顶部导航栏、左侧状态筛选面板、中间任务列表、每条任务的卡片、底部进度统计……如果把所有逻辑全部揉进一个巨大的 .vue 文件,你将面临三个致命问题:
- 可读性崩塌:上千行模板与脚本交织,新同事接手时如同面对一团缠绕的电线。
- 复用性为零:任务卡片在”我的任务”和”团队看板”两个页面都要用,但代码只存在于一处,无法拆出来共享。
- 维护成本失控:修改筛选面板的逻辑,却意外破坏了进度统计的渲染——因为它们共享同一块状态空间。
组件化的本质是分而治之。每个组件像流水线上一个独立的工位,只负责自己的工序,通过标准化的接口与上下游协作。
1.2 单一职责原则
拆分组件的黄金准则是单一职责原则(Single Responsibility Principle):一个组件只做一件事,并且把它做好。
以我们的任务管理应用为例,合理的组件树可能是这样的:
App
├── TaskNavbar ← 顶部导航
├── StatusFilter ← 左侧筛选面板
├── TaskBoard ← 任务面板(容器组件)
│ ├── TaskCard ← 单张任务卡片(展示组件)
│ ├── TaskCard
│ └── TaskCard
└── ProgressPanel ← 底部进度统计
每个组件的名字就说明了它的唯一职责。当产品经理说”把任务卡片的截止日期改成红色高亮”时,你只需要打开 TaskCard.vue,不必翻阅其他文件。
思考题 1:如果
TaskCard既要展示任务信息,又要处理”拖拽排序”逻辑,你会怎么拆分?提示:容器组件与展示组件的分离。
二、组件注册与使用——为砖块编号
2.1 全局注册 vs 局部注册
Vue 提供两种方式让你在模板中使用一个组件。
全局注册——在应用的入口处一次注册,任何地方都能直接使用:
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import TaskCard from './components/TaskCard.vue'
const app = createApp(App)
app.component('TaskCard', TaskCard) // 全局注册
app.mount('#app')
注册后,在任意组件模板中都可以直接写 <TaskCard />,无需再次导入。
局部注册——只在需要的组件中导入并声明:
<!-- TaskBoard.vue -->
<script setup>
import TaskCard from './TaskCard.vue'
</script>
<template>
<div class="task-board">
<TaskCard />
</div>
</template>
在 <script setup> 语法中,导入的组件会自动注册为局部组件,无需额外声明。
如何选择? 全局注册的组件会打包进最终产物,即使某些页面从未使用它。因此,实际项目中推荐以局部注册为主,只有真正全站通用的基础组件(如通用按钮、图标)才考虑全局注册。
2.2 命名规范
Vue 推荐两种命名风格:
| 风格 | 示例 | 使用场景 |
|---|---|---|
| PascalCase | <TaskCard /> | <script> 和 <template> 中均可使用,推荐 |
| kebab-case | <task-card /> | 仅在模板中使用时的备选方案 |
无论选择哪种,请在项目中保持一致。本章后续代码统一使用 PascalCase。
思考题 2:如果你在全局注册了组件名
TaskCard,但在模板中写成<task-card />,Vue 能识别吗?为什么?
三、Props 数据传递——自上而下的供给线
3.1 基本用法
Props 是父组件向子组件传递数据的唯一正规通道。把它想象成水管——水(数据)只能从高处(父组件)流向低处(子组件),不可逆流。
<!-- TaskBoard.vue(父组件) -->
<script setup>
import { ref } from 'vue'
import TaskCard from './TaskCard.vue'
const taskList = ref([
{ id: 1, title: '完成需求文档', priority: 'high', done: false },
{ id: 2, title: '编写单元测试', priority: 'medium', done: true },
{ id: 3, title: '代码评审', priority: 'low', done: false }
])
</script>
<template>
<div class="task-board">
<TaskCard
v-for="task in taskList"
:key="task.id"
:task-title="task.title"
:priority="task.priority"
:is-done="task.done"
/>
</div>
</template>
<!-- TaskCard.vue(子组件) -->
<script setup>
const props = defineProps({
taskTitle: String,
priority: String,
isDone: Boolean
})
</script>
<template>
<div class="task-card" :class="{ 'task-card--done': isDone }">
<h3>{{ taskTitle }}</h3>
<span class="priority-badge">{{ priority }}</span>
</div>
</template>
注意父组件模板中使用 kebab-case(:task-title),子组件声明时使用 camelCase(taskTitle)。Vue 会自动在两者之间做转换。
3.2 类型校验与默认值
在正式项目中,Props 必须进行类型约束。裸奔的 Props 就像不检查零件规格就上流水线——迟早出问题。
<script setup>
const props = defineProps({
taskTitle: {
type: String,
required: true
},
priority: {
type: String,
default: 'medium',
validator(value) {
return ['low', 'medium', 'high'].includes(value)
}
},
isDone: {
type: Boolean,
default: false
},
assignee: {
type: Object,
default: () => ({ name: '未分配', avatar: '' })
}
})
</script>
上面是运行时声明风格。如果项目使用 TypeScript,还可以使用更简洁的纯类型声明风格:
<script setup lang="ts">
interface Props {
taskTitle: string
priority?: 'low' | 'medium' | 'high'
isDone?: boolean
assignee?: { name: string; avatar: string }
}
const props = withDefaults(defineProps<Props>(), {
priority: 'medium',
isDone: false,
assignee: () => ({ name: '未分配', avatar: '' })
})
</script>
纯类型声明风格通过泛型参数传递接口,由 Vue 编译器在编译时提取类型信息。需要设置默认值时,配合 withDefaults 使用。两种风格不能在同一个 defineProps 调用中混用——但同一组件内的 defineProps 和 defineEmits 可以各自选择不同风格。
几个要点:
required: true(运行时风格)或非可选属性(类型风格)表示该 prop 必传,否则控制台会发出警告。validator函数进行自定义校验,返回false时触发警告(仅运行时风格支持)。- 当
default值是对象或数组时,必须用工厂函数返回,避免多个组件实例共享同一引用。
3.3 单向数据流
这是 Vue 组件体系中最重要的铁律之一:子组件绝不能直接修改 props。
<!-- 错误示范 -->
<script setup>
const props = defineProps({ isDone: Boolean })
// 千万不要这样做!
function toggleDone() {
props.isDone = !props.isDone // Vue 会抛出警告
}
</script>
为什么?因为 prop 的值由父组件控制。如果子组件偷偷改了,父组件毫不知情,数据的真实来源就变得模糊不清——这就是”单向数据流”要避免的混乱。
如果子组件需要基于 prop 做本地变换,有两种合法方式:
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({ taskTitle: String })
// 方式一:用 ref 创建本地副本
const localTitle = ref(props.taskTitle)
// 方式二:用 computed 做派生计算
const displayTitle = computed(() => {
return props.taskTitle.toUpperCase()
})
</script>
思考题 3:如果父组件传入一个对象类型的 prop,子组件修改了该对象的某个属性,Vue 会报警告吗?这是否违反了单向数据流?
四、自定义事件——自下而上的信号灯
4.1 $emit 基础
既然数据只能自上而下流动,那子组件需要通知父组件时怎么办?答案是发射事件。
<!-- TaskCard.vue -->
<script setup>
const props = defineProps({
taskTitle: String,
isDone: Boolean
})
const emit = defineEmits(['toggleDone', 'deleteTask'])
function handleToggle() {
emit('toggleDone')
}
function handleDelete() {
emit('deleteTask')
}
</script>
<template>
<div class="task-card">
<h3>{{ taskTitle }}</h3>
<button @click="handleToggle">
{{ isDone ? '标记未完成' : '标记完成' }}
</button>
<button @click="handleDelete">删除</button>
</div>
</template>
<!-- TaskBoard.vue -->
<script setup>
import { ref } from 'vue'
import TaskCard from './TaskCard.vue'
const taskList = ref([
{ id: 1, title: '完成需求文档', priority: 'high', done: false },
{ id: 2, title: '编写单元测试', priority: 'medium', done: true }
])
function onToggleDone(taskId) {
const task = taskList.value.find(t => t.id === taskId)
if (task) task.done = !task.done
}
function onDeleteTask(taskId) {
taskList.value = taskList.value.filter(t => t.id !== taskId)
}
</script>
<template>
<TaskCard
v-for="task in taskList"
:key="task.id"
:task-title="task.title"
:is-done="task.done"
@toggle-done="onToggleDone(task.id)"
@delete-task="onDeleteTask(task.id)"
/>
</template>
数据在父组件中管理,子组件只负责”喊一声”,由父组件决定如何处理。这就像车间工人发现异常时按下警铃,而不是自行决定停产——决策权始终在管理层。
4.2 事件命名规范
Vue 官方推荐在 JavaScript 中(defineEmits 声明和 emit() 调用)使用 camelCase,如 toggleDone、deleteTask;在模板中监听时使用 kebab-case,如 @toggle-done、@delete-task。Vue 的 SFC 编译器会自动处理两者之间的转换。这与 Props 的命名规范一致——JS 中 camelCase,模板中 kebab-case。注意:如果你在非 SFC 的 DOM 模板中使用,HTML 属性不区分大小写,此时必须使用 kebab-case 形式。
4.3 v-model 在组件上的使用
v-model 是 Props + Events 的语法糖,能让双向绑定的书写更加简洁。
假设我们要做一个任务标题的行内编辑功能:
<!-- TaskTitleEditor.vue -->
<script setup>
const props = defineProps({
modelValue: String
})
const emit = defineEmits(['update:modelValue'])
function onInput(event) {
emit('update:modelValue', event.target.value)
}
</script>
<template>
<input
class="title-editor"
:value="modelValue"
@input="onInput"
/>
</template>
<!-- 父组件中使用 -->
<script setup>
import { ref } from 'vue'
import TaskTitleEditor from './TaskTitleEditor.vue'
const currentTitle = ref('完成需求文档')
</script>
<template>
<TaskTitleEditor v-model="currentTitle" />
<p>当前标题: {{ currentTitle }}</p>
</template>
v-model="currentTitle" 等价于 :modelValue="currentTitle" @update:modelValue="currentTitle = $event"。
Vue 3 还支持多个 v-model 绑定,通过参数名区分:
<!-- StatusFilter.vue -->
<script setup>
defineProps({
keyword: String,
priorityLevel: String
})
const emit = defineEmits(['update:keyword', 'update:priorityLevel'])
</script>
<template>
<input
:value="keyword"
@input="emit('update:keyword', $event.target.value)"
placeholder="搜索任务..."
/>
<select
:value="priorityLevel"
@change="emit('update:priorityLevel', $event.target.value)"
>
<option value="all">全部</option>
<option value="high">高优先</option>
<option value="medium">中优先</option>
<option value="low">低优先</option>
</select>
</template>
<!-- 父组件 -->
<StatusFilter
v-model:keyword="searchKeyword"
v-model:priority-level="selectedPriority"
/>
五、插槽系统——预留的安装位
Props 传递的是数据,而插槽传递的是结构。如果说 Props 决定了”显示什么内容”,那么插槽决定了”以什么形态呈现”。
5.1 默认插槽
最简单的插槽用法——为组件预留一个内容出口:
<!-- TaskCard.vue -->
<template>
<div class="task-card">
<div class="task-card__body">
<slot>
<!-- 这里是后备内容,当父组件没有传入插槽内容时显示 -->
<span class="placeholder">暂无任务描述</span>
</slot>
</div>
</div>
</template>
<!-- 使用时 -->
<TaskCard>
<p>这是一条重要任务的详细说明,包含验收标准和截止日期。</p>
</TaskCard>
<!-- 不传内容时,显示后备内容 -->
<TaskCard />
5.2 具名插槽
当组件需要多个插槽出口时,用名字加以区分:
<!-- TaskCard.vue -->
<template>
<div class="task-card">
<header class="task-card__header">
<slot name="header">
<span>默认标题</span>
</slot>
</header>
<main class="task-card__body">
<slot>
<span>暂无描述</span>
</slot>
</main>
<footer class="task-card__footer">
<slot name="actions">
<button>默认操作</button>
</slot>
</footer>
</div>
</template>
<!-- 父组件使用 -->
<TaskCard>
<template #header>
<h3>紧急:修复线上 Bug</h3>
<span class="badge badge--high">高优先</span>
</template>
<p>用户反馈登录页在 Safari 下白屏,需要在今天 18:00 前修复。</p>
<template #actions>
<button class="btn-primary" @click="markDone">完成</button>
<button class="btn-danger" @click="removeTask">删除</button>
</template>
</TaskCard>
#header 是 v-slot:header 的缩写。没有 name 的 <slot> 是默认插槽,对应 #default 或直接书写在组件标签内的内容。
5.3 作用域插槽
有时候,父组件在填充插槽内容时,需要访问子组件内部的数据。作用域插槽就是为此而生的。
<!-- TaskList.vue -->
<script setup>
import { ref } from 'vue'
const taskItems = ref([
{ id: 1, title: '设计数据库表结构', status: 'in-progress', progress: 60 },
{ id: 2, title: '编写接口文档', status: 'pending', progress: 0 },
{ id: 3, title: '前端页面开发', status: 'done', progress: 100 }
])
</script>
<template>
<ul class="task-list">
<li v-for="item in taskItems" :key="item.id">
<!-- 将每一项的数据"暴露"给父组件的插槽内容 -->
<slot
name="task-item"
:task-data="item"
:is-completed="item.status === 'done'"
>
<!-- 后备:最简展示 -->
{{ item.title }}
</slot>
</li>
</ul>
</template>
<!-- 父组件 -->
<TaskList>
<template #task-item="{ taskData, isCompleted }">
<div :class="['task-row', { 'task-row--completed': isCompleted }]">
<span>{{ taskData.title }}</span>
<div class="progress-bar">
<div
class="progress-bar__fill"
:style="{ width: taskData.progress + '%' }"
></div>
</div>
</div>
</template>
</TaskList>
作用域插槽的核心机制:子组件通过 <slot> 上的属性绑定(:task-data="item")向上传递数据,父组件通过 #task-item="{ taskData }" 解构接收。这让子组件掌控数据逻辑,父组件掌控渲染形态,实现了关注点的彻底分离。
思考题 4:作用域插槽和 Props 都能把数据从子组件传给父组件使用。它们的适用场景有什么不同?提示:想想”谁在控制渲染结构”。
六、组件通信模式——搭建信息高速公路
到目前为止,我们掌握了 Props(向下传数据)、Events(向上发信号)、Slots(传递结构)。但现实中的组件关系远不止父子两级。当组件树变深、关系变复杂时,需要更灵活的通信手段。
6.1 provide / inject:跨层级的直通管道
Props 逐层传递在层级较深时会产生”接力赛”问题——中间层组件根本不使用某个数据,却被迫声明和转发它,这被称为 prop drilling。
provide / inject 可以直接穿越中间层:
<!-- App.vue(祖先组件) -->
<script setup>
import { ref, provide } from 'vue'
import TaskBoard from './TaskBoard.vue'
const currentUser = ref({ id: 1, name: '张三', role: 'admin' })
const projectConfig = ref({ theme: 'dark', language: 'zh-CN' })
// 向所有后代组件提供数据
provide('currentUser', currentUser)
provide('projectConfig', projectConfig)
</script>
<template>
<TaskBoard />
</template>
<!-- TaskCard.vue(深层后代组件) -->
<script setup>
import { inject } from 'vue'
// 直接注入,无需经过中间层
const currentUser = inject('currentUser')
const projectConfig = inject('projectConfig', { theme: 'light', language: 'en' })
// inject 第二个参数是默认值,当没有祖先提供该值时生效
</script>
<template>
<div :class="'theme-' + projectConfig.theme">
<p>负责人: {{ currentUser.name }}</p>
</div>
</template>
中间层组件 TaskBoard 完全不需要知道 currentUser 的存在。
保持单向数据流:如果后代组件需要修改 provide 的数据,不应直接修改注入的 ref,而应由提供方同时提供修改方法:
<!-- App.vue -->
<script setup>
import { ref, provide } from 'vue'
const currentUser = ref({ id: 1, name: '张三', role: 'admin' })
function updateUserRole(newRole) {
currentUser.value.role = newRole
}
provide('currentUser', currentUser)
provide('updateUserRole', updateUserRole) // 提供修改方法
</script>
<!-- 某个深层组件中 -->
<script setup>
import { inject } from 'vue'
const updateUserRole = inject('updateUserRole')
function promoteToAdmin() {
updateUserRole('admin') // 通过祖先提供的方法修改数据
}
</script>
这种模式确保数据的修改权始终归属于数据的提供方。
最佳实践:Symbol key + readonly 保护
在大型项目中,字符串 key 容易出现命名冲突。Vue 官方推荐使用 Symbol 作为 provide/inject 的 key,并用 readonly() 包裹提供的数据,防止后代组件意外修改:
// keys.ts — 集中管理注入 key
import type { InjectionKey, Ref } from 'vue'
export const CurrentUserKey: InjectionKey<Ref<{ id: number; name: string; role: string }>> = Symbol('currentUser')
<!-- App.vue -->
<script setup>
import { ref, provide, readonly } from 'vue'
import { CurrentUserKey } from './keys'
const currentUser = ref({ id: 1, name: '张三', role: 'admin' })
// readonly 防止后代组件意外修改
provide(CurrentUserKey, readonly(currentUser))
</script>
<!-- 后代组件 -->
<script setup>
import { inject } from 'vue'
import { CurrentUserKey } from './keys'
const currentUser = inject(CurrentUserKey)
// TypeScript 会自动推断类型,且尝试修改会得到警告
</script>
6.2 事件总线的替代方案
在 Vue 2 时代,许多项目使用”事件总线”(一个空的 Vue 实例作为中央事件调度器)来实现任意组件间通信。Vue 3 移除了 $on、$off 等实例方法,官方不再推荐此模式。
替代方案有以下几种:
方案一:使用外部事件库(如 mitt)
// eventBus.js
import mitt from 'mitt'
export const taskEventBus = mitt()
<!-- 组件 A:发送事件 -->
<script setup>
import { taskEventBus } from '@/utils/eventBus'
function notifyTaskCreated(taskData) {
taskEventBus.emit('task-created', taskData)
}
</script>
<!-- 组件 B:监听事件 -->
<script setup>
import { onMounted, onUnmounted } from 'vue'
import { taskEventBus } from '@/utils/eventBus'
function handleTaskCreated(taskData) {
console.log('新任务已创建:', taskData.title)
}
onMounted(() => {
taskEventBus.on('task-created', handleTaskCreated)
})
onUnmounted(() => {
taskEventBus.off('task-created', handleTaskCreated) // 务必清理
})
</script>
方案二:使用状态管理(Pinia)
对于更复杂的场景,推荐使用 Pinia 等状态管理工具,将共享状态集中管理。这超出了本章范围,将在后续章节详细介绍。
6.3 组件间通信最佳实践
不同场景适合不同的通信方式,以下是选型指南:
| 通信关系 | 推荐方式 | 备注 |
|---|---|---|
| 父 -> 子 | Props | 最基础、最常用 |
| 子 -> 父 | 自定义事件(emit) | 保持单向数据流 |
| 父 <-> 子(双向) | v-model | 本质是 prop + event 的语法糖 |
| 祖先 -> 后代(跨级) | provide / inject | 避免 prop drilling |
| 兄弟组件 | 状态提升到共同父组件 | 最朴素但最清晰的方式 |
| 任意组件 | Pinia 或事件库 | 适合大型应用 |
思考题 5:假设
StatusFilter和ProgressPanel是兄弟组件,StatusFilter 切换筛选条件后,ProgressPanel 需要更新统计数据。不使用任何第三方库,你能想到几种实现方式?
七、生命周期——组件的诞生、成长与告别
每个 Vue 组件从创建到销毁,都会经历一系列确定的阶段。理解生命周期,才能在正确的时机执行正确的操作——比如在组件挂载后请求数据,在组件卸载前清理定时器。
7.1 完整的生命周期流程
Vue 3 Composition API 中的生命周期钩子:
setup() ← 组件实例初始化(替代 beforeCreate / created)
↓
onBeforeMount() ← 即将挂载到 DOM
↓
onMounted() ← 已挂载到 DOM,可访问真实 DOM 节点
↓
onBeforeUpdate() ← 响应式数据变化,即将重新渲染
↓
onUpdated() ← 重新渲染完成
↓
onBeforeUnmount() ← 即将从 DOM 卸载
↓
onUnmounted() ← 已从 DOM 卸载,所有绑定和监听已清除
// 调试与错误处理钩子(按需使用):
onErrorCaptured() ← 捕获后代组件的错误,可用于错误边界
onRenderTracked() ← 调试用:响应式依赖被追踪时触发(仅开发环境)
onRenderTriggered() ← 调试用:响应式依赖触发重新渲染时触发(仅开发环境)
7.2 实战:带生命周期管理的任务面板
下面用一个完整示例,展示各生命周期钩子的典型用途:
<!-- ProgressPanel.vue -->
<script setup>
import {
ref,
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted
} from 'vue'
const taskStats = ref({ total: 0, done: 0, pending: 0 })
let refreshTimer = null
// setup 阶段:初始化响应式数据(等同于 beforeCreate + created)
console.log('[setup] 组件实例初始化,响应式数据就绪')
onBeforeMount(() => {
// DOM 尚未渲染,适合做最后的数据准备
console.log('[onBeforeMount] 即将挂载,DOM 还不可用')
})
onMounted(async () => {
// DOM 已渲染,可以安全地操作 DOM 或发起网络请求
console.log('[onMounted] 已挂载,开始获取任务统计')
// 模拟请求数据
const data = await fetchTaskStats()
taskStats.value = data
// 启动定时刷新
refreshTimer = setInterval(async () => {
taskStats.value = await fetchTaskStats()
}, 30000) // 每 30 秒刷新
})
onBeforeUpdate(() => {
console.log('[onBeforeUpdate] 数据变化,即将重新渲染')
})
onUpdated(() => {
console.log('[onUpdated] 重新渲染完成')
})
onBeforeUnmount(() => {
// 清理定时器,防止内存泄漏
console.log('[onBeforeUnmount] 即将卸载,清理资源')
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
})
onUnmounted(() => {
console.log('[onUnmounted] 已卸载')
})
// 模拟 API 请求
function fetchTaskStats() {
return new Promise(resolve => {
setTimeout(() => {
resolve({ total: 12, done: 7, pending: 5 })
}, 500)
})
}
</script>
<template>
<div class="progress-panel">
<h3>任务进度</h3>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-value">{{ taskStats.total }}</span>
<span class="stat-label">总计</span>
</div>
<div class="stat-item stat-item--done">
<span class="stat-value">{{ taskStats.done }}</span>
<span class="stat-label">已完成</span>
</div>
<div class="stat-item stat-item--pending">
<span class="stat-value">{{ taskStats.pending }}</span>
<span class="stat-label">待处理</span>
</div>
</div>
</div>
</template>
7.3 父子组件的生命周期执行顺序
当父子组件嵌套时,生命周期的执行顺序遵循”由外到内创建,由内到外挂载”的规律:
挂载阶段:
父 setup → 父 onBeforeMount → 子 setup → 子 onBeforeMount → 子 onMounted → 父 onMounted
更新阶段(当父组件数据变化导致子组件重渲染时):
父 onBeforeUpdate → 子 onBeforeUpdate → 子 onUpdated → 父 onUpdated
卸载阶段(父组件条件渲染导致子组件被移除时):
父 onBeforeUpdate → 子 onBeforeUnmount → 子 onUnmounted → 父 onUpdated
理解这个顺序非常关键。例如,如果你在父组件的 onMounted 中试图通过模板引用访问子组件的 DOM——这是安全的,因为此时子组件一定已经 onMounted 了。
思考题 6:假设
ProgressPanel组件在onMounted中启动了一个 WebSocket 连接来实时接收任务更新,那么断开连接的逻辑应该写在哪个钩子中?如果忘记清理会发生什么?
八、综合实战——组装完整的任务面板
让我们把本章学到的所有概念融会贯通,构建一个功能完整的任务管理面板。
<!-- App.vue -->
<script setup>
import { ref, provide, computed } from 'vue'
import StatusFilter from './components/StatusFilter.vue'
import TaskBoard from './components/TaskBoard.vue'
import ProgressPanel from './components/ProgressPanel.vue'
const taskList = ref([
{ id: 1, title: '梳理产品需求', priority: 'high', status: 'done' },
{ id: 2, title: '设计技术方案', priority: 'high', status: 'in-progress' },
{ id: 3, title: '搭建项目脚手架', priority: 'medium', status: 'in-progress' },
{ id: 4, title: '编写自动化测试', priority: 'low', status: 'pending' },
{ id: 5, title: '部署上线', priority: 'medium', status: 'pending' }
])
const filterKeyword = ref('')
const filterPriority = ref('all')
const filteredTaskList = computed(() => {
return taskList.value.filter(task => {
const matchKeyword = task.title.includes(filterKeyword.value)
const matchPriority =
filterPriority.value === 'all' || task.priority === filterPriority.value
return matchKeyword && matchPriority
})
})
function addTask(newTask) {
const nextId = Math.max(...taskList.value.map(t => t.id)) + 1
taskList.value.push({ id: nextId, ...newTask })
}
function removeTask(taskId) {
taskList.value = taskList.value.filter(t => t.id !== taskId)
}
function updateTaskStatus(taskId, newStatus) {
const task = taskList.value.find(t => t.id === taskId)
if (task) task.status = newStatus
}
// 通过 provide 让深层组件也能访问任务操作方法
provide('taskActions', { addTask, removeTask, updateTaskStatus })
provide('allTasks', taskList)
</script>
<template>
<div class="app-container">
<h1>任务管理面板</h1>
<StatusFilter
v-model:keyword="filterKeyword"
v-model:priority-level="filterPriority"
/>
<TaskBoard
:task-list="filteredTaskList"
@remove="removeTask"
@update-status="updateTaskStatus"
/>
<ProgressPanel :task-list="taskList" />
</div>
</template>
在这个综合示例中:
- Props 向下传递
taskList到TaskBoard和ProgressPanel。 - Events 让
TaskBoard向上通知父组件删除或更新任务。 - v-model 让
StatusFilter与父组件双向同步筛选条件。 - provide / inject 让深层组件也能调用任务操作方法,避免层层透传。
- computed 衍生出过滤后的任务列表,筛选逻辑集中在数据层。
这就是一个典型的”自上而下数据流 + 自下而上事件流”的组件架构。数据的真相只有一个来源(taskList),任何修改都通过明确的路径发生,整个系统的数据流向清晰可追溯。
结尾自测
完成本章学习后,请回答以下问题来检验掌握程度:
-
组件注册:全局注册和局部注册各有什么优缺点?在
<script setup>中导入组件后,是否还需要手动注册? -
Props 与类型校验:请写出一个 prop 定义,要求接收一个数组类型的
taskList,默认值为空数组,且数组中每一项必须包含id和title字段(提示:使用validator)。 -
事件与 v-model:
v-model:keyword="searchKeyword"展开后等价于什么?如果子组件中emit的事件名写成了update:Keyword(大写 K),会发生什么? -
插槽:默认插槽、具名插槽、作用域插槽分别解决什么问题?如果一个组件同时有默认插槽和具名插槽
#header,父组件只传了#header的内容,默认插槽区域会显示什么? -
生命周期与通信:在一个父子组件结构中,父组件在
onMounted中调用provide提供数据,子组件在setup中使用inject接收。这样能正常工作吗?为什么?(提示:回顾 provide 的正确使用位置和父子组件生命周期执行顺序。)
本章小结
本章从组件化思维出发,系统地学习了 Vue 组件开发的六大核心机制:
- 组件注册确定了组件”在哪里可用”
- Props建立了自上而下的数据供给线
- 自定义事件建立了自下而上的信号通道
- v-model为双向绑定提供了优雅的语法糖
- 插槽系统让组件具备了结构级的灵活性
- provide / inject打通了跨层级的直通管道
- 生命周期让我们在正确的时机执行正确的操作
这些机制共同构成了 Vue 组件间协作的完整体系。掌握了它们,你就拥有了设计和实现任意复杂组件树的能力。
下一章,我们将深入组合式 API,学习如何用 Composable 函数实现逻辑复用,彻底解决 Options API 在大型组件中的维护性困局。
购买课程解锁全部内容
渐进式到全面掌控:12 章系统精通 Vue 3
¥29.90