Vue3 组件通信

【面试速答版】

Q1: “Vue3 中父组件如何向子组件传递数据?子组件如何触发父组件的事件?”

父传子用 props,子传父用 emit。在 Vue3 的 <script setup> 中,用 defineProps 声明 props,用 defineEmits 声明自定义事件,两者都不需要 import:

1
2
3
4
5
6
7
8
9
10
11
<!-- 父 -->
<Child :title="pageTitle" @update="handleUpdate" />

<!-- 子 -->
<script setup>
const props = defineProps({ title: String })
const emit = defineEmits(['update'])

// 触发父组件事件
emit('update', newValue)
</script>

与 Vue2 的区别:Vue3 推荐 defineProps + defineEmits(在 script setup 中可用),替代了 Vue2 的 props 选项和 this.$emit

Q2: “Vue3 的 v-model 有什么变化?如何实现多个双向绑定?”

Vue3 支持多个 v-model,每个绑定对应一对 prop + update:prop 事件。默认的 v-model 对应 modelValue prop 和 update:modelValue 事件。多个绑定写法:v-model:title="title" v-model:content="content"。Vue2 的 .sync 修饰符在 Vue3 中废弃,统一用多 v-model 替代。

Q3: “provide/inject 的原理是什么?适合什么场景?”

祖先组件通过 provide(key, value) 提供数据,后代组件通过 inject(key) 注入。适合跨多层组件传递数据变化不频繁的场景,如主题(light/dark)、当前用户信息、语言设置。注意:默认不是响应式的——如果用 provide('count', ref(0)) 传一个 ref,后代才能感知变化。适合场景:深层传递、不需要额外引入状态管理库。

【深入理解版】

1. 这个知识点要解决什么问题?

组件化应用由父子组件嵌套构成,数据不可避免地需要跨越组件边界流动。主要场景:

  • 父子之间:父组件传递配置给子组件,子组件反馈操作结果
  • 祖孙之间:爷爷组件的数据,孙子组件需要用到,但中间两层”不关心”这个数据
  • 兄弟之间:两个同级组件需要共享同一份状态(如购物车数据)

Vue3 提供了多种通信机制,不同场景选不同方式。选错了就会导致代码冗余或性能问题。

2. 核心原理/执行过程

2.1 props 的单向数据流

props 是 Vue 最基本的通信方式——数据从父组件流向子组件,子组件不能直接修改 props:

1
2
3
4
5
6
7
<!-- 父组件 -->
<script setup>
const list = ref(['A', 'B', 'C'])
</script>
<template>
<Child :items="list" />
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- 子组件 -->
<script setup>
// defineProps 声明接收的 props,不需要 import
// 参数可以传数组或对象(对象可以加类型/默认值校验)
const props = defineProps({
items: {
type: Array,
required: true,
default: () => [],
}
})

// ❌ 不能直接修改 props
// props.items.push('D') // 开发环境下会警告
</script>

单项数据流:数据从父到子单向流动。如果子组件需要修改数据,通过 emit 事件通知父组件去改。这种模式的好处是数据变化路径清晰,便于追踪 bug。

2.2 emit 事件

子组件通过 defineEmits 声明事件,然后调用 emit 触发:

1
2
3
4
5
6
7
8
<script setup>
const emit = defineEmits(['update', 'delete'])

function handleClick() {
emit('update', { id: 1, title: '新标题' })
// 第一个参数是事件名,后面的参数是传给父组件的载荷
}
</script>
1
2
<!-- 父组件 -->
<Child @update="(payload) => handleUpdate(payload)" />

Vue3 vs Vue2 的 emit 差异:Vue2 中 $emit 直接挂载在组件实例上(this.$emit),Vue3 中 defineEmits 是编译时宏,在 <script setup> 中不需要 import。而且 defineEmits 可以做事件名校验——声明了的事件才能触发,没声明的事件触发时会在开发环境下警告。

2.3 多 v-model 替代 .sync

Vue3 的 v-model 本质是语法糖——v-model:title="title" 等价于:

1
<Child :title="title" @update:title="val => title = val" />

所以 Vue3 支持多个 v-model 绑定

1
2
3
4
5
<Child
v-model:title="pageTitle"
v-model:content="pageContent"
v-model:author="pageAuthor"
/>

子组件中:

1
2
3
4
5
6
7
8
<script setup>
const props = defineProps(['title', 'content', 'author'])
const emit = defineEmits(['update:title', 'update:content', 'update:author'])
</script>
<template>
<input :value="title" @input="emit('update:title', $event.target.value)" />
<input :value="content" @input="emit('update:content', $event.target.value)" />
</template>

Vue2 的 .sync 修饰符(:title.sync="title")做的事情完全一样,Vue3 统一为 v-model:propName,语法更一致。

2.4 provide/inject 的跨层级注入

当数据需要从爷爷传给孙子,中间隔了两层,用 props 就要经过两层不必要的传递:

1
2
3
4
5
6
7
<!-- 爷爷组件 -->
<script setup>
import { ref, provide } from 'vue'
const theme = ref('dark')
provide('theme', theme) // 提供数据
provide('setTheme', (val) => theme.value = val) // 提供修改方法
</script>
1
2
3
4
5
6
7
8
9
10
11
12
<!-- 孙子组件(跳过中间的父/子层级) -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme', 'light') // 第二个参数是默认值
const setTheme = inject('setTheme')
</script>
<template>
<div :class="theme">
当前主题: {{ theme }}
<button @click="setTheme('light')">切换为亮色</button>
</div>
</template>

provide(key, value) 把数据注入到后代组件中。inject(key, defaultValue) 从祖先组件获取数据,如果找不到对应的 provide,使用默认值。

默认不是响应式的——如果你 provide('theme', 'dark') 传了一个字符串,后代拿到的是固定值,后续祖先修改这个值后代不会感知。需要传 refreactive 对象才能保持响应式。

3. 实际应用场景

场景1:props + emit 基础通信

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!-- TodoList.vue -->
<script setup>
import { ref } from 'vue'
import TodoItem from './TodoItem.vue'

const todos = ref([
{ id: 1, text: '学习 Vue3', done: false },
{ id: 2, text: '复习响应式', done: false },
])

function toggleTodo(id) {
const todo = todos.value.find(t => t.id === id)
if (todo) todo.done = !todo.done
}
</script>

<template>
<TodoItem
v-for="todo in todos"
:key="todo.id"
:todo="todo"
@toggle="toggleTodo"
/>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- TodoItem.vue -->
<script setup>
const props = defineProps({
todo: { type: Object, required: true }
})
const emit = defineEmits(['toggle'])
</script>

<template>
<li>
<input type="checkbox" :checked="todo.done" @change="emit('toggle', todo.id)" />
<span :class="{ done: todo.done }">{{ todo.text }}</span>
</li>
</template>

场景2:多 v-model 实现表单组件

1
2
3
4
5
6
7
8
9
10
<!-- UserForm.vue(父组件) -->
<script setup>
const name = ref('')
const email = ref('')
</script>

<template>
<FormInput v-model:value="name" label="姓名" />
<FormInput v-model:value="email" label="邮箱" type="email" />
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- FormInput.vue(子组件) -->
<script setup>
const props = defineProps({
value: String,
label: String,
type: { type: String, default: 'text' },
})
const emit = defineEmits(['update:value'])
</script>

<template>
<div>
<label>{{ label }}</label>
<input :type="type" :value="value" @input="emit('update:value', $event.target.value)" />
</div>
</template>

4. 常见误区 & 实际项目中的坑

误区1:在子组件中直接修改 props

1
2
3
4
<script setup>
const props = defineProps(['count'])
props.count++ // ❌ 开发环境警告:意外修改 props
</script>

正确做法:emit 事件通知父组件修改。

误区2:认为 provide/inject 默认是响应式的

1
2
3
4
5
6
7
8
// ❌ 传基础类型,后代感知不到变化
provide('theme', 'dark')
const theme = inject('theme')
theme = 'light' // 祖先的值没变,后代也不会更新

// ✅ 传 ref 或 reactive
const theme = ref('dark')
provide('theme', theme)

坑:v-model 默认 prop 名称变化

Vue2 的 v-model="count" 默认对应 value prop 和 input 事件。Vue3 改为 modelValue prop 和 update:modelValue 事件。迁移旧组件时要注意。

5. 与相关知识的关联 & 对比

通信方式方向适用场景
props父→子直接父子
emit子→父子组件反馈操作
v-model父↔子双向绑定
provide/inject祖先→后代跨层级、低频数据
slots父→子(模板)定制渲染
defineExpose / ref父→子直接调用子组件方法
差异点Vue2Vue3
默认 v-modelvalue + inputmodelValue + update:modelValue
.sync 修饰符存在废弃(多 v-model 替代)
EventBus$on/$off(内置)移除(推荐 mitt)
$listeners独立对象合并到 $attrs
defineExpose默认全部暴露需显式声明

6. 现代最佳实践(2024-2025)

  1. 父子通信优先 props/emit + v-model,最显式、最符合单向数据流。
  2. 跨层级用 provide/inject 并传 ref/reactive 对象,保持响应式。
  3. 全局共享状态用 Pinia,不要自己用 provide/inject 实现全局状态管理——Pinia 帮你处理了状态隔离、响应式、DevTools 等。
  4. EventBus 场景用 mitt(仅 200 字节),Vue3 已移除 $on/$off
  5. 使用 TypeScript 时用 defineProps 的类型声明形式
1
2
3
4
5
6
7
8
9
<script setup lang="ts">
interface Props { title: string; count?: number }
const props = defineProps<Props>() // 类型安全

const emit = defineEmits<{
(e: 'update:title', val: string): void
(e: 'delete', id: number): void
}>()
</script>

7. 常见疑问解答

Q:provide/inject 和 Pinia 的适用边界在哪里?

A:provide/inject 适合跨 2-3 层但不会在多个无关页面之间共享的数据——如当前主题、当前用户信息。Pinia 适合跨页面、跨路由、多组件共享的全局状态——如购物车数据、用户设置。如果数据需要在完全不相关的两个页面之间共享,用 Pinia。如果数据只在”某个内部组件树”中传递,用 provide/inject。

Q:为什么 Vue3 要移除 $on/$off/$once?

A:$on/$off/$once 是 Vue2 实例上的事件发射器(EventBus)API。Vue 团队认为组件通信应该通过 props/emit、provide/inject、Pinia 等显式的机制,而不是”全局发布订阅”这种隐式方式。EventBus 在大型项目中会导致事件来源不明、命名冲突、调试困难的问题。如果确实需要跨组件的事件通信,用 mitt 库(200 字节)即可。

关联知识点索引

  • Vue3 核心变化与 Composition API.md — setup 中如何使用通信 API
  • Pinia.md — 全局状态管理与 provide/inject 的适用边界