侧边栏壁纸
博主头像
洋洋的技术笔记

行动起来,活在当下

  • 累计撰写 16 篇文章
  • 累计创建 2 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录
VUE

Props与组件通信

洋洋的技术笔记
2026-11-09 / 0 评论 / 0 点赞 / 2 阅读 / 0 字

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>

常见写法对比如下:

写法

实际传入值

title="掌握 Props"

字符串 "掌握 Props"

:title="task.title"

task.title 的当前值

completed="true"

字符串 "true"

:completed="true"

布尔值 true

:priority="2"

数字 2

:task="task"

一个对象引用

尤其要注意 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
})

常见类型如下:

类型声明

适合的数据

String

标题、分类名称、格式化后的日期文本

Number

数量、进度、排序值

Boolean

是否完成、是否禁用

Array

标签列表、选项列表

Object

结构明确的业务对象

Date

日期对象

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 声明是否对应

数字或布尔值变成字符串

使用 :value="...",不要只写普通 HTML 属性

控制台提示缺少必填 Prop

由父组件补充,或重新判断它是否真的可选

对象默认值导致实例间共享

使用 default: () => ({}) 或 default: () => []

父组件更新后,本地值没有更新

检查是否只在初始化时复制了一次 Props;派生值使用 computed

子组件修改 Props 出现警告

使用本地状态,或通知父组件更新数据

子组件修改对象字段也能运行

技术上可能生效,但会破坏单向数据流,应避免

解构 Props 后失去响应式

通过 props.xxx 访问,或使用 toRef、computed

class 没出现在声明的 Props 中

class 通常属于透传属性,不需要声明为 Prop

TypeScript 对象字段没有约束

使用接口或类型字面量描述 Props,不要只写 Object

Props 数量持续增加

检查组件职责、对象分组和是否存在过度逐层转发


十七、总结

Props 是组件的输入契约,也是父子组件通信的基础:

  1. <script setup> 使用 defineProps 声明 Props,模板可以直接访问。

  2. 静态属性传递字符串,动态值和非字符串值使用 v-bind。

  3. JavaScript 中推荐 camelCase,模板中可以使用对应的 kebab-case。

  4. 对象写法可以声明类型、必填项、默认值和自定义校验器。

  5. 对象与数组默认值使用工厂函数,避免组件实例共享默认数据。

  6. Props 遵循单向数据流,子组件不应直接修改 Prop 或其对象字段。

  7. 本地可编辑值可以单独建立状态,派生值使用 computed,数据变更由父组件处理。

  8. Props 是组件显式依赖,未声明的普通属性通常通过透传机制处理。

  9. TypeScript 项目可使用类型参数与 withDefaults 获得更精确的类型约束。

  10. 好的 Props 设计来自清晰的组件职责,而不是机械地传整个对象或拆出大量字段。

下一站预告

下一篇《自定义事件与v-model》将系统讲解子组件如何向父组件发送事件、传递参数、校验事件,以及如何用 v-model 封装可复用输入组件,补齐父子组件双向协作的完整链路。


作者:洋洋技术笔记 发布日期:2026-11-09 系列:Vue.js从入门到精通 - 第9篇


原文链接:洋洋的技术笔记

0

评论区