5.2 组件通信:数据进出口设计 本节摘要:组件通信的第一原则是 Props 单向数据流——父传子靠属性,子告父靠事件,双向绑定是两者的组合约定。本节覆盖 Props 校验、自定义事件、v-model 的两代约定,以及跨层通信的方案阶梯(依赖注入、事件总线的兴衰、状态仓库),最后给出按关系距离选型的决策路径。 学习目标 阅读完本节,你应当能够: 写出带类型校验与默认值的 Props 定义,说明单向数据流的约束内容; 实现自定义事件与 Vue 3 的多 v-model 绑定; 说明事件总线方案衰落的原因与替代品; 按组件间关系距离选择通信方案而不是随手抓一个。 一、Props:唯一的数据进口 两条硬规矩: 单向数据流——子组件不允许改 props。Vue 会在开发环境警告直接赋值;
本节摘要:组件通信的第一原则是 Props 单向数据流——父传子靠属性,子告父靠事件,双向绑定是两者的组合约定。本节覆盖 Props 校验、自定义事件、v-model 的两代约定,以及跨层通信的方案阶梯(依赖注入、事件总线的兴衰、状态仓库),最后给出按关系距离选型的决策路径。
阅读完本节,你应当能够:
// Vue 2 / Vue 3 选项式通用 export default { props: { title: { type: String, required: true }, pageSize: { type: Number, default: 20, validator: (v) => v > 0 // 自定义校验,开发环境告警 }, columns: { type: Array, default: () => [] // 对象/数组默认值必须用函数返回 } } }
两条硬规矩:
单向数据流——子组件不允许改 props。Vue 会在开发环境警告直接赋值;对象内部字段被改不会警告但同样危险,因为父组件无法感知。要"改"怎么办?发事件让父组件自己改,或者用计算属性转接:
props: ['initialCount'], computed: { count: { get() { return this.initialCount; }, set(v) { this.$emit('update:count', v); } // 改动通过事件回流 } }
引用类型的坑——传对象或数组时,子组件拿到的是引用,内部改字段会污染父组件数据且无警告。约束只能靠规范与代码评审:props 视为只读,需要本地加工就深拷贝或转计算属性。
// 子组件 this.$emit('confirm', { id: 1, action: 'approve' }); // 父组件 <user-card @confirm="onConfirm"></user-card>
事件命名建议始终用连字符(confirm-item 而非 confirmItem),因为 DOM 模板里大写会被浏览器统一转小写。Vue 3 推荐显式声明 emits,既作文档又能区分组件自定义事件与原生事件透传:
emits: ['confirm', 'cancel']
组合式写法里用 defineEmits:
<script setup> const emit = defineEmits(['confirm']); function on_click() { emit('confirm', { id: 1 }); } </script>
第 3 章说过 v-model 的本质是单向绑定加反向事件,组件上同样成立。两代约定不同:
<!-- Vue 2:value + input --> <custom-input v-model="text"></custom-input> <!-- 展开为 --> <custom-input :value="text" @input="text = $event"></custom-input> <!-- Vue 3:modelValue + update:modelValue,且支持多绑定 --> <user-form v-model:name="name" v-model:age="age"></user-form>
Vue 3 的多 v-model 让"一个表单组件管理多个字段"不再需要蹩脚的 .sync(该修饰符已移除)。自定义输入组件的完整实现:
<script setup> defineProps(['modelValue']); defineEmits(['update:modelValue']); </script> <template> <input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" /> </template>

provide / inject 解决"隔 N 层传配置":祖先 provide,任意后代 inject,免掉中间组件无意义的层层透传(术语叫 props 钻井)。适合主题、语言、当前用户这类全局只读配置。风险是数据来源不如 props 直观,Vue 3 可配合 readonly 包一层防止后代误改:
// 祖先 provide('theme', readonly(theme)); // 后代 const theme = inject('theme', 'light'); // 第二参数是默认值
兄弟组件没有直连通道,两条正路:提升状态到共同父组件(父下发、兄弟上报),或者状态直接进仓库。前者适合两三个相关组件的小范围共享,后者适合多处消费的中大范围。
模板引用是逃生门:父组件用 ref 拿到子组件实例,直接调它的方法。适合"聚焦子组件输入框""触发子组件校验"这类强控制场景,但每多用一次,组件封装就漏一次气——它绕过了所有的通信契约。
事件总线的兴衰值得一提。Vue 2 时代常见 new Vue() 当总线、全局 on/off 广播事件,看似解了任意通信,实则事件满天飞、来源不可追、内存泄漏频发。Vue 3 干脆移除了实例上的事件接口,官方态度明确:任意距离的通信用状态仓库解决。老工程迁移时,总线代码是重点改造对象。
需求:设置页含多个分区组件,顶部按钮统一"保存全部"。设计走一遍阶梯——分区内编辑产生脏数据,若用 emits 层层上抛到页面对象再下发,页面组件会膨胀成中转站。正确姿势:页面级状态用 Pinia(第 6 章),每个分区从仓库读写自己的分片,顶部按钮调用仓库的统一保存动作。分区间无直接通信,页面组件只负责布局。这个例子也预告了下一章的登场逻辑:当通信层级开始腐烂,就是状态管理进场的时候。
组件之间的数据治理讲完,下一节看内容分发与按需加载这两个高阶维度。