组件定义与 props
组件基础:props、emits 与事件 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
这一节建立组件通信的第一条规则:数据通过 props 向下流,props 只读。学完你能定义带类型校验的 props、用 defineProps 的两种写法、理解 props 解构的响应式问题,并知道什么时候该传对象、什么时候该拆成基本类型。
问题场景
你写了一个 UserCard 组件,父组件传 user 对象进去。子组件里为了「顺手」,直接改 props.user.name——控制台没报错,界面也变了,于是你以为可以这么写。几天后另一个页面复用这个组件,父组件的数据被莫名其妙改了,排查半天。Vue 不会阻止你改 props 对象的内部属性,但单向数据流被破坏后,数据流向就失控了。
核心解释
组件就是一个 .vue 文件。父组件 import 后直接用标签,数据通过属性传入,子组件用 defineProps 声明接收:
<!-- components/UserCard.vue -->
<script setup>
const props = defineProps({
name: { type: String, required: true },
age: { type: Number, default: 0 },
tags: { type: Array, default: () => [] },
});
</script>
<template>
<article>
<h3>{{ props.name }}({{ props.age }} 岁)</h3>
<span v-for="tag in props.tags" :key="tag" class="tag">{{ tag }}</span>
</article>
</template>
defineProps 是编译宏,不需要 import,也不能在普通函数里调用。两种写法:对象语法(如上,带运行时校验)和数组语法(defineProps(["name", "age"]),只声明不校验)。TypeScript 项目更常用类型语法:
TypeScript 项目更常用类型语法:先写 interface Props { name: string; age?: number; tags?: string[] },再 withDefaults(defineProps<Props>(), { age: 0, tags: () => [] })。编译期会校验传参类型,但运行时校验只有对象语法才有——这点在 V14 展开。
props 只读:Vue 会在开发环境警告「Set operation on key ... failed: target is readonly」,但只对顶层赋值生效;改 props.user.name 不会警告。所以规则要自己守:子组件永远不直接改 props,需要改就 emit 事件让父组件改(下一节)。
解构 props 的坑:Vue 3.5 之前,const { name } = defineProps(...) 会丢失响应式(props 更新后 name 不变)。Vue 3.5+ 的解构是响应式的(编译器做了处理),但为了兼容性和可读性,团队里常见两种做法:不解构,或解构后用 toRef。稳妥写法:
<script setup>
import { toRef } from "vue";
const props = defineProps({ name: String });
const name = toRef(props, "name"); // 保持响应式
</script>
传值形态的选择:传对象(<UserCard :user="user" />)适合字段多、整体语义明确的场景;拆成多个基本类型(:name="user.name" :age="user.age")适合组件只关心少数字段、复用面广的场景。后者更利于 memo 化思维(改 name 不影响只读 age 的渲染),前者更省事。没有绝对答案,看组件边界是否清晰。
示例拆解
一个商品卡片,props 带默认值与校验:
<!-- components/ProductCard.vue -->
<script setup>
const props = defineProps({
title: { type: String, required: true },
price: { type: Number, required: true },
currency: { type: String, default: "CNY" },
onSale: { type: Boolean, default: false },
});
</script>
<template>
<div class="card" :class="{ sale: props.onSale }">
<h4>{{ props.title }}</h4>
<p>
{{ props.currency }} {{ props.price.toFixed(2) }}
<span v-if="props.onSale">限时优惠</span>
</p>
</div>
</template>
<!-- App.vue -->
<script setup>
import { reactive } from "vue";
import ProductCard from "./components/ProductCard.vue";
const products = reactive([
{ id: 1, title: "机械键盘", price: 399, onSale: true },
{ id: 2, title: "显示器", price: 1299 },
]);
</script>
<template>
<main>
<ProductCard
v-for="p in products"
:key="p.id"
:title="p.title"
:price="p.price"
:on-sale="p.onSale"
/>
</main>
</template>
细节:HTML 属性名是 kebab-case(on-sale),props 声明是 camelCase(onSale),Vue 自动转换;:price="p.price" 是表达式绑定,传的是数字;不写冒号 price="399" 传的是字符串,toFixed 会报错。布尔 prop 只写属性名(<ProductCard on-sale />)等价于传 true。
动手任务
- 给
ProductCard加一个stockprop(默认0),库存为 0 时显示「缺货」并加禁用样式。 - 把
ProductCard改成 TypeScript 版本,用withDefaults(defineProps<Props>(), ...)重写。 - 在父组件里把
:on-sale="p.onSale"改成:on-sale="true",观察第二个商品也显示优惠——理解「传值」和「传常量」的区别。
常见错误
- 子组件直接改 props 对象内部属性:能跑但破坏单向数据流,父组件数据被污染。改为 emit 事件。
- props 名大小写混用:声明
onSale,模板写:onsale或:on_sale都收不到。模板里统一用 kebab-case。 - 对象/数组默认值直接写
[]:default: []会被所有实例共享同一个数组。必须default: () => []。 - 忘记 required 导致 undefined 崩溃:
{{ props.title.toUpperCase() }}在 title 未传时报错,加 required 或默认值。
检查与复盘
- 能写出对象语法和类型语法的 props 声明
- 能解释为什么不能改 props,以及正确的替代路径
- 能说出 props 解构丢失响应式的条件与修复方式
参考资料
- Vue 官方文档「组件基础」:组件定义与使用
- Vue 官方文档「Props」:声明、校验、命名转换
读完这一课了?
标记完成后,工作台会安排接下来的学习。