Vue3 父子组件通信完全指南

0 阅读6分钟

一、核心通信方式概览

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 父子组件通信的核心要点:

  1. Props 向下传递:使用 defineProps声明,遵循单向数据流
  2. 事件向上传递:使用 defineEmits声明,通过 emit触发
  3. v-model 简化双向绑定:是 :modelValue+ @update:modelValue的语法糖
  4. refs 访问子组件:使用 defineExpose暴露需要访问的内容
  5. provide/inject 跨层级通信:适合全局状态或配置

选择建议

  • 简单数据传递:使用 Props + 事件
  • 表单输入场景:使用 v-model
  • 需要调用子组件方法:使用 refs
  • 全局配置/主题:使用 provide/inject
  • 复杂组件通信:考虑使用 Pinia(状态管理)

记住:保持通信简单明了,避免过度设计。良好的组件通信设计是 Vue 应用可维护性的关键。