跳到主要内容
预计阅读 54 分钟

组件基础与通信

优秀的软件架构师不会把整栋楼画在一张图纸上,而是将它拆分成承重墙、隔断、管线系统,各司其职又彼此协作。Vue 的组件化开发遵循同样的理念——将复杂界面拆分为职责清晰的独立单元,再通过定义良好的接口让它们协同工作。


开篇自测

在正式学习之前,请先检验自己对以下概念的掌握程度:

  1. 为什么一个页面不应该把所有 HTML 都写在同一个文件里?如果你已经在其他语言(如 Java、Python)中接触过”模块化”或”封装”的概念,尝试用自己的话解释它带来的好处。
  2. 父组件如何向子组件传递数据?子组件能否直接修改父组件给它的数据?
  3. 你是否了解 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 调用中混用——但同一组件内的 definePropsdefineEmits 可以各自选择不同风格。

几个要点:

  • 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,如 toggleDonedeleteTask;在模板中监听时使用 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>

#headerv-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:假设 StatusFilterProgressPanel 是兄弟组件,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 向下传递 taskListTaskBoardProgressPanel
  • EventsTaskBoard 向上通知父组件删除或更新任务。
  • v-modelStatusFilter 与父组件双向同步筛选条件。
  • provide / inject 让深层组件也能调用任务操作方法,避免层层透传。
  • computed 衍生出过滤后的任务列表,筛选逻辑集中在数据层。

这就是一个典型的”自上而下数据流 + 自下而上事件流”的组件架构。数据的真相只有一个来源(taskList),任何修改都通过明确的路径发生,整个系统的数据流向清晰可追溯。


结尾自测

完成本章学习后,请回答以下问题来检验掌握程度:

  1. 组件注册:全局注册和局部注册各有什么优缺点?在 <script setup> 中导入组件后,是否还需要手动注册?

  2. Props 与类型校验:请写出一个 prop 定义,要求接收一个数组类型的 taskList,默认值为空数组,且数组中每一项必须包含 idtitle 字段(提示:使用 validator)。

  3. 事件与 v-modelv-model:keyword="searchKeyword" 展开后等价于什么?如果子组件中 emit 的事件名写成了 update:Keyword(大写 K),会发生什么?

  4. 插槽:默认插槽、具名插槽、作用域插槽分别解决什么问题?如果一个组件同时有默认插槽和具名插槽 #header,父组件只传了 #header 的内容,默认插槽区域会显示什么?

  5. 生命周期与通信:在一个父子组件结构中,父组件在 onMounted 中调用 provide 提供数据,子组件在 setup 中使用 inject 接收。这样能正常工作吗?为什么?(提示:回顾 provide 的正确使用位置和父子组件生命周期执行顺序。)


本章小结

本章从组件化思维出发,系统地学习了 Vue 组件开发的六大核心机制:

  • 组件注册确定了组件”在哪里可用”
  • Props建立了自上而下的数据供给线
  • 自定义事件建立了自下而上的信号通道
  • v-model为双向绑定提供了优雅的语法糖
  • 插槽系统让组件具备了结构级的灵活性
  • provide / inject打通了跨层级的直通管道
  • 生命周期让我们在正确的时机执行正确的操作

这些机制共同构成了 Vue 组件间协作的完整体系。掌握了它们,你就拥有了设计和实现任意复杂组件树的能力。

下一章,我们将深入组合式 API,学习如何用 Composable 函数实现逻辑复用,彻底解决 Options API 在大型组件中的维护性困局。

购买课程解锁全部内容

渐进式到全面掌控:12 章系统精通 Vue 3

¥29.90