首页

最近使用

0
暂无使用记录,点击左侧工具开始使用

Vue3父子组件通信指南:Props、Emits、Expose 与 v-model 实践

Vue 3 的核心原则是单向数据流:数据从父组件流向子组件,子组件通过事件通知父组件进行修改。基于这一原则,Vue 提供了多种通信方式,各自适用于不同场景。本文将以 <script setup> 语法为基础,系统梳理这些方式,并附上实用的避坑指南。

一、父传子:Props 的正确用法

1.1 基本传递

父组件通过在子组件标签上添加属性来传递数据。使用 : 前缀表示动态绑定,传递的是 JavaScript 表达式;不加前缀则传递静态字符串。

<!-- 父组件 Father.vue -->
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'

const message = ref('来自父亲的问候')
const userInfo = { name: '小明', age: 18 }
</script>

<template>
  <Child 
    :msg="message"      <!-- 动态绑定变量 -->
    :user="userInfo"    <!-- 动态绑定对象 -->
    count="123"         <!-- 静态字符串,等价于 :count="'123'" -->
  />
</template>

1.2 子组件接收

子组件使用 defineProps 宏来声明接收的属性:

<!-- 子组件 Child.vue -->
<script setup>
// 方式一:简单数组声明(仅适用于快速原型)
const props = defineProps(['msg', 'user', 'count'])

// 方式二:对象声明,支持类型校验和默认值(推荐)
const props = defineProps({
  msg: {
    type: String,
    required: true
  },
  user: {
    type: Object,
    default: () => ({})  // 对象/数组的默认值必须用工厂函数
  },
  count: {
    type: String,
    default: '0'
  }
})

// 使用:通过 props.xxx 访问
console.log(props.msg)
</script>

<template>
  <p>{{ msg }}</p>
  <p>{{ user.name }}</p>
</template>

TypeScript 用户:可以使用基于类型的声明,更加简洁且类型安全。

const props = defineProps<{
  msg: string
  user?: { name: string; age: number }
  count?: string
}>()

重要原则:Props 是只读的

子组件绝对不能直接修改 props 的值:

// ❌ 错误!这会触发 Vue 的警告
props.msg = '新值'

// ✅ 正确做法:通过事件通知父组件修改
const emit = defineEmits(['updateMsg'])
emit('updateMsg', '新值')

这个限制确保了数据流的可预测性,是 Vue 响应式系统的核心设计之一。

二、子传父:Emits 事件机制

当子组件需要向父组件传递数据或通知状态变化时,使用 defineEmits 触发自定义事件。

2.1 子组件触发事件

<!-- 子组件 Child.vue -->
<script setup>
// 声明事件(推荐使用对象语法进行校验)
const emit = defineEmits({
  // 简单声明
  updateMsg: null,
  // 带校验:可以验证传递的参数是否符合预期
  sendData: (payload) => {
    if (payload && typeof payload === 'object' && 'id' in payload) {
      return true
    }
    console.warn('sendData 事件参数格式不正确')
    return false
  }
})

function handleClick() {
  emit('updateMsg', '子组件发来的新消息')
}

function sendComplex() {
  emit('sendData', { id: 1, content: '复杂数据' })
}
</script>

<template>
  <button @click="handleClick">发送消息</button>
  <button @click="sendComplex">发送对象</button>
</template>

2.2 父组件监听事件

父组件使用 @事件名 语法监听,事件名在模板中统一使用 kebab-case(短横线命名)格式:

<!-- 父组件 Father.vue -->
<script setup>
import Child from './Child.vue'
import { ref } from 'vue'

const parentMsg = ref('初始内容')

function onUpdateMsg(newValue) {
  parentMsg.value = newValue  // 在这里真正更新数据
}

function onSendData(data) {
  console.log(data) // { id: 1, content: '复杂数据' }
}
</script>

<template>
  <Child 
    @update-msg="onUpdateMsg" 
    @send-data="onSendData" 
  />
  <p>父组件收到:{{ parentMsg }}</p>
</template>

三、父调子:Expose + ref

某些场景下,父组件需要直接访问子组件的实例,调用其方法或读取其数据。例如:重置表单、聚焦输入框、触发子组件的刷新逻辑等。

3.1 子组件暴露内容

子组件使用 defineExpose 宏显式暴露属性或方法:

<!-- 子组件 Child.vue -->
<script setup>
import { ref } from 'vue'

const count = ref(0)
const internalData = ref('内部私有数据')  // 未暴露,父组件无法访问

function increment() {
  count.value++
}

function reset() {
  count.value = 0
}

// 只暴露 increment 方法和 count 属性
defineExpose({
  count,
  increment
})
</script>

<template>
  <p>子组件 count: {{ count }}</p>
</template>

3.2 父组件通过 ref 访问

父组件使用 ref 获取子组件实例,并通过 .value 访问暴露的内容:

<!-- 父组件 Father.vue -->
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'

// ref 变量名必须与模板中的 ref 属性值一致
const childRef = ref(null)

function callChildMethod() {
  // 使用可选链确保安全访问
  childRef.value?.increment()
  console.log('当前子组件 count:', childRef.value?.count)
}
</script>

<template>
  <Child ref="childRef" />
  <button @click="callChildMethod">调用子组件方法</button>
</template>

使用建议

expose 应作为最后的选择,而非首选方案。过度使用会破坏组件的封装性,使组件间的耦合度升高。在大部分场景中,优先考虑使用 props + emits 完成通信。

四、v-model:双向绑定的语法糖

v-model 本质上是 :modelValue@update:modelValue 的组合语法糖,特别适合表单控件或需要双向绑定的自定义组件。

4.1 基础用法

<!-- 父组件 -->
<Child v-model="searchText" />
<!-- 完全等价于 -->
<Child :modelValue="searchText" @update:modelValue="searchText = $event" />

子组件实现:

<!-- 子组件 -->
<script setup>
defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])

const updateValue = (event) => {
  emit('update:modelValue', event.target.value)
}
</script>

<template>
  <input :value="modelValue" @input="updateValue" />
</template>

4.2 多个 v-model 绑定

Vue 3 支持为 v-model 指定参数名,实现多个双向绑定:

<!-- 父组件 -->
<Child 
  v-model:title="bookTitle" 
  v-model:author="bookAuthor" 
/>

子组件对应接收:

<script setup>
defineProps(['title', 'author'])
const emit = defineEmits(['update:title', 'update:author'])
</script>

4.3 自定义修饰符

<!-- 父组件:使用 capitalize 修饰符 -->
<Child v-model.capitalize="text" />

子组件可以通过 modelModifiers prop 获取修饰符状态:

<script setup>
const props = defineProps({
  modelValue: String,
  modelModifiers: { default: () => ({}) }
})
const emit = defineEmits(['update:modelValue'])

const updateValue = (e) => {
  let value = e.target.value
  // 根据修饰符处理值
  if (props.modelModifiers.capitalize) {
    value = value.charAt(0).toUpperCase() + value.slice(1)
  }
  emit('update:modelValue', value)
}
</script>

五、方式对比与选型建议

通信方式 方向 核心 API 推荐使用场景
Props 父 → 子 defineProps 父组件向子组件传递数据,用于展示或配置。
Emits 子 → 父 defineEmits 子组件向父组件发送事件通知或回传数据。
Expose + ref 父 → 子(调用) defineExpose, ref 父组件需要主动触发子组件的特定方法(命令式调用)。
v-model 双向绑定 :modelValue + @update:modelValue 表单控件或自定义输入组件,需要同步更新状态。

选型决策树

需要父→子传递数据?
  ├─ 是 → 使用 Props
  └─ 否 → 需要子→父通知?
           ├─ 是 → 使用 Emits
           └─ 否 → 需要父主动调用子方法?
                    ├─ 是 → 使用 Expose + ref
                    └─ 否 → 考虑是否真的需要通信

核心原则

尽可能保持数据流的单向性。Props 负责传入,Emits 负责传出,这是最清晰、最可维护的模式。v-model 是这一模式在表单场景下的优雅封装。而 expose 是"逃生舱",仅在必须进行命令式操作时使用。