一、核心通信方式概览
Vue3 父子组件通信主要有以下几种方式:
| 通信方式 | 方向 | 适用场景 | 特点 |
|---|---|---|---|
| Props | 父 → 子 | 传递数据、配置 | 单向数据流,响应式 |
| 自定义事件 | 子 → 父 | 子组件通知父组件 | 通过 emit触发 |
| v-model | 双向 | 表单输入、组件值同步 | 语法糖,简化双向绑定 |
| refs | 父 → 子 | 访问子组件实例 | 直接调用子组件方法 |
| provide/inject | 祖先 → 后代 | 跨层级传递数据 | 依赖注入,非严格父子 |
| 事件总线 | 任意组件 | 非父子组件通信 | 全局事件,Vue3 较少使用 |
二、Props:父传子数据传递
基础用法
<!-- ParentComponent.vue -->
<template>
<ChildComponent
:title="parentTitle"
:count="parentCount"
:user="userData"
/>
</template>
<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'
const parentTitle = ref('来自父组件的标题')
const parentCount = ref(0)
const userData = ref({
name: '张三',
age: 25
})
</script>
<!-- ChildComponent.vue -->
<template>
<div>
<h2>{{ title }}</h2>
<p>计数: {{ count }}</p>
<p>用户: {{ user.name }} - {{ user.age }}岁</p>
</div>
</template>
<script setup>
// 使用 defineProps 定义 props
const props = defineProps({
title: {
type: String,
required: true,
default: '默认标题'
},
count: {
type: Number,
default: 0
},
user: {
type: Object,
default: () => ({})
}
})
// 在 JavaScript 中访问 props
console.log(props.title)
</script>
TypeScript 支持
<!-- ChildComponent.vue -->
<script setup lang="ts">
interface User {
name: string
age: number
}
interface Props {
title: string
count?: number
user: User
tags?: string[]
}
// 使用泛型定义 props 类型
const props = defineProps<Props>()
// 带默认值的 TypeScript props
withDefaults(defineProps<Props>(), {
count: 0,
tags: () => ['默认标签']
})
</script>
Props 验证
<script setup>
defineProps({
// 基础类型检查
age: Number,
// 多个可能的类型
id: [String, Number],
// 必填且为字符串
title: {
type: String,
required: true
},
// 带默认值的对象
config: {
type: Object,
default: () => ({
visible: true,
color: 'blue'
})
},
// 自定义验证函数
score: {
validator(value) {
return value >= 0 && value <= 100
}
},
// 数组默认值
items: {
type: Array,
default: () => []
}
})
</script>
三、自定义事件:子传父通信
基础事件发射
<!-- ChildComponent.vue -->
<template>
<div>
<button @click="handleClick">点击我</button>
<input
:value="inputValue"
@input="handleInput"
placeholder="输入内容"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
// 定义可触发的事件
const emit = defineEmits(['button-clicked', 'input-changed'])
const inputValue = ref('')
const handleClick = () => {
// 触发无参数事件
emit('button-clicked')
// 触发带参数事件
emit('custom-event', {
id: 1,
message: '按钮被点击了',
timestamp: new Date()
})
}
const handleInput = (event) => {
inputValue.value = event.target.value
// 触发带值的事件
emit('input-changed', inputValue.value)
// 触发带多个参数的事件
emit('value-updated', inputValue.value, 'input')
}
</script>
<!-- ParentComponent.vue -->
<template>
<ChildComponent
@button-clicked="onButtonClick"
@input-changed="onInputChange"
@custom-event="onCustomEvent"
@value-updated="onValueUpdated"
/>
</template>
<script setup>
const onButtonClick = () => {
console.log('子组件的按钮被点击了')
}
const onInputChange = (value) => {
console.log('输入值变化:', value)
}
const onCustomEvent = (payload) => {
console.log('自定义事件:', payload)
}
const onValueUpdated = (value, source) => {
console.log(`值更新: ${value}, 来源: ${source}`)
}
</script>
TypeScript 事件类型
<!-- ChildComponent.vue -->
<script setup lang="ts">
// 定义事件类型
interface EmitEvents {
(e: 'update:title', value: string): void
(e: 'submit', data: FormData): void
(e: 'cancel'): void
(e: 'custom', payload: { id: number; name: string }): void
}
const emit = defineEmits<EmitEvents>()
const submitForm = () => {
const formData = new FormData()
formData.append('name', 'test')
emit('submit', formData)
}
</script>
事件验证
<script setup>
const emit = defineEmits({
// 无验证
'click': null,
// 带验证
'submit': (payload) => {
// 返回 true 表示验证通过
return payload.email && payload.email.includes('@')
}
})
const handleSubmit = () => {
const payload = { email: 'test@example.com' }
if (emit('submit', payload)) {
console.log('事件验证通过')
}
}
</script>
四、v-model:双向数据绑定
单 v-model 绑定
<!-- ParentComponent.vue -->
<template>
<div>
<ChildComponent v-model="parentValue" />
<p>父组件值: {{ parentValue }}</p>
</div>
</template>
<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'
const parentValue = ref('初始值')
</script>
<!-- ChildComponent.vue -->
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>
多 v-model 绑定
<!-- ParentComponent.vue -->
<template>
<ChildComponent
v-model:first-name="firstName"
v-model:last-name="lastName"
v-model:age="age"
/>
</template>
<script setup>
import { ref } from 'vue'
const firstName = ref('张')
const lastName = ref('三')
const age = ref(25)
</script>
<!-- ChildComponent.vue -->
<template>
<div>
<input
:value="firstName"
@input="$emit('update:firstName', $event.target.value)"
placeholder="姓"
/>
<input
:value="lastName"
@input="$emit('update:lastName', $event.target.value)"
placeholder="名"
/>
<input
type="number"
:value="age"
@input="$emit('update:age', parseInt($event.target.value))"
placeholder="年龄"
/>
</div>
</template>
<script setup>
defineProps({
firstName: String,
lastName: String,
age: Number
})
defineEmits(['update:firstName', 'update:lastName', 'update:age'])
</script>
带修饰符的 v-model
<!-- ParentComponent.vue -->
<template>
<ChildComponent v-model.trim="text" />
<ChildComponent v-model.number="count" />
<ChildComponent v-model.lazy="lazyText" />
</template>
<!-- ChildComponent.vue -->
<template>
<input
:value="modelValue"
@input="handleInput"
/>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps(['modelValue', 'modelModifiers'])
const emit = defineEmits(['update:modelValue'])
const handleInput = (event) => {
let value = event.target.value
// 处理修饰符
if (props.modelModifiers?.trim) {
value = value.trim()
}
if (props.modelModifiers?.number) {
value = parseFloat(value)
}
emit('update:modelValue', value)
}
</script>
五、refs:访问子组件实例
模板引用
<!-- ParentComponent.vue -->
<template>
<ChildComponent ref="childRef" />
<button @click="callChildMethod">调用子组件方法</button>
<button @click="accessChildData">访问子组件数据</button>
<button @click="focusChildInput">聚焦子组件输入框</button>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import ChildComponent from './ChildComponent.vue'
// 创建 ref 引用
const childRef = ref(null)
onMounted(() => {
// 组件挂载后可以访问
console.log('子组件实例:', childRef.value)
})
const callChildMethod = () => {
if (childRef.value) {
childRef.value.sayHello('父组件')
childRef.value.updateData('新数据')
}
}
const accessChildData = () => {
if (childRef.value) {
console.log('子组件数据:', childRef.value.childData)
console.log('计算属性:', childRef.value.formattedData)
}
}
const focusChildInput = () => {
if (childRef.value) {
childRef.value.focusInput()
}
}
</script>
<!-- ChildComponent.vue -->
<template>
<div>
<input ref="inputRef" v-model="childData" />
<p>{{ formattedData }}</p>
</div>
</template>
<script setup>
import { ref, computed, defineExpose } from 'vue'
const childData = ref('子组件数据')
const inputRef = ref(null)
// 计算属性
const formattedData = computed(() => {
return `格式化: ${childData.value.toUpperCase()}`
})
// 子组件方法
const sayHello = (name) => {
console.log(`Hello ${name}, 来自子组件!`)
}
const updateData = (newData) => {
childData.value = newData
}
const focusInput = () => {
if (inputRef.value) {
inputRef.value.focus()
}
}
// 暴露给父组件的属性和方法
defineExpose({
childData,
formattedData,
sayHello,
updateData,
focusInput
})
</script>
动态组件引用
<template>
<component
:is="currentComponent"
ref="dynamicRef"
/>
<button @click="handleDynamicComponent">操作动态组件</button>
</template>
<script setup>
import { ref, shallowRef } from 'vue'
import ComponentA from './ComponentA.vue'
import ComponentB from './ComponentB.vue'
const currentComponent = shallowRef(ComponentA)
const dynamicRef = ref(null)
const handleDynamicComponent = () => {
if (dynamicRef.value) {
// 调用动态组件的方法
if (dynamicRef.value.customMethod) {
dynamicRef.value.customMethod()
}
}
}
// 切换组件
const switchComponent = () => {
currentComponent.value = currentComponent.value === ComponentA
? ComponentB
: ComponentA
}
</script>
六、provide/inject:依赖注入
基础使用
<!-- 祖先组件 AncestorComponent.vue -->
<template>
<div>
<ParentComponent />
<button @click="updateTheme">切换主题</button>
</div>
</template>
<script setup>
import { ref, provide, readonly } from 'vue'
import ParentComponent from './ParentComponent.vue'
// 提供响应式数据
const theme = ref('light')
const user = ref({
name: '张三',
role: 'admin'
})
// 提供方法
const updateTheme = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
// 使用 provide 提供数据
provide('theme', theme)
provide('user', user)
provide('updateTheme', updateTheme)
// 提供只读数据,防止子组件修改
provide('readonlyData', readonly({
appName: '我的应用',
version: '1.0.0'
}))
</script>
<!-- 中间组件 ParentComponent.vue -->
<template>
<ChildComponent />
</template>
<script setup>
import ChildComponent from './ChildComponent.vue'
// 中间组件不需要处理注入的数据
</script>
<!-- 子组件 ChildComponent.vue -->
<template>
<div :class="`theme-${theme}`">
<h2>用户信息</h2>
<p>姓名: {{ user.name }}</p>
<p>角色: {{ user.role }}</p>
<p>应用: {{ readonlyData.appName }} {{ readonlyData.version }}</p>
<button @click="updateTheme">切换主题</button>
</div>
</template>
<script setup>
import { inject } from 'vue'
// 注入数据
const theme = inject('theme', 'light') // 第二个参数是默认值
const user = inject('user')
const updateTheme = inject('updateTheme')
const readonlyData = inject('readonlyData')
// 如果确定数据存在,可以不用默认值
const requiredData = inject('requiredData')
</script>
TypeScript 支持
<!-- 祖先组件 -->
<script setup lang="ts">
import { provide } from 'vue'
interface User {
name: string
age: number
email?: string
}
// 提供时指定类型
provide<User>('user', {
name: '张三',
age: 25
})
// 或者使用 InjectionKey
import type { InjectionKey } from 'vue'
const themeKey: InjectionKey<string> = Symbol('theme')
provide(themeKey, 'dark')
</script>
<!-- 子组件 -->
<script setup lang="ts">
import { inject } from 'vue'
import type { InjectionKey } from 'vue'
interface Config {
apiUrl: string
timeout: number
}
// 使用泛型指定类型
const config = inject<Config>('config')
// 使用 InjectionKey
const themeKey: InjectionKey<string> = Symbol('theme')
const theme = inject(themeKey, 'light') // 有默认值
</script>
七、综合实战示例
示例1:任务管理器
<!-- ParentTaskManager.vue -->
<template>
<div class="task-manager">
<h1>任务管理器</h1>
<!-- 添加任务 -->
<TaskInput @add-task="addTask" />
<!-- 任务统计 -->
<TaskStats
:total="tasks.length"
:completed="completedCount"
/>
<!-- 任务列表 -->
<TaskList
:tasks="tasks"
@toggle-task="toggleTask"
@delete-task="deleteTask"
@edit-task="editTask"
/>
<!-- 批量操作 -->
<BatchActions
v-model:selected-tasks="selectedTasks"
:tasks="tasks"
@clear-completed="clearCompleted"
/>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import TaskInput from './TaskInput.vue'
import TaskStats from './TaskStats.vue'
import TaskList from './TaskList.vue'
import BatchActions from './BatchActions.vue'
// 任务数据
const tasks = ref([
{ id: 1, text: '学习 Vue3', completed: true },
{ id: 2, text: '写项目文档', completed: false },
{ id: 3, text: '代码评审', completed: false }
])
const selectedTasks = ref([])
// 计算属性
const completedCount = computed(() => {
return tasks.value.filter(task => task.completed).length
})
// 方法
const addTask = (taskText) => {
const newTask = {
id: Date.now(),
text: taskText,
completed: false
}
tasks.value.push(newTask)
}
const toggleTask = (taskId) => {
const task = tasks.value.find(t => t.id === taskId)
if (task) {
task.completed = !task.completed
}
}
const deleteTask = (taskId) => {
tasks.value = tasks.value.filter(task => task.id !== taskId)
}
const editTask = ({ id, text }) => {
const task = tasks.value.find(t => t.id === id)
if (task) {
task.text = text
}
}
const clearCompleted = () => {
tasks.value = tasks.value.filter(task => !task.completed)
}
</script>
<!-- TaskInput.vue -->
<template>
<div class="task-input">
<input
v-model="newTask"
@keyup.enter="addTask"
placeholder="输入新任务..."
/>
<button @click="addTask">添加</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const emit = defineEmits(['add-task'])
const newTask = ref('')
const addTask = () => {
if (newTask.value.trim()) {
emit('add-task', newTask.value.trim())
newTask.value = ''
}
}
</script>
<!-- TaskList.vue -->
<template>
<div class="task-list">
<div v-for="task in tasks" :key="task.id" class="task-item">
<input
type="checkbox"
:checked="task.completed"
@change="$emit('toggle-task', task.id)"
/>
<span
:class="{ completed: task.completed }"
@dblclick="enableEdit(task)"
>
<template v-if="editingTaskId !== task.id">
{{ task.text }}
</template>
<input
v-else
ref="editInput"
v-model="editText"
@blur="saveEdit(task)"
@keyup.enter="saveEdit(task)"
@keyup.escape="cancelEdit"
/>
</span>
<button @click="$emit('delete-task', task.id)">删除</button>
</div>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue'
const props = defineProps({
tasks: {
type: Array,
required: true
}
})
const emit = defineEmits(['toggle-task', 'delete-task', 'edit-task'])
// 编辑功能
const editingTaskId = ref(null)
const editText = ref('')
const editInput = ref(null)
const enableEdit = (task) => {
editingTaskId.value = task.id
editText.value = task.text
nextTick(() => {
if (editInput.value) {
editInput.value.focus()
}
})
}
const saveEdit = (task) => {
if (editText.value.trim() && editText.value !== task.text) {
emit('edit-task', {
id: task.id,
text: editText.value.trim()
})
}
cancelEdit()
}
const cancelEdit = () => {
editingTaskId.value = null
editText.value = ''
}
</script>
示例2:表单验证系统
<!-- FormContainer.vue -->
<template>
<ValidationProvider
v-slot="{ errors }"
:rules="validationRules"
>
<form @submit.prevent="handleSubmit">
<!-- 表单字段 -->
<FormField
v-model="formData.username"
label="用户名"
name="username"
:error="errors[0]"
/>
<FormField
v-model="formData.email"
label="邮箱"
name="email"
type="email"
:error="emailError"
/>
<FormField
v-model.number="formData.age"
label="年龄"
name="age"
type="number"
/>
<FormField
v-model="formData.password"
label="密码"
name="password"
type="password"
/>
<FormField
v-model="formData.confirmPassword"
label="确认密码"
name="confirmPassword"
type="password"
:error="passwordError"
/>
<button type="submit" :disabled="!isFormValid">提交</button>
</form>
</ValidationProvider>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import FormField from './FormField.vue'
import ValidationProvider from './ValidationProvider.vue'
// 表单数据
const formData = ref({
username: '',
email: '',
age: '',
password: '',
confirmPassword: ''
})
// 验证规则
const validationRules = {
username: 'required|min:3|max:20',
email: 'required|email',
age: 'min:18|max:100',
password: 'required|min:6'
}
// 计算属性
const emailError = computed(() => {
const email = formData.value.email
if (!email) return '邮箱不能为空'
if (!/^[^\s@]+@[^\s@]+.[^\s@]+$/.test(email)) {
return '邮箱格式不正确'
}
return ''
})
const passwordError = computed(() => {
if (formData.value.password !== formData.value.confirmPassword) {
return '两次输入的密码不一致'
}
return ''
})
const isFormValid = computed(() => {
return formData.value.username &&
!emailError.value &&
formData.value.age >= 18 &&
!passwordError.value
})
// 监听表单变化
watch(formData, (newValue) => {
console.log('表单数据变化:', newValue)
}, { deep: true })
const handleSubmit = () => {
if (isFormValid.value) {
console.log('提交数据:', formData.value)
// 发送到服务器...
}
}
</script>
<!-- FormField.vue -->
<template>
<div class="form-field">
<label :for="name">{{ label }}</label>
<input
:id="name"
:type="type"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
:class="{ error: error }"
/>
<div v-if="error" class="error-message">
{{ error }}
</div>
<slot></slot>
</div>
</template>
<script setup>
defineProps({
modelValue: [String, Number],
label: String,
name: String,
type: {
type: String,
default: 'text'
},
error: String
})
defineEmits(['update:modelValue'])
</script>
八、最佳实践与注意事项
1. 单向数据流原则
<!-- ❌ 错误:直接在子组件修改 props -->
<script setup>
const props = defineProps(['value'])
const updateValue = () => {
props.value = '新值' // 错误!不要直接修改 props
}
</script>
<!-- ✅ 正确:通过事件通知父组件修改 -->
<script setup>
const props = defineProps(['value'])
const emit = defineEmits(['update:value'])
const updateValue = () => {
emit('update:value', '新值') // 正确
}
</script>
2. Props 设计原则
<script setup>
// 好的 Props 设计
defineProps({
// 明确的数据类型
title: String,
// 必要的默认值
count: {
type: Number,
default: 0
},
// 复杂的默认值使用函数
config: {
type: Object,
default: () => ({})
},
// 提供验证
status: {
validator(value) {
return ['active', 'inactive', 'pending'].includes(value)
}
}
})
</script>
3. 事件命名规范
<script setup>
// 使用 kebab-case 事件名(HTML 中)
defineEmits(['update:value', 'submit-form'])
// 在模板中使用
<MyComponent @update:value="handleUpdate" />
</script>
4. 避免过度使用 refs
<!-- ❌ 过度使用 refs -->
<script setup>
const childRef = ref(null)
// 直接操作子组件内部状态
childRef.value.internalState = '新值'
</script>
<!-- ✅ 优先使用 props 和 events -->
<script setup>
// 通过 props 传递数据
<ChildComponent :value="parentValue" />
// 通过事件接收通知
<ChildComponent @change="handleChange" />
</script>
5. 性能优化
<script setup>
import { shallowRef } from 'vue'
// 对于大型对象,使用 shallowRef
const largeData = shallowRef({ /* 大数据 */ })
// 避免不必要的响应式
const staticConfig = {
apiUrl: 'https://api.example.com',
timeout: 5000
}
provide('config', staticConfig)
</script>
6. TypeScript 最佳实践
<script setup lang="ts">
// 使用接口定义 Props
interface Props {
title: string
count?: number
items: string[]
}
// 使用泛型
const props = defineProps<Props>()
// 事件类型
interface EmitEvents {
(e: 'update:title', value: string): void
(e: 'submit', data: FormData): void
}
const emit = defineEmits<EmitEvents>()
</script>
九、常见问题与解决方案
问题1:Props 不更新
<!-- 原因:直接修改了对象/数组的属性 -->
<script setup>
const props = defineProps(['user'])
// ❌ 错误:直接修改对象属性
props.user.name = '新名字'
// ✅ 正确:创建新对象
const newUser = { ...props.user, name: '新名字' }
emit('update:user', newUser)
</script>
问题2:事件不触发
<!-- 检查点: -->
<!-- 1. 事件名是否正确(大小写敏感) -->
<!-- 2. 是否在子组件中正确使用 defineEmits -->
<!-- 3. 父组件是否正确监听 -->
<!-- 子组件 -->
<script setup>
// 确保定义了事件
defineEmits(['my-event'])
</script>
<!-- 父组件 -->
<template>
<!-- 确保事件监听正确 -->
<ChildComponent @my-event="handleEvent" />
</template>
问题3:v-model 不工作
<!-- 确保子组件正确实现 v-model -->
<script setup>
// 接收 modelValue
defineProps(['modelValue'])
// 触发 update:modelValue
defineEmits(['update:modelValue'])
</script>
十、总结
Vue3 父子组件通信的核心要点:
- Props 向下传递:使用
defineProps声明,遵循单向数据流 - 事件向上传递:使用
defineEmits声明,通过emit触发 - v-model 简化双向绑定:是
:modelValue+@update:modelValue的语法糖 - refs 访问子组件:使用
defineExpose暴露需要访问的内容 - provide/inject 跨层级通信:适合全局状态或配置
选择建议:
- 简单数据传递:使用 Props + 事件
- 表单输入场景:使用 v-model
- 需要调用子组件方法:使用 refs
- 全局配置/主题:使用 provide/inject
- 复杂组件通信:考虑使用 Pinia(状态管理)
记住:保持通信简单明了,避免过度设计。良好的组件通信设计是 Vue 应用可维护性的关键。