六维教程

Vue3 组件通信入门

假设你在做一个“用户列表”页面:

  • 左侧是用户列表(从后端一次性获取)
  • 右侧是一张 UserCard 卡片,展示某个人的姓名、年龄、头像
  • 点击左侧任意一个人,右侧卡片立即切换显示对应的人

你可能会想:“点击时让卡片自己去请求数据不就行了?”

但仔细一想,这样会带来几个麻烦:

  1. 数据重复请求 —— 列表数据明明已经在父组件里了,卡片还要再发一次请求,浪费性能。
  2. 状态难以同步 —— 如果卡片自己维护自己的数据,页面就不知道“当前选中了谁”,也无法统一控制高亮、路由等。
  3. 延迟感 —— 每次点击都要等网络响应,卡片会短暂空白,体验不好。

正确的设计思路是:

父组件(页面) 负责管理所有数据和“当前选中”的状态,子组件(卡片) 只负责接收数据并展示。

数据从父组件流向子组件,这叫 父传子,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: truedefault 不要同时用,它们逻辑上是矛盾的。

子组件 内部使用

在子组件的 <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)
上一篇
Vue3 生命周期
下一篇
Vue3 组件事件