# 组件通信：props 与事件通信


## 概述

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

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

比如：父组件获取了用户信息，子组件（用户卡片）需要展示它。用户点了子组件的按钮，父组件需要知道。

Vue 3 用两种方式解决：

- **props**：父组件把数据传给子组件
- **事件**：子组件通知父组件"发生了什么"

## props：父传子

### 基本用法

父组件通过属性传值，子组件用 `defineProps` 声明接收：

```vue
<!-- Parent.vue -->
<script setup lang="ts">
import Child from '@/components/Child.vue'

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

<template>
  <Child :title="title" />
</template>
```

```vue
<!-- Child.vue -->
<script setup lang="ts">
const props = defineProps(['title'])

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

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

> [!info] 说明
> `defineProps` 是 Vue 3 的一个编译宏，不需要从 vue 中导入。它在编译阶段就会被处理掉，不会出现在最终代码中。

### 传递各种类型

| 类型 | 父组件写法 | 子组件接收 |
|------|-----------|-----------|
| 字符串 | `<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"`

```vue
<template>
  <!-- 以下两种写法等效 -->
  <Child disabled />
  <Child :disabled="true" />
</template>
```

### 用对象绑定多个属性

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

```typescript
const post = { id: 1, title: 'Vue 入门', author: '张三' }
```

```vue
<template>
  <BlogPost v-bind="post" />
  <!-- 等同于 -->
  <BlogPost :id="post.id" :title="post.title" :author="post.author" />
</template>
```

### 单向数据流

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

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

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

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

**场景一：只用 props 做初始值**

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

正确的做法是复制到 `ref`：

```vue
<script setup lang="ts">
import { ref } from 'vue'

const props = defineProps(['initialCount'])

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

**场景二：需要对 props 做转换**

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

```vue
<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps(['message'])

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

### 类型校验

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

```vue
<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 时控制台会警告       |

### TypeScript 写法

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

```vue
<script setup lang="ts">
defineProps<{
  title: string
  likes?: number           // 可选属性
  author: string
}>()
</script>
```

设置默认值需要用 `withDefaults`：

```vue
<script setup lang="ts">
interface Props {
  title: string
  likes?: number
}

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

## 事件：子传父

### 基本用法

子组件用 `$emit` 发送事件，父组件用 `@` 监听：

```vue
<!-- Child.vue -->
<template>
  <button @click="$emit('clicked')">点击</button>
</template>
```

```vue
<!-- Parent.vue -->
<template>
  <Child @clicked="handleClick" />
</template>

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

### 声明事件

用 `defineEmits` 声明子组件会触发哪些事件：

```vue
<!-- Child.vue -->
<script setup lang="ts">
const emit = defineEmits(['submit', 'cancel'])

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

声明事件的好处：

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

### 事件参数

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

```vue
<!-- Child.vue -->
<template>
  <button @click="$emit('increaseBy', 1)">+1</button>
</template>
```

```vue
<!-- 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>
```

### TypeScript 写法

```vue
<script setup lang="ts">
const emit = defineEmits<{
  (e: 'change', id: number): void
  (e: 'update', value: string): void
}>()

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

### 事件校验

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

```vue
<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>
```

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

## 父子通信总结

```
父组件 ──props 传数据──→ 子组件
父组件 ←──emit 发事件─── 子组件
```

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

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

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

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


---

> 作者: Aphros  
> URL: https://blog.papergate.top/posts/%E7%BB%84%E4%BB%B6%E9%80%9A%E4%BF%A1props-%E4%B8%8E%E4%BA%8B%E4%BB%B6%E9%80%9A%E4%BF%A1/  

