Props与组件通信
Props 是父组件向子组件传递数据的公开契约。清晰的 Props 设计,能让组件知道“需要接收什么”,也让使用者知道“可以怎样复用它”。
上一篇文章中,我们把学习看板拆成了多个职责单一的组件,但组件展示的数据仍写死在各自内部。本文将为组件增加数据输入能力,学习 Props 的声明、传递、校验、默认值和单向数据流,并通过任务卡片理解父子组件之间合理的数据边界。
写作约定:本系列以 Vue 3
<script setup>为主要写法,示例默认使用 Vue 3.2+ 和 Vite 创建的项目。本文聚焦父组件向子组件传值;子组件通知父组件的完整自定义事件机制将在下一篇展开。
一、为什么需要 Props
先回顾一个没有数据输入能力的组件:
<!-- TaskCard.vue -->
<template>
<article class="task-card">
<h2>完成组件拆分</h2>
<p>Vue.js 从入门到精通</p>
<span>未完成</span>
</article>
</template>这个组件可以独立渲染,但每次使用都显示同一条任务。若页面需要展示十条不同任务,显然不应该复制出十个组件文件。
Props 可以把不变的结构留在组件内部,把变化的数据交给父组件传入:
<template>
<main>
<TaskCard title="复习模板语法" category="Vue 基础" />
<TaskCard title="掌握 Props" category="组件通信" />
<TaskCard title="完成看板练习" category="项目实战" />
</main>
</template>
<script setup>
import TaskCard from './components/TaskCard.vue'
</script>同一个 TaskCard 定义创建了三个独立实例,每个实例接收不同的数据。父组件负责决定展示什么,子组件负责决定如何展示。
Props 带来的核心价值是:
复用结构:同一组件可以展示不同内容。
明确依赖:组件需要哪些外部数据,一眼可见。
约束输入:通过类型、必填项和自定义校验尽早发现错误。
划分职责:父组件管理数据,子组件根据数据渲染界面。
二、声明 Props:先定义组件的输入契约
在 <script setup> 中,使用编译器宏 defineProps 声明组件可以接收的属性。它不需要从 vue 导入。
<!-- TaskCard.vue -->
<template>
<article class="task-card">
<p class="category">{{ category }}</p>
<h2>{{ title }}</h2>
<span>{{ completed ? '已完成' : '未完成' }}</span>
</article>
</template>
<script setup>
const props = defineProps({
title: String,
category: String,
completed: Boolean
})
</script>
<style scoped>
.task-card {
padding: 20px;
border: 1px solid #d1d5db;
border-radius: 12px;
}
.category {
color: #059669;
}
</style>defineProps 返回一个 Props 对象。这里将其保存为 props,但模板中可以直接使用 title、category 和 completed。
在 <script> 中则通过 props 访问:
const props = defineProps({
title: String,
category: String,
completed: Boolean
})
console.log(props.title)也可以只声明属性名,不写类型:
const props = defineProps(['title', 'category', 'completed'])数组写法适合非常简单的示例。实际项目更推荐对象写法,因为它能同时表达类型、必填项、默认值和校验规则。
三、父组件如何传递 Props
3.1 静态字符串
没有使用 v-bind 时,属性值按字符串传递:
<TaskCard title="掌握 Props" category="组件通信" />这里的 title 和 category 都是字符串。
3.2 动态值与非字符串值
使用 v-bind 或缩写 : 可以传递 JavaScript 表达式:
<template>
<TaskCard
:title="task.title"
:category="task.category"
:completed="task.completed"
:priority="2"
/>
</template>
<script setup>
import { reactive } from 'vue'
import TaskCard from './components/TaskCard.vue'
const task = reactive({
title: '掌握 Props',
category: '组件通信',
completed: false
})
</script>常见写法对比如下:
尤其要注意 completed="true" 与 :completed="true" 的区别。需要传递数字、布尔值、数组或对象时,通常都应使用 v-bind。
3.3 使用 v-bind 传递一组属性
如果对象中的键与 Props 名称一致,可以一次传入:
<template>
<TaskCard v-bind="task" />
</template>
<script setup>
import { reactive } from 'vue'
import TaskCard from './components/TaskCard.vue'
const task = reactive({
title: '掌握 Props',
category: '组件通信',
completed: false
})
</script>这相当于:
<TaskCard
:title="task.title"
:category="task.category"
:completed="task.completed"
/>属性较少且对应关系明确时,v-bind="task" 很简洁;如果对象还包含大量子组件不需要的字段,显式列出 Props 更容易阅读,也能避免无关属性透传到根元素。
四、Prop 名称:JavaScript 用 camelCase,模板可用 kebab-case
JavaScript 中通常使用 camelCase 声明 Props:
const props = defineProps({
taskTitle: String,
createdAt: String
})父组件模板中推荐使用对应的 kebab-case:
<TaskCard task-title="掌握 Props" created-at="2026-11-09" />Vue 会自动对应 taskTitle 与 task-title。在单文件组件中也可以使用 camelCase,但团队应统一风格:
<TaskCard :taskTitle="title" />HTML 特性名不区分大小写,因此 DOM 内模板更推荐 kebab-case。无论选择哪种写法,都不要为了让模板“看起来一致”而把 JavaScript 中的 Props 声明改成带横线的字符串键。
五、类型声明与运行时校验
Vue 支持使用原生构造函数声明 Props 类型:
const props = defineProps({
title: String,
priority: Number,
completed: Boolean,
tags: Array,
task: Object,
deadline: Date,
onSelect: Function
})常见类型如下:
一个 Prop 也可以允许多种类型:
const props = defineProps({
id: [String, Number]
})运行时校验主要服务于开发阶段。父组件传入不匹配的数据时,Vue 会在控制台给出警告,但它不会代替服务端校验,也不会自动修复业务数据。
如果组件只需要一个完整任务对象,可以声明为对象:
<template>
<article>
<h2>{{ task.title }}</h2>
<p>{{ task.category }}</p>
</article>
</template>
<script setup>
const props = defineProps({
task: {
type: Object,
required: true
}
})
</script>不过,type: Object 只能说明传入值是对象,不能保证其中一定存在 title。TypeScript 项目可以通过类型定义进一步约束对象结构,后文会介绍。
六、必填项与默认值
6.1 声明必填 Props
组件离开某个数据就无法正常工作时,应将其标记为必填:
const props = defineProps({
title: {
type: String,
required: true
}
})required: true 表示父组件必须提供该 Prop。它用于描述真实的组件约束,不建议给所有 Props 一律加上必填。
6.2 为基本类型设置默认值
可选 Props 可以提供默认值:
const props = defineProps({
category: {
type: String,
default: '未分类'
},
completed: {
type: Boolean,
default: false
},
priority: {
type: Number,
default: 1
}
})父组件未传递时,子组件使用默认值:
<TaskCard title="整理学习笔记" />此时 category 为“未分类”,completed 为 false。
6.3 对象和数组使用工厂函数
对象或数组的默认值应通过工厂函数返回:
const props = defineProps({
tags: {
type: Array,
default: () => []
},
author: {
type: Object,
default: () => ({
name: '洋洋技术笔记'
})
}
})每个组件实例都能获得独立的默认值,避免不同实例意外共享同一个对象或数组。
6.4 默认值不等于空值兜底
默认值主要用于处理 undefined,并不意味着任何无效输入都会被替换。父组件显式传入空字符串、错误类型或结构残缺的对象时,仍需要根据业务语义处理。
例如,标题必填并不代表标题一定不是空白字符串:
const props = defineProps({
title: {
type: String,
required: true,
validator: value => value.trim().length > 0
}
})七、自定义校验器
除了类型检查,还可以通过 validator 添加业务约束:
const props = defineProps({
priority: {
type: Number,
default: 1,
validator: value => Number.isInteger(value) && value >= 1 && value <= 5
},
status: {
type: String,
default: 'pending',
validator: value => ['pending', 'done'].includes(value)
},
progress: {
type: Number,
default: 0,
validator: value => value >= 0 && value <= 100
}
})校验器返回 true 表示通过,返回 false 时 Vue 在开发环境给出警告。
适合放入校验器的是简单、同步且稳定的约束,例如:
数字必须位于指定区间;
状态必须属于固定选项;
数组长度不能超过组件可展示的上限;
字符串不能为空白。
不要在校验器中发送请求、修改状态或执行复杂计算。校验器的职责是验证输入,不是处理业务流程。
八、Boolean Props 的特殊转换规则
布尔 Prop 在模板中有一些便捷写法。子组件声明:
const props = defineProps({
disabled: Boolean
})父组件可以只写属性名:
<BaseButton disabled />这表示 disabled 为 true,完全不写时默认是 false。以下写法的语义需要区分:
<BaseButton />
<BaseButton disabled />
<BaseButton :disabled="false" />
<BaseButton :disabled="true" />当一个 Prop 同时允许多种类型时,类型声明顺序还可能影响布尔值转换。若希望支持简写,通常将 Boolean 放在 String 前:
const props = defineProps({
disabled: [Boolean, String]
})日常开发中,显式写 :disabled="isDisabled" 往往更容易理解,尤其是值来自复杂表达式时。
九、单向数据流:不要在子组件中直接修改 Props
Vue 的 Props 遵循单向数据流:父组件更新数据,新的值流向子组件;子组件不应直接修改自己接收到的 Prop。
下面的写法应避免:
<script setup>
const props = defineProps({
title: String
})
// 不推荐:直接修改父组件传入的 Prop
props.title = '修改后的标题'
</script>这种修改会让子组件与父组件的数据关系变得难以追踪,Vue 也会在开发环境发出警告。
9.1 根据初始值建立本地状态
如果 Prop 只用于初始化一个可独立变化的本地值,可以创建本地响应式状态:
<template>
<input v-model="draftTitle">
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
initialTitle: {
type: String,
default: ''
}
})
const draftTitle = ref(props.initialTitle)
</script>这里用 initialTitle 命名,明确它只提供初始值。之后输入框修改的是子组件自己的 draftTitle。
注意:ref(props.initialTitle) 只读取一次初始值。父组件之后更新 initialTitle,不会自动重置 draftTitle。
9.2 根据 Props 计算派生值
如果值完全由 Props 决定,应使用计算属性:
<template>
<strong>{{ percent }}%</strong>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
completed: Number,
total: Number
})
const percent = computed(() => {
if (!props.total) return 0
return Math.round((props.completed / props.total) * 100)
})
</script>父组件传入新的 completed 或 total 后,计算属性会自动更新。
9.3 不要直接解构后当作响应式变量使用
以下写法会丢失响应式联系:
const props = defineProps({
title: String
})
const { title } = props在传统写法中,title 只是声明时取出的普通值,父组件更新后它不会随之更新。模板直接使用 Props,或在脚本中通过 props.title 访问,是最直观的方式。
确实需要把单个 Prop 转成响应式引用时,可以使用 toRef:
import { toRef } from 'vue'
const props = defineProps({
title: String
})
const title = toRef(props, 'title')如果只是为了基于 Props 计算另一个值,通常优先使用 computed,不必为了少写 props. 而大量创建引用。
9.4 对象 Props 也不能随意修改内部字段
直接给 Prop 重新赋值不被允许,修改对象内部字段同样应谨慎:
const props = defineProps({
task: Object
})
// 不推荐:子组件直接修改父组件对象中的字段
props.task.completed = true因为对象按引用传递,这会直接改变父组件持有的数据,破坏清晰的数据流向。更合理的方式是由子组件发出“用户希望完成任务”的事件,让父组件决定是否更新原数据。
十、子组件需要变更数据时怎么办
子组件不应该修改 Props,但可以表达用户意图。例如,任务卡片只负责通知“完成按钮被点击”:
<!-- TaskCard.vue -->
<template>
<article class="task-card">
<p>{{ category }}</p>
<h2>{{ title }}</h2>
<span>{{ completed ? '已完成' : '未完成' }}</span>
<button
type="button"
:disabled="completed"
@click="emit('complete')"
>
标记完成
</button>
</article>
</template>
<script setup>
defineProps({
title: {
type: String,
required: true
},
category: {
type: String,
default: '未分类'
},
completed: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['complete'])
</script>父组件监听事件并更新自己的任务:
<template>
<TaskCard
:title="task.title"
:category="task.category"
:completed="task.completed"
@complete="completeTask"
/>
</template>
<script setup>
import { reactive } from 'vue'
import TaskCard from './components/TaskCard.vue'
const task = reactive({
title: '掌握 Props',
category: '组件通信',
completed: false
})
function completeTask() {
task.completed = true
}
</script>数据变化仍由父组件完成,更新后的 completed 再通过 Props 流向子组件:
父组件保存任务数据
│ Props
▼
子组件展示任务并响应点击
│ complete 事件
▼
父组件更新任务数据
│ Props 自动更新
▼
子组件显示最新状态这里只展示了自定义事件的最小用法。事件参数、事件校验和多个 v-model 等内容将在下一篇系统介绍。
十一、完整示例:可复用的学习任务列表
下面把数据放在父组件,把卡片展示放在子组件。
11.1 子组件只关心展示与交互
<!-- TaskCard.vue -->
<template>
<article class="task-card" :class="{ completed }">
<div class="task-heading">
<span class="category">{{ category }}</span>
<span>优先级 {{ priority }}</span>
</div>
<h2>{{ title }}</h2>
<p>{{ completed ? '已完成' : '待完成' }}</p>
<button
v-if="!completed"
type="button"
@click="emit('complete')"
>
完成任务
</button>
</article>
</template>
<script setup>
defineProps({
title: {
type: String,
required: true,
validator: value => value.trim().length > 0
},
category: {
type: String,
default: '未分类'
},
priority: {
type: Number,
default: 1,
validator: value => Number.isInteger(value) && value >= 1 && value <= 5
},
completed: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['complete'])
</script>
<style scoped>
.task-card {
padding: 20px;
border: 1px solid #d1d5db;
border-radius: 12px;
background: white;
}
.task-heading {
display: flex;
justify-content: space-between;
gap: 12px;
color: #6b7280;
}
.category {
color: #059669;
font-weight: 600;
}
.completed {
opacity: 0.65;
}
button {
padding: 8px 14px;
border: 0;
border-radius: 6px;
color: white;
background: #42b883;
cursor: pointer;
}
</style>11.2 父组件管理任务数据
<!-- App.vue -->
<template>
<main class="dashboard">
<h1>学习任务</h1>
<p>已完成 {{ completedCount }} / {{ tasks.length }}</p>
<section class="task-grid">
<TaskCard
v-for="task in tasks"
:key="task.id"
:title="task.title"
:category="task.category"
:priority="task.priority"
:completed="task.completed"
@complete="completeTask(task.id)"
/>
</section>
</main>
</template>
<script setup>
import { computed, ref } from 'vue'
import TaskCard from './components/TaskCard.vue'
const tasks = ref([
{
id: 1,
title: '复习组件注册方式',
category: '组件基础',
priority: 2,
completed: true
},
{
id: 2,
title: '掌握 Props 声明与校验',
category: '组件通信',
priority: 3,
completed: false
},
{
id: 3,
title: '完成学习看板练习',
category: '项目实战',
priority: 1,
completed: false
}
])
const completedCount = computed(
() => tasks.value.filter(task => task.completed).length
)
function completeTask(taskId) {
const task = tasks.value.find(item => item.id === taskId)
if (task) {
task.completed = true
}
}
</script>
<style scoped>
.dashboard {
max-width: 960px;
margin: 0 auto;
padding: 32px 20px;
}
.task-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 20px;
}
</style>这个例子中的职责边界很清楚:
App.vue持有任务列表、统计进度并处理完成操作;TaskCard.vue声明输入要求,负责展示和发出完成意图;每个卡片通过稳定的
task.id与列表项对应;子组件没有直接修改
task.completed,数据仍保持单向流动。
十二、Props 与透传属性不是一回事
父组件传给子组件的内容,不一定都属于 Props。只有子组件通过 defineProps 声明的名称才会成为 Props。
<TaskCard
title="掌握 Props"
class="featured-card"
data-track="task-card"
/>如果 TaskCard 只声明了 title,那么 class 和 data-track 属于透传属性。对于单根组件,它们默认会落到子组件根元素:
<template>
<article class="task-card">
<h2>{{ title }}</h2>
</article>
</template>
<script setup>
defineProps({
title: String
})
</script>最终根元素会合并 task-card 与 featured-card,并带有 data-track 属性。
如果组件有多个根节点,或者需要把属性放到内部指定元素,可以关闭自动透传。下面的 defineOptions 是 Vue 3.3+ 提供的编译器宏:
<template>
<article>
<header>任务信息</header>
<div v-bind="$attrs">
<h2>{{ title }}</h2>
</div>
</article>
</template>
<script setup>
defineOptions({ inheritAttrs: false })
defineProps({ title: String })
</script>不要为了减少模板中的属性而把所有输入都设计成透传。组件真正依赖的数据应显式声明为 Props,普通 HTML 属性则交给透传机制处理。
十三、TypeScript 项目中的 Props
TypeScript 项目可以使用类型参数声明 Props,获得更完整的编辑器和编译期检查。
<!-- TaskCard.vue -->
<template>
<article>
<h2>{{ title }}</h2>
<p>{{ category }}</p>
<span v-for="tag in tags" :key="tag">{{ tag }}</span>
</article>
</template>
<script setup lang="ts">
interface Props {
title: string
category?: string
tags?: string[]
completed?: boolean
}
const props = withDefaults(defineProps<Props>(), {
category: '未分类',
tags: () => [],
completed: false
})
</script>接口中带 ? 的属性是可选 Props,withDefaults 为它们提供默认值。对象和数组默认值仍使用工厂函数。
也可以把任务结构提取为类型:
export interface Task {
id: number
title: string
category: string
completed: boolean
}<script setup lang="ts">
import type { Task } from '../types/task'
const props = defineProps<{
task: Task
}>()
</script>类型声明能在开发时发现缺少字段、字段类型错误等问题,比单纯的 type: Object 更精确。但接口中的复杂运行时约束仍可能需要 validator 或业务校验配合。
类型参数和运行时对象声明是两种不同写法,同一份 defineProps 中不要混用。
十四、Options API 中的 Props
维护 Options API 项目时,通过 props 选项声明:
<template>
<article>
<h2>{{ title }}</h2>
<p>{{ category }}</p>
</article>
</template>
<script>
export default {
props: {
title: {
type: String,
required: true
},
category: {
type: String,
default: '未分类'
}
}
}
</script>在其他选项中通过 this 访问:
export default {
props: {
completed: Number,
total: Number
},
computed: {
percent() {
if (!this.total) return 0
return Math.round((this.completed / this.total) * 100)
}
}
}Props 的类型、默认值、校验器和单向数据流规则与组合式 API 相同,区别主要在声明与访问方式。
十五、Props 设计的常见取舍
15.1 传整个对象,还是拆成多个 Props
传整个对象:
<TaskCard :task="task" />适合子组件确实依赖同一业务对象的大部分字段,而且对象结构稳定的情况。
拆成多个 Props:
<TaskCard
:title="task.title"
:completed="task.completed"
/>适合通用展示组件。它不必理解完整的 Task 业务结构,因此更容易复用和测试。
不要为了追求“通用”而机械拆出几十个 Props,也不要让一个基础卡片组件依赖庞大且无关的页面对象。应以组件真正需要的字段为依据。
15.2 Props 太多时先检查职责
当一个组件需要十几甚至几十个 Props 时,可能出现了以下情况:
一个组件承担了过多展示和交互职责;
可以把相关字段组织成结构明确的对象;
多层组件只是在重复转发数据;
部分数据更适合通过插槽、组合函数或状态管理提供。
Props 数量不是唯一标准,但不断增加的输入参数通常是在提醒重新审视组件边界。
15.3 不要过度使用 Props 逐层转发
如果数据需要经过很多层组件才能到达目标组件,中间组件却完全不使用它,就会产生“Props 逐层转发”。可以先检查组件拆分是否合理,再根据数据作用域选择依赖注入或状态管理,而不是立即把所有数据放进全局仓库。
十六、常见问题自查
十七、总结
Props 是组件的输入契约,也是父子组件通信的基础:
<script setup>使用defineProps声明 Props,模板可以直接访问。静态属性传递字符串,动态值和非字符串值使用
v-bind。JavaScript 中推荐 camelCase,模板中可以使用对应的 kebab-case。
对象写法可以声明类型、必填项、默认值和自定义校验器。
对象与数组默认值使用工厂函数,避免组件实例共享默认数据。
Props 遵循单向数据流,子组件不应直接修改 Prop 或其对象字段。
本地可编辑值可以单独建立状态,派生值使用
computed,数据变更由父组件处理。Props 是组件显式依赖,未声明的普通属性通常通过透传机制处理。
TypeScript 项目可使用类型参数与
withDefaults获得更精确的类型约束。好的 Props 设计来自清晰的组件职责,而不是机械地传整个对象或拆出大量字段。
下一站预告
下一篇《自定义事件与v-model》将系统讲解子组件如何向父组件发送事件、传递参数、校验事件,以及如何用 v-model 封装可复用输入组件,补齐父子组件双向协作的完整链路。
作者:洋洋技术笔记 发布日期:2026-11-09 系列:Vue.js从入门到精通 - 第9篇
原文链接:洋洋的技术笔记
评论区