Vue3 组件通信入门
假设你在做一个“用户列表”页面:
- 左侧是用户列表(从后端一次性获取)
- 右侧是一张
UserCard卡片,展示某个人的姓名、年龄、头像 - 点击左侧任意一个人,右侧卡片立即切换显示对应的人
你可能会想:“点击时让卡片自己去请求数据不就行了?”
但仔细一想,这样会带来几个麻烦:
- 数据重复请求 —— 列表数据明明已经在父组件里了,卡片还要再发一次请求,浪费性能。
- 状态难以同步 —— 如果卡片自己维护自己的数据,页面就不知道“当前选中了谁”,也无法统一控制高亮、路由等。
- 延迟感 —— 每次点击都要等网络响应,卡片会短暂空白,体验不好。
正确的设计思路是:
父组件(页面) 负责管理所有数据和“当前选中”的状态,子组件(卡片) 只负责接收数据并展示。
数据从父组件流向子组件,这叫 父传子,Vue 中通过 props 实现。
这就是本篇要讲的核心 —— 为什么需要 props,以及怎么用。
最小例子
我们来模拟跑通上面的例子,先别管列表和选中,把最核心的一步走通:页面把”小明”这条数据,传给卡片显示。
父组件 App.vue:
<script setup>
import UserCard from './components/UserCard.vue'
const user = { name: '小明', age: 18 }
</script>
<template>
<UserCard :name="user.name" :age="user.age" />
</template>
子组件 UserCard.vue(收数据、展示):
<script setup>
const props = defineProps({
name: String,
age: Number
})
</script>
<template>
<div class="card">
<h3>{{ props.name }}</h3>
<p>{{ props.age }} 岁</p>
</div>
</template>
这个例子里通信只靠两行,先盯住它们:
- 父组件
<UserCard :name="user.name" />—— 在子组件标签上写属性,把数据传出去。 - 子组件
defineProps({ name, age })—— 声明”我接收这两个”,声明后模板里就能用{{ props.name }}。
跑起来页面就是一张固定卡片”小明,18 岁”。
下面拆开讲两端的语法,讲完再回到开头的列表需求。
props传值三步走
父组件 属性绑定
父组件在子组件标签上添加属性,值可以是静态的,也可以是动态的:
<template>
<!-- 传静态字符串(不加冒号) -->
<UserCard name="小明" />
<!-- 传动态变量(加冒号,即 v-bind) -->
<UserCard :name="user.name" :age="user.age" />
</template>
关键规则:
- 传变量或表达式:必须用
:(v-bind)绑定,例如:name="user.name"。 - 传静态字符串:可以省略
:,直接写name="小明"。
但注意,数字、对象、数组等非字符串类型,必须用 : 绑定,否则会被当作普通字符串。
子组件 声明接收
defineProps 是一个编译宏,直接在 <script setup> 里用,不用导入。
它的作用就是告诉 Vue:”我这个组件允许接收哪些外部属性”。
先分清两种整体写法:
| 写法 | 示例 | 适用场景 |
|---|---|---|
| 简写(数组) | defineProps(['name', 'age']) |
快速开发,只列出属性名 |
| 详细(对象) | defineProps({ name: String }) |
推荐,支持类型、默认值、必填 |
其中详细(对象)写法里,每个属性(对象的键)可以配两种”限制条件”:
单限制:值直接写类型,只限制这一个条件(只能是类型):
defineProps({ name: String, age: Number })
多限制:值写成一个配置对象,同时配多个条件:
defineProps({
name: { type: String, required: true }, // 类型 + 必填
age: { type: Number, default: 0 }, // 类型 + 默认值
avatar: { type: String, default: '/default-avatar.png' }
})
配置对象里能用的选项:
| 配置项 | 作用 | 示例 |
|---|---|---|
type |
期望的类型(可允许多种) | { type: [String, Number] } |
default |
父组件未传时使用的默认值 | { type: Number, default: 0 } |
required |
是否必填(不传会在控制台警告) | { name: { type: String, required: true } } |
记忆方法:只需要类型就用单限制,还需要默认值或必填就用多限制(配置对象)。
一个常见坑:写了 default 为什么没生效?
记住:只要父组件传了值(哪怕传的是 undefined),default 就不会触发。
所以如果你发现默认值没起作用,先检查父组件是否真的没有写该属性,或者是否传了 undefined。
另外,required: true 和 default 不要同时用,它们逻辑上是矛盾的。
子组件 内部使用
在子组件的 <script setup> 和 <template> 中都可以访问 props 对象:
<script setup>
const props = defineProps({ name: String, age: Number })
// 脚本中通过 props.xxx 读取
console.log(props.name)
</script>
<template>
<!-- 模板中同样用 props.xxx -->
<p>{{ props.name }},{{ props.age }} 岁</p>
</template>
你还可以用解构赋值让代码更简洁(解构出来的变量依然保持响应性):
<script setup>
const { name, age } = defineProps({ name: String, age: Number })
</script>
<template>
<p>{{ name }},{{ age }} 岁</p>
</template>
回到需求
现在把最小例子升级回开头的场景。
UserCard 还是刚才那张卡,一个字都不用改;变化只发生在父组件,把”传一个固定用户”换成”列表 + 选中状态”:
<!-- 父组件 App.vue -->
<script setup>
import { ref, computed } from 'vue'
import UserCard from './components/UserCard.vue'
// 1. 父组件一次性拿到列表数据
const users = ref([
{ id: 1, name: '小明', age: 18, avatar: 'https://.../xiaoming.png' },
{ id: 2, name: '小红', age: 20, avatar: 'https://.../xiaohong.png' },
{ id: 3, name: '小刚', age: 22, avatar: 'https://.../xiaogang.png' }
])
// 2. 父组件维护"当前选中的 id"
const selectedId = ref(1)
// 3. 父组件自己计算出当前要展示的用户对象
const currentUser = computed(() =>
users.value.find(user => user.id === selectedId.value)
)
// 4. 点击列表时,父组件更新选中 id
const selectUser = (id) => {
selectedId.value = id
}
</script>
<template>
<div class="layout">
<!-- 左侧列表 -->
<ul>
<li
v-for="user in users"
:key="user.id"
@click="selectUser(user.id)"
:class="{ active: user.id === selectedId }"
>
{{ user.name }}
</li>
</ul>
<!-- 右侧卡片:父组件把当前用户的数据传给子组件 -->
<UserCard
:name="currentUser.name"
:age="currentUser.age"
:avatar="currentUser.avatar"
/>
</div>
</template>
<style scoped>
.layout { display: flex; gap: 20px; }
.active { background: #eee; font-weight: bold; }
</style>
这个父组件用到的 v-for(列表渲染)、@click(事件处理)、:class(动态样式)、computed(计算与侦听)都是前面几篇讲过的。
这里只看它们如何围绕 props 协作:列表数据在父组件,选谁由父组件决定,选中的那条通过 :name/:age/:avatar 传给 UserCard。
效果就是,点击左侧列表,selectedId 变化 → currentUser 重新计算 → 新的数据通过 props 传递给 UserCard → 卡片立即刷新,整个过程同步完成,没有网络延迟,也没有状态分散。
单向数据流
Vue 强制 props 是只读的,即子组件不能直接修改它。
这是为了保证数据流向清晰:数据属于父组件,任何变化都应该由父组件发起,这样调试时只需追踪一个源头。
<script setup>
const props = defineProps({ count: Number })
const wrong = () => {
props.count++ // ❌ 会报警告,且无法修改父组件数据
}
</script>
如果子组件确实需要基于 prop 产生自己的可变状态,有两种合法做法:
<script setup>
const props = defineProps({ count: Number })
// 做法一:派生计算属性(会跟随 prop 变化)
const show = computed(() => props.count > 0 ? '有值' : '无值')
// 做法二:复制一份到本地 ref(仅在初始化时复制,后续父组件更新不会同步)
const localCount = ref(props.count)
</script>
对象/数组的引用问题
如果 prop 是对象或数组,子组件通过 props.obj.xxx = ... 确实能修改到父组件的数据(因为是引用传递)。
Vue 会发出警告,但修改仍然生效。请务必遵守约定,不要这样做。如需变更,应当使用 emit 通知父组件(下篇 Vue3 组件事件 会讲)。
父组件控制子组件外观
先想一个问题:父组件想给子组件加个样式,直接写 CSS 就行了吗?不行,因为scoped 隔离。
回忆Vue3 动态样式讲的 scoped:它会给组件里的元素加一个随机属性(data-v-xxx),样式规则也带上同样限定。
为什么选不中? 因为 scoped 会把选择器”加上父组件自己的 data-v 限定”来编译。
写 .card h3,编译后变成 .card h3[data-v-父]——它要求 h3 带父组件的 data-v。
但 h3 是子组件里的元素,身上只有子组件的 data-v-子,没有 data-v-父,于是匹配不上,样式静默失效:
<!-- 父组件 -->
<style scoped>
.card h3 {
color: red; // 编译后约等于 .card h3[data-v-父],h3 没有这个属性 → 不生效
}
</style>
<!-- 子组件 UserCard.vue:真实 DOM 上的 h3 只带子组件的属性 -->
<template>
<div class="card" data-v-子>
<h3 data-v-子>{{ props.name }}</h3> <!-- 没有 data-v-父,匹配失败 -->
</div>
</template>
所以”父组件改子组件外观”要分两种位置处理:目标在子组件根元素上,还是子组件内部深处。
根元素:用 class 透传
UserCard 的根元素 <div class="card"> 比较特殊:它是”父组件和子组件的交界处”,两边的 data-v 属性它都有。
父组件在子组件标签上写的 class,会自动合并到这个根元素上,于是父组件 scoped 样式能选中它:
<!-- 父组件:在标签上写 class -->
<template>
<UserCard class="highlight" />
</template>
<style scoped>
.highlight {
border: 2px solid #f60;
}
</style>
<!-- 子组件 UserCard.vue:根元素自动拿到 highlight,无需配合 -->
<template>
<div class="card">
<h3>{{ props.name }}</h3>
</div>
</template>
一句话:想给子组件”整体”加样式,在子组件标签上写 class 即可,这是最省事的办法。
内部元素:用 :deep()
但要改的往往是子组件内部的元素,比如 <h3>。
它只有子组件的 data-v,父组件的 scoped 规则选不中它,直接写会静默失效。
这时用 :deep() 穿透作用域:<style scoped> 里把选择器包进 :deep(),编译后这条规则就不带父组件的限定,能钻进子组件内部:
<!-- 父组件 -->
<style scoped>
:deep(.card-title) {
color: red;
}
</style>
<!-- 子组件内部元素 .card-title 被父组件样式改到 -->
<template>
<div class="card">
<h3 class="card-title">{{ props.name }}</h3>
</div>
</template>
这个写法常用于调整第三方组件库(如 Element Plus)的内部样式,想改哪一层就写哪一层的类名。
速查卡片
| 场景 | 写法示例 |
|---|---|
| 声明 props(推荐) | defineProps({ name: { type: String, required: true } }) |
| 快捷声明 | defineProps(['name']) |
| 父组件传值 | <Child :name="user.name" /> |
| 模板/脚本中使用 | props.name 或解构后的 name |
| 修改 prop | 禁止直接改;使用 computed 派生或 ref 复制 |
| 给子组件根元素加类 | <Child class="active" /> |
| 穿透修改子组件内部样式 | :deep(.inner-class) |