组件通信:props 与事件通信

第 1 节 概述

上一篇文章我们把页面拆成了多个组件。

组件是独立的,但在实际应用中,它们之间需要传递数据。

比如:父组件获取了用户信息,子组件(用户卡片)需要展示它。用户点了子组件的按钮,父组件需要知道。

Vue 3 用两种方式解决:

  • props:父组件把数据传给子组件
  • 事件:子组件通知父组件"发生了什么"

第 2 节 props:父传子

2.1 基本用法

父组件通过属性传值,子组件用 defineProps 声明接收:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<!-- Parent.vue -->
<script setup lang="ts">
import Child from '@/components/Child.vue'

const title = ref('Vue 3 入门教程')
</script>

<template>
  <Child :title="title" />
</template>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<!-- Child.vue -->
<script setup lang="ts">
const props = defineProps(['title'])

// 在 script 中用 props.title 读取
console.log(props.title)
</script>

<template>
  <h1>{{ title }}</h1>
  <!-- 模板中可以直接用 title -->
</template>
说明

defineProps 是 Vue 3 的一个编译宏,不需要从 vue 中导入。它在编译阶段就会被处理掉,不会出现在最终代码中。

2.2 传递各种类型

类型父组件写法子组件接收
字符串<Child title="你好" />defineProps(['title'])
数字<Child :count="42" />defineProps(['count'])
布尔<Child :published="true" />defineProps(['published'])
数组<Child :list="[1, 2, 3]" />defineProps(['list'])
对象<Child :user="{ name: '张三' }" />defineProps(['user'])

注意两点:

  • 不加 : 的是字符串,加 : 的是变量或表达式
  • 布尔属性的特殊规则:只写属性名没有值,等同于 :prop="true",不写属性则等同于 :prop="false"
1
2
3
4
5
<template>
  <!-- 以下两种写法等效 -->
  <Child disabled />
  <Child :disabled="true" />
</template>

2.3 用对象绑定多个属性

如果一个对象中所有属性都要传给子组件,可以用 v-bind 绑定整个对象:

1
const post = { id: 1, title: 'Vue 入门', author: '张三' }
1
2
3
4
5
<template>
  <BlogPost v-bind="post" />
  <!-- 等同于 -->
  <BlogPost :id="post.id" :title="post.title" :author="post.author" />
</template>

2.4 单向数据流

props 是只读的,子组件不能修改它。

1
2
3
// ❌ 不要这样做
const props = defineProps(['title'])
props.title = '新标题' // 会报错

这是 Vue 的一条铁律:数据只能从父组件流向子组件,不能反向修改。

那如果子组件需要基于 props 做操作怎么办?

场景一:只用 props 做初始值

子组件需要把 props 的值作为初始值,之后自己独立维护。

正确的做法是复制到 ref

1
2
3
4
5
6
7
8
<script setup lang="ts">
import { ref } from 'vue'

const props = defineProps(['initialCount'])

// ✅ 复制到本地 ref
const count = ref(props.initialCount)
</script>

场景二:需要对 props 做转换

比如把传入的字符串转成大写再展示:

1
2
3
4
5
6
7
8
<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps(['message'])

// ✅ 用 computed 派生新值
const upperMessage = computed(() => props.message.toUpperCase())
</script>

2.5 类型校验

defineProps 可以用对象写法来声明类型和规则:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<script setup lang="ts">
defineProps({
  // 基本类型检查
  title: String,
  likes: Number,

  // 多个候选类型
  id: [String, Number],

  // 必填属性
  username: {
    type: String,
    required: true,
  },

  // 有默认值
  count: {
    type: Number,
    default: 0,
  },

  // 对象类型需要工厂函数
  user: {
    type: Object,
    default: () => ({ name: '默认用户' }),
  },

  // 自定义校验
  status: {
    validator: (value: string) => {
      return ['success', 'warning', 'danger'].includes(value)
    },
  },
})
</script>
校验选项说明
type类型构造函数,如 String、Number、Boolean
required是否必填
default默认值(对象和数组必须用函数返回)
validator自定义校验函数,返回 false 时控制台会警告

2.6 TypeScript 写法

用 TypeScript 泛型可以让类型更清晰:

1
2
3
4
5
6
7
<script setup lang="ts">
defineProps<{
  title: string
  likes?: number           // 可选属性
  author: string
}>()
</script>

设置默认值需要用 withDefaults

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<script setup lang="ts">
interface Props {
  title: string
  likes?: number
}

const props = withDefaults(defineProps<Props>(), {
  likes: 0,
})
</script>

第 3 节 事件:子传父

3.1 基本用法

子组件用 $emit 发送事件,父组件用 @ 监听:

1
2
3
4
<!-- Child.vue -->
<template>
  <button @click="$emit('clicked')">点击</button>
</template>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<!-- Parent.vue -->
<template>
  <Child @clicked="handleClick" />
</template>

<script setup lang="ts">
function handleClick() {
  console.log('子组件被点击了')
}
</script>

3.2 声明事件

defineEmits 声明子组件会触发哪些事件:

1
2
3
4
5
6
7
8
<!-- Child.vue -->
<script setup lang="ts">
const emit = defineEmits(['submit', 'cancel'])

function onSubmit() {
  emit('submit')
}
</script>

声明事件的好处:

  • 文档化:阅读代码时能清楚看到子组件支持哪些事件
  • 避免冲突:声明过的事件不会作为"透传属性"落到根元素

3.3 事件参数

子组件可以带参数给父组件:

1
2
3
4
<!-- Child.vue -->
<template>
  <button @click="$emit('increaseBy', 1)">+1</button>
</template>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<!-- Parent.vue -->
<template>
  <Child @increase-by="(n) => count += n" />
  <!-- 或者用方法 -->
  <Child @increase-by="increaseCount" />
</template>

<script setup lang="ts">
const count = ref(0)

function increaseCount(n: number) {
  count.value += n
}
</script>

3.4 TypeScript 写法

1
2
3
4
5
6
7
8
<script setup lang="ts">
const emit = defineEmits<{
  (e: 'change', id: number): void
  (e: 'update', value: string): void
}>()

emit('change', 1)
</script>

3.5 事件校验

声明事件时还可以做校验:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<script setup lang="ts">
const emit = defineEmits({
  // 不校验
  click: null,

  // 校验参数
  submit: ({ email, password }: { email: string; password: string }) => {
    if (email && password) {
      return true
    }
    console.warn('submit 事件参数不完整')
    return false
  },
})
</script>

校验不通过只会发警告,不会阻止事件发送。

第 4 节 父子通信总结

1
2
父组件 ──props 传数据──→ 子组件
父组件 ←──emit 发事件─── 子组件
方式方向用途
props父 → 子传递数据、配置项
$emit子 → 父通知父组件发生了某个动作

这两种是最基础的组件通信方式。它们已经可以解决大部分场景。

但有一个情况比较尴尬:如果组件层级很深(爷→父→子→孙),要通过 props 一层层传递,代码会变得很啰嗦。

在解决这个问题之前,还有一个场景要先处理:父组件想往子组件里传一段 HTML 模板,而不是传数据。这该怎么做?