讲解
组件是可复用的 UI 单元:把「一个按钮的样式、行为、文案规则」封装起来,用到的地方只写 <AppButton>保存</AppButton>。拆组件的收益是复用与隔离——每个组件管自己的状态和模板,组件之间通过明确的接口交流,这个接口就是 props(父传子数据)和 emits(子通知父事件)。
props 在 <script setup> 里用 defineProps 声明:const props = defineProps({ title: String, max: { type: Number, default: 10 } })。声明时可以配类型、默认值、required 校验。牢记 props 单向数据流:子组件绝不能直接改 props,改不动(只读)而且是反模式——父组件数据一变就把你的修改冲掉了。想基于 props 做本地编辑,先拷进本地 ref。
emits 用 defineEmits 声明,返回一个 emit 函数:子组件 emit('submit', formData),父组件 <Child @submit="handleSubmit">。声明 emits(defineEmits(['submit']))不只是文档作用,还能配校验函数。父子通信的完整闭环是:父通过 props 把数据传下去,子通过 emit 把意图传上来,父改自己的数据,新的 props 又流回子组件——数据永远单向流动,事件逆流而上。
<script setup> 写的组件不需要显式注册,import 后在模板里直接用;组件名用 PascalCase 或短横线形式都可以。组件默认把所有特性(class、style、事件)透传到根元素(attribute 继承),写包装组件时这个行为很省心。
示例
把「props 校验 + emit 通知 + 单向数据流」的完整闭环用纯 JS 走一遍(在本教程构建时被真实执行):
import assert from 'node:assert/strict';
// 模拟 defineProps 的运行时校验
function checkProps(def, actual) {
const errors = [];
for (const [key, rule] of Object.entries(def)) {
const value = actual[key];
if (value === undefined) {
if (rule.required) errors.push('缺少必需 prop: ' + key);
continue;
}
const type = typeof rule === 'function' ? rule.name.toLowerCase() : rule.type.name.toLowerCase();
if (typeof value !== type) errors.push(key + ' 类型错误: 期望 ' + type);
}
return errors;
}
const def = { title: { type: String, required: true }, max: { type: Number, default: 10 } };
assert.deepStrictEqual(checkProps(def, { title: '购物车', max: '99' }), ['max 类型错误: 期望 number']);
assert.deepStrictEqual(checkProps(def, { max: 5 }), ['缺少必需 prop: title']);
assert.deepStrictEqual(checkProps(def, { title: '购物车', max: 5 }), []);
// 单向数据流:子组件 emit,父组件改自己的状态
function createParent() {
const cart = { items: [] };
return {
cart,
onAdd(product) {
cart.items.push(product); // 父组件响应子组件事件
},
};
}
function Child(props, emit) {
return {
clickButton() {
emit('add', { id: 1, name: '《Vue 实战》' }); // 子组件只发意图,不改父数据
},
};
}
const parent = createParent();
const child = Child({}, (event, payload) => {
assert.strictEqual(event, 'add');
parent.onAdd(payload);
});
child.clickButton();
assert.strictEqual(parent.cart.items.length, 1);
assert.strictEqual(parent.cart.items[0].name, '《Vue 实战》');
console.log('props 校验拦截了 2 种错误');
console.log('子组件 emit → 父组件 cart:', parent.cart.items.map((i) => i.name).join('、'));
真实父子组件:
<script setup>
// Child.vue
const props = defineProps({
title: { type: String, required: true },
max: { type: Number, default: 10 },
});
const emit = defineEmits(['add']);
</script>
<template>
<h3>{{ title }}(上限 {{ max }})</h3>
<button @click="emit('add', { id: 1, name: '《Vue 实战》' })">加入</button>
</template>
<script setup>
// Parent.vue
import { ref } from 'vue';
import Child from './Child.vue';
const count = ref(0);
function onAdd(product) {
count.value++;
console.log('加入:', product.name);
}
</script>
<template>
<Child title="购物车" :max="10" @add="onAdd" />
<p>已加入 {{ count }} 件</p>
</template>
常见坑
- 子组件直接改 props:控制台会警告,且数据流被打乱;本地要改就拷进 ref。
- props 传字符串当数字:
max="10"传的是字符串,加冒号:max="10"才是数字。 - 事件名大小写翻车:模板里统一用短横线
@my-event,emit 时用驼峰 Vue 会自动对应,但保持一致最省心。 - defineProps 用 import 的值做默认值:defineProps 是编译宏,参数必须是静态可分析的,复杂默认值用工厂函数。
- 把方法当 prop 传下去代替 emit:能跑,但绕过了事件接口,组件边界变模糊,团队协作时会踩坑。
小结
组件接口 = props 下传数据 + emits 上传事件,数据单向流动;defineProps/defineEmits 声明接口并做校验。下一章看 props/emits 的一个高频特化:组件上的 v-model。