Vue.js: 组件通信:props/emits

最后更新:2026-08-26

组件通信是 Vue 的核心机制——父组件用 props 传数据给子组件,子组件用 emits 触发事件通知父组件。理解双向数据流是写好 Vue 应用的关键。

Vue 3 的 <script setup> 提供了 definePropsdefineEmits 两个编译时宏,无需导入即可使用,且能完美支持 TypeScript 类型推断。

1. 你将学到


2. 一个电商购物车的"双向绑定"陷阱

(1) 痛点:子组件改 prop,父组件报错

Alice 实现了一个带数量输入的购物车:

VUE
<!-- CartItem.vue 子组件 -->
<template>
  <input :value="quantity" @input="quantity = $event.target.value">
</template>

<script setup>
// ❌ 翻车版:直接修改 prop
const props = defineProps({ quantity: Number })

function update() {
  props.quantity++  // ❌ Vue 警告:避免直接修改 prop
}
</script>

Vue 报警告:

[Vue warn] Set operation on key "quantity" failed: target is readonly.

Charlie 调试 1 小时后发现:

"Alice,我改数量时父组件的购物车总价没更新。Vue 在修改 prop,但父组件不知道。"

(2) Vue 单向数据流 + emit 解法

VUE
<!-- CartItem.vue 子组件 -->
<template>
  <div>
    <input :value="quantity" @input="$emit('update:quantity', +$event.target.value)">
  </div>
</template>

<script setup>
const props = defineProps({ quantity: Number })
const emit = defineEmits(['update:quantity'])
</script>
VUE
<!-- Cart.vue 父组件 -->
<template>
  <CartItem 
    v-for="item in items" 
    :key="item.id"
    :quantity="item.quantity" 
    @update:quantity="(val) => item.quantity = val"
  />
</template>

现在数据流正确了:子组件 emit → 父组件 receive → 父组件更新 data → 重新传给子组件。

(3) 收益

修复数据流后:


3. props 完整语法

(1) 7 种 props 类型声明

VUE
<script setup>
defineProps({
  // 1. 基础类型
  name: String,
  age: Number,
  active: Boolean,
  
  // 2. 复杂类型
  user: Object,
  items: Array,
  
  // 3. 多类型(任一)
  id: [String, Number],
  
  // 4. 必填
  title: { type: String, required: true },
  
  // 5. 默认值
  pageSize: { type: Number, default: 20 },
  
  // 6. 自定义验证
  email: {
    type: String,
    validator: (val) => val.includes('@')
  },
  
  // 7. 函数默认值(默认值需要是函数)|
  createdAt: {
    type: Date,
    default: () => new Date()
  }
})
</script>

(2) 详细示例

VUE
<script setup>
defineProps({
  // 字符串
  title: String,
  
  // 数字 + 默认值 + 验证
  pageSize: {
    type: Number,
    default: 20,
    validator: (val) => val > 0 && val <= 100
  },
  
  // 布尔(注意:默认值的 falsy 值要用函数形式)
  isVip: {
    type: Boolean,
    default: false
  },
  
  // 数组默认值(必须用函数返回)
  tags: {
    type: Array,
    default: () => ['vue', 'javascript']
  },
  
  // 对象默认值(必须用函数返回)
  user: {
    type: Object,
    default: () => ({ name: 'Guest', age: 0 })
  },
  
  // 必填 + 自定义验证
  email: {
    type: String,
    required: true,
    validator: (val) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)
  }
})
</script>

(3) 6 大默认值规则

规则 示例
基本类型 default: 20
数组/对象 default: () => []
null default: null
undefined 不用写 default
函数引用 不用 default: fn,用 () => fn()
Symbol / BigInt 工厂函数返回

4. TypeScript 强类型 props(Vue 3 推荐)

(1) 基础类型声明

VUE
<script setup lang="ts">
interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user'
}

defineProps<{
  user: User
  size?: 'small' | 'medium' | 'large'
  showEmail?: boolean
}>()
</script>

(2) 必填 vs 可选(?)

TS
defineProps<{
  // 必填(默认)
  user: User
  pageSize: number
  
  // 可选(加 ?)
  variant?: 'primary' | 'secondary'
  showIcon?: boolean
}>()

(3) withDefaults 提供默认值

VUE
<script setup lang="ts">
interface Props {
  user: User
  size?: 'small' | 'medium' | 'large'
  showEmail?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  size: 'medium',
  showEmail: true
})
</script>

(4) 运行时声明 vs 类型声明对比

维度 运行时 defineProps({}) 类型 defineProps<T>()
类型推断 强(IDE 自动补全)
默认值 直接 default withDefaults 包装
验证 validator 函数 TypeScript 编译时检查
推荐 小项目/纯 JS 大项目/TS 项目

5. emit 完整语法

(1) 5 种事件声明

VUE
<script setup>
// 1. 简单声明
const emit = defineEmits(['click', 'submit', 'cancel'])

// 2. 带参数验证
const emit = defineEmits({
  addToCart: (productId) => typeof productId === 'number',
  remove: (id) => typeof id === 'string'
})

// 3. TypeScript 类型
const emit = defineEmits<{
  'add-to-cart': [productId: number]
  'remove-item': [id: string, reason: string]
  'update': [id: number, data: object]
}>()
</script>

(2) 5 种触发方式

VUE
<script setup>
const emit = defineEmits(['click', 'submit', 'add-to-cart'])

// 1. 简单触发
emit('click')

// 2. 传参
emit('add-to-cart', 123)

// 3. 传多个参数
emit('submit', { name: 'Alice' }, 2026)

// 4. 条件触发
if (isValid) emit('submit')

// 5. 函数式触发
function handleAdd() {
  emit('add-to-cart', props.product.id)
}
</script>

(3) 父组件监听

VUE
<template>
  <ChildComponent 
    @click="handleClick"
    @add-to-cart="handleAdd"
    @submit="handleSubmit"
  />
</template>

<script setup>
function handleClick() { /* 无参数 */ }
function handleAdd(productId) { /* 1 个参数 */ }
function handleSubmit(data, year) { /* 多个参数 */ }
</script>

(4) emit 类型验证

VUE
<script setup>
const emit = defineEmits({
  // ✅ 验证:id 必须是 number
  remove: (id) => typeof id === 'number' || 'Validation failed',
  
  // ❌ 抛错:参数不匹配时
  // remove: 'invalid',  // 错:必须是函数
})
</script>

6. 单向数据流原则

(1) 核心原则

100%
graph LR
    A[父组件 data] -->|props| B[子组件]
    B -->|emit| A
    
    style A fill:#42b883
    style B fill:#42b883

(2) 5 个例外情况

VUE
<script setup>
const props = defineProps({ user: Object })

// ❌ 错:直接修改 prop
props.user.name = 'Bob'  // Vue 警告!

// ✅ 例外 1:prop 是引用类型时,可以用新对象替换
// 父组件传的是引用,子组件不能改引用,但可以 emit 让父改

// ✅ 例外 2:prop 初始值可以保存到本地
const localUser = ref({ ...props.user })
// 然后修改 localUser(不影响父)

// ✅ 例外 3:用 computed 派生
const userName = computed(() => props.user.name)

// ✅ 例外 4:用 v-model(语法糖)
// <Child v-model="value" /> 等同于 :value + @update:value

// ✅ 例外 5:provide/inject 跨层级
// 父 provide,子 inject(Phase 2.5 学)
</script>

(3) 5 个反模式

JS
// ❌ 反模式 1:直接修改 prop
props.user.name = 'Bob'

// ❌ 反模式 2:在子组件用 watch 修改 prop
watch(() => props.value, (val) => { props.value = val * 2 })

// ❌ 反模式 3:双向绑定的 ref 透传(不推荐)
const localRef = ref(props.value)
watch(localRef, (val) => emit('update', val))

// ❌ 反模式 4:使用 v-model 时不遵循命名
// v-model 期望 update:xxx 事件,自定义事件名要配套
emit('change', val)  // ❌ 父组件 @change 不会触发
emit('update:value', val)  // ✅ v-model 配套

// ❌ 反模式 5:在子组件中直接管理父组件的数据
emit('update', { ...props.user, name: 'Bob' })  // 错:应该只传 ID,父组件自己更新

7. props 透传:inheritAttrs 和 useAttrs

(1) 默认行为

VUE
<!-- Parent.vue -->
<template>
  <ChildComponent class="parent-class" :title="title" />
</template>

<!-- ChildComponent.vue - 默认会自动接收 class="parent-class" -->
<template>
  <div>  <!-- 自动有 class="parent-class" -->
    <h3>{{ title }}</h3>
  </div>
</template>

(2) 关闭自动透传

VUE
<script setup>
defineOptions({ inheritAttrs: false })
</script>

<template>
  <!-- class 不会自动应用到根元素 -->
  <div class="my-class">
    <h3>Title</h3>
  </div>
</template>

(3) useAttrs 显式使用

VUE
<script setup>
const { useAttrs } = Vue

defineOptions({ inheritAttrs: false })
const attrs = useAttrs()
</script>

<template>
  <div>
    <input v-bind="attrs" />
    <!-- 把父组件传的 class/style/其他属性手动绑到 input -->
  </div>
</template>

(4) 5 个使用场景

场景 做法
默认 不用管,class 自动应用到根
自定义根元素 useAttrs() 显式绑定
透传到非根元素 inheritAttrs: false + v-bind="$attrs"
多根组件(Fragment) 必须 inheritAttrs: false + 显式绑定
性能优化 关闭透传避免不必要的 DOM 属性

8. 完整示例:电商商品卡 props/emits

▶ 示例:ProductCard 完整 props/emits

HTML 📖 仅展示
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>

<style>
.product-card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 1rem; margin: 0.5rem 0; transition: all 0.2s; }
.product-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.out-of-stock { opacity: 0.6; }
.badge { background: #f59e0b; color: white; padding: 2px 8px; border-radius: 4px; font-size: 0.85rem; }
.price { color: #42b883; font-weight: bold; }
button { background: #42b883; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; }
button:disabled { background: #ccc; cursor: not-allowed; }
.fav-btn { background: #fff; color: #333; border: 1px solid #ddd; }
</style>

<div id="app">
  <product-card
    v-for="product in products"
    :key="product.id"
    :product="product"
    :show-stock-badge="true"
    @add-to-cart="handleAddToCart"
    @quick-view="handleQuickView"
    @toggle-favorite="handleToggleFavorite"
  ></product-card>
  <p>购物车商品数: {{ cartCount }} | 收藏: {{ favorites.length }}</p>
</div>

<script>
const { createApp, ref, computed } = Vue

// 子组件:ProductCard
const ProductCard = {
  props: {
    product: {
      type: Object,
      required: true,
      validator: (val) => val.id && val.name && val.price !== undefined
    },
    showStockBadge: { type: Boolean, default: true }
  },
  emits: {
    'add-to-cart': (productId) => typeof productId === 'number',
    'quick-view': (productId) => typeof productId === 'number',
    'toggle-favorite': (productId, isFavorite) =>
      typeof productId === 'number' && typeof isFavorite === 'boolean'
  },
  setup(props, { emit }) {
    const inStock = computed(() => props.product.stock > 0)
    const lowStock = computed(() => props.product.stock > 0 && props.product.stock < 10)
    function handleAddToCart() {
      if (inStock.value) emit('add-to-cart', props.product.id)
    }
    return { inStock, lowStock, handleAddToCart }
  },
  template: `
    <div :class="['product-card', { 'out-of-stock': !inStock }]">
      <h3>{{ product.name }}</h3>
      <p class="price">${{ product.price }}</p>
      <span v-if="showStockBadge && lowStock" class="badge">Only {{ product.stock }} left</span>
      <button :disabled="!inStock" @click="handleAddToCart">
        {{ inStock ? 'Add to Cart' : 'Out of Stock' }}
      </button>
      <button class="fav-btn" @click="$emit('toggle-favorite', product.id, true)">
        ❤️ Favorite
      </button>
      <button class="fav-btn" @click="$emit('quick-view', product.id)">👁 Quick View</button>
    </div>
  `
}

const App = {
  components: { ProductCard },
  setup() {
    const products = ref([
      { id: 1, name: 'iPhone', price: 999, stock: 50 },
      { id: 2, name: 'MacBook', price: 2499, stock: 0 }
    ])
    const cartCount = ref(0)
    const favorites = ref([])
    function handleAddToCart(id) { cartCount.value++; console.log('Added:', id) }
    function handleQuickView(id) { console.log('Quick view:', id) }
    function handleToggleFavorite(id, isFav) {
      if (isFav) favorites.value.push(id)
      console.log('Favorites:', favorites.value)
    }
    return { products, cartCount, favorites, handleAddToCart, handleQuickView, handleToggleFavorite }
  }
}

createApp(App).mount('#app')
</script>
逻辑代码 83 行(超过 40 行限制,仅展示)

▶ 示例:v-model 双向绑定

HTML
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>

<style>
input { padding: 6px; border: 1px solid #ddd; border-radius: 4px; margin: 4px; }
</style>

<div id="app">
  <!-- 父组件使用 v-model -->
  <custom-input v-model="searchQuery" placeholder="搜索..."></custom-input>
  <p>实时显示: {{ searchQuery || '(空)' }}</p>
</div>

<script>
const { createApp, ref } = Vue

// 子组件:CustomInput(用 v-model 语法糖)
const CustomInput = {
  props: ['modelValue'],
  emits: ['update:modelValue'],
  template: `
    <input
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
      :placeholder="placeholder"
    >
  `
}

const App = {
  components: { CustomInput },
  props: ['placeholder'],
  setup() {
    const searchQuery = ref('')
    return { searchQuery }
  }
}

createApp(App, { placeholder: '搜索...' }).mount('#app')
</script>
▶ 试一试

▶ 示例:useAttrs 透传(多根组件)

HTML
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>

<style>
input { padding: 6px; border: 1px solid #ddd; border-radius: 4px; margin: 4px; }
.input-field { border-color: #42b883; }
</style>

<div id="app">
  <!-- 父组件传 class 和 placeholder -->
  <custom-input v-model="name" label="Name:" placeholder="Enter name..." class="input-field"></custom-input>
  <p>输入值: {{ name }}</p>
</div>

<script>
const { createApp, ref } = Vue

// 多根组件必须用 inheritAttrs: false
const CustomInput = {
  inheritAttrs: false,
  props: ['modelValue', 'label'],
  emits: ['update:modelValue'],
  template: `
    <div>
      <label>{{ label }}</label>
      <!-- 把 attrs(class、placeholder 等)手动绑到 input -->
      <input
        v-bind="$attrs"
        :value="modelValue"
        @input="$emit('update:modelValue', $event.target.value)"
      >
    </div>
  `
}

const App = {
  components: { CustomInput },
  setup() {
    const name = ref('')
    return { name }
  }
}

createApp(App).mount('#app')
</script>
▶ 试一试

▶ 示例:5 个常见错误速查

错误 现象 解决
直接修改 prop Vue 警告 用 emit 让父改
数组/对象 prop 默认值是引用 多个组件共享 default: () => [] 工厂
v-model 事件名不匹配 父收不到 update:xxx
validator 返回字符串 Vue 不报错 返回 true/false
透传到多根组件 Vue 警告 inheritAttrs: false

▶ 示例:props 5 大验证场景

场景 验证
邮箱 val => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)
URL val => /^https?:\/\//.test(val)
枚举值 val => ['a', 'b', 'c'].includes(val)
范围 val => val >= 0 && val <= 100
自定义对象 val => val.id && val.name

❓ 常见问题

Q props 能直接修改吗?
A 不能。Vue 单向数据流原则,props 只读。子组件要改数据必须 emit 事件让父组件改。直接修改会触发 Vue 警告。
Q 怎么传递复杂数据(嵌套对象)?
A 直接传引用,Vue 自动深响应。修改对象内部属性父组件能感知(深响应),但子组件不应直接修改,应 emit 让父改整个对象或属性。
Q defineProps 和 props 选项区别?
A <script setup> 中用 defineProps() 宏(无需 import),Options API 中用 props: {} 选项。两种等价,推荐 setup + defineProps。
Q emit 能在子组件中异步触发吗?
A 能。emit 是同步函数调用,但可以放在 setTimeout/Promise 中:setTimeout(() => emit('done'), 1000)
Q v-model 的本质是什么?
A 是 props + emit 的语法糖。<Child v-model="x" /> 等同于 :modelValue="x" @update:modelValue="x = $event"。本课示例 3 详解。
Q 怎么在子组件中 watch props 变化?
Awatch(() => props.xxx, (newVal, oldVal) => {...})。注意不要在 watch 中修改 props,否则无限循环。
Q useAttrs 什么时候用?
A 3 个场景:(1) 多根组件(Fragment)必须用;(2) 想把 attribute 透传到非根元素;(3) 想要显式控制 attribute 绑定位置。普通单根组件不需要用。
Q props 验证失败会怎样?
A 开发环境 Vue 警告,生产环境静默失败。建议 validator 返回 false(错误)或返回字符串(错误消息)。

📖 小节


📝 作业

  1. 基础题(难度⭐) 实现一个简单的 Button 组件:

    • props: text (String), variant (String, 'primary'/'success'/'danger'), disabled (Boolean)
    • 3 种 variant 对应 3 种 CSS class
    • emit click 事件(无参数)
    • 在父组件中测试 3 种 variant
  2. 进阶题(难度⭐⭐) 实现一个双向绑定的 Input 组件:

    • 子组件 CustomInput.vue:props: modelValue, label
    • 用 v-model 语法糖
    • emit update:modelValue
    • 父组件:实时显示用户输入
  3. 挑战题(难度⭐⭐⭐) 实现完整的"商品卡 + 收藏"系统:

    1. ProductCard.vue:完整 props(product/showStockBadge/maxStock)+ emits(add-to-cart/toggle-favorite)
    2. App.vue:商品列表 + 收藏夹状态
    3. 收藏按钮:emit toggle-favorite,父组件更新收藏状态
    4. 用 useAttrs + inheritAttrs 实现多根组件(不破坏练习)
    5. 至少 3 个 validator 验证(email/url/enum)
    6. TypeScript 强类型 props
Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏