文章目录
本文基于 Vue 3.5 技术栈,从
scoped样式隔离的底层原理出发,系统讲解父子组件通信(Props / Emit)、Props 校验、以及 Provide/Inject 和 EventBus 等非父子通信方案。全文"概念 → 原理 → 实战 → 复盘"层层递进,所有代码均可运行。
一、scoped 属性详解
1. 核心作用
在 Vue 单文件组件(.vue)中,<style> 标签默认是全局样式。如果你在 A 组件中写了 h3 { color: red },项目中所有组件的 h3 标签都会变红——这就是样式污染,会造成选择器冲突。
添加 scoped 属性后,样式变为当前组件的局部样式,仅作用于本组件内部 DOM 元素,不会影响外部其他组件。
结合项目代码来直观感受。来看 01-scoped作用及原理 目录下的示例——两个并排的子组件,各自写了 h3 样式:
父组件 App.vue(不写 scoped):
<script setup>
import MyLeft from './components/MyLeft.vue'
import MyRight from './components/MyRight.vue'
</script>
<template>
<div class="container">
<my-left />
<my-right />
</div>
</template>
<style>
.container {
display: flex;
}
</style>
左子组件 MyLeft.vue(h3 = 红色):
<script setup></script>
<template>
<div class="my-left"
style="flex:1; height:100px; align-items:center; text-align:center; background:paleturquoise;">
<h3>每天起床第一句</h3>
</div>
</template>
<style scoped>
h3 {
color: red;
}
</style>
右子组件 MyRight.vue(h3 = 绿色):
<script setup></script>
<template>
<div class="my-right"
style="flex:1; height:100px; align-items:center; text-align:center; background:plum;">
<h3>先给自己打个气</h3>
</div>
</template>
<style scoped>
h3 {
color: green;
}
</style>
运行结果:左侧"每天起床第一句"为红色,右侧"先给自己打个气"为绿色,两者互不干扰。如果去掉
scoped,两个h3 { color }规则会互相覆盖,最后只有一个颜色生效。
2. 底层实现原理
scoped 并非浏览器原生能力,而是 Vue 在编译阶段通过 PostCSS 实现的效果。核心机制分两步:
第一步:Vue 自动给当前组件内所有 DOM 标签,添加一个唯一自定义属性 data-v-随机哈希串。 例如:
编译前:<h3>每天起床第一句</h3>
编译后:<h3 data-v-7ba5bd90>每天起床第一句</h3>
每个组件的哈希串是全局唯一的——MyLeft 的哈希是 7ba5bd90,MyRight 的哈希是 f3e2d1a0。
第二步:编译 CSS 选择器时,自动在每条规则末尾拼接该唯一属性选择器,形成交集选择器。 例如:
/* 你写的源码 */
h3 {
color: red;
}
/* 编译后实际生效的 CSS */
h3[data-v-7ba5bd90] {
color: red;
}
原理图解:
组件 MyLeft 组件 MyRight
│ │
▼ ▼
模板编译:每个标签 模板编译:每个标签
加 data-v-7ba5bd90 加 data-v-f3e2d1a0
│ │
▼ ▼
CSS 编译:选择器拼接 CSS 编译:选择器拼接
h3[data-v-7ba5bd90] h3[data-v-f3e2d1a0]
│ │
▼ ▼
只匹配左侧 h3 ✅ 只匹配右侧 h3 ✅
不匹配右侧 h3 ❌ 不匹配左侧 h3 ❌
一句话总结:每个组件拥有自己专属的"身份印章"(data-v-xxx),CSS 选择器只认盖了自己印章的标签。不同组件的印章不同,样式互不干扰——从根源上隔绝了样式污染。
3. 补充特点
scoped 隔离仅对当前组件模板内的标签生效。有一个特殊规则需要注意:
- 子组件的根节点:会继承父组件的
data-v-xxx属性,所以父组件的 scoped 样式能在子组件根节点上生效(常用于调整子组件的外边距、定位等)。 - 子组件内部 DOM(根节点以下的所有元素):不会继承父级哈希属性,父组件的 scoped 样式无法穿透到子组件内部。
<!-- 父组件模板编译后 -->
<div data-v-parent123>
<ChildComponent data-v-parent123 /> ← 子组件根节点有父级哈希
<!-- 但 ChildComponent 内部的 <p>、<span> 都没有 data-v-parent123 -->
</div>
二、组件通信总览
组件通信的本质:Vue 各组件各自独立封闭,组件 A 的数据组件 B 天然无法直接访问(就像你的钥匙打不开邻居的门)。因此必须依靠 Vue 提供的指定方案来实现跨组件数据传递。
组件关系与对应通信方案:
| 组件关系 | 通信方案 | 说明 |
|---|---|---|
| 父传子(核心重点) | props 自定义属性 | 最基础的通信方式 |
| 子传父(核心重点) | defineEmits 自定义事件 | 子通知父修改数据 |
| 隔代祖先与后代 | provide + inject | 跨越多层中间组件 |
| 兄弟 / 无关联跨组件 | EventBus 事件总线 | 借助公共模块中转 |
| 大型项目全局跨组件 | Pinia 状态管理库 | 推荐用于复杂项目 |
本文重点覆盖前四种方案,Pinia 作为大型项目终极方案,后续会单独深入学习。
如何判定父子组件? 规则非常简单——在 A 组件的模板中(<template>),以自定义标签的形式引入 B 组件,则:A 为父组件,B 为子组件。
<!-- App.vue -->
<template>
<MyGoods /> <!-- App 是父,MyGoods 是子 -->
</template>
三、父传子(Props)详解
1. 使用场景
子组件的内容不能写死为固定数据。比如一个商品卡片组件,商品名称、价格、图片每次都不同——这些数据需要由父组件动态传入,这样才能做到"一个组件,复用 N 次",提升组件的复用性与灵活性。
2. 两步流程(口诀:子接父传)
第一步:父组件(提供数据)——在子组件标签上,通过 :自定义属性名="父组件数据" 绑定动态属性,也就是 props。
第二步:子组件(接收数据)——在 <script setup> 中使用 defineProps() 显式声明接收的属性,接收后才可在模板与逻辑中使用。
完整代码示例——商品卡片:
父组件 App.vue:
<script setup>
// 导入 MyGoods 商品组件
import MyGoods from './components/MyGoods.vue'
// 父组件提供数据
const goodsObj = {
id: '3998274',
name: '德国百年工艺高端水晶玻璃红酒杯2支装',
price: 139,
picture: 'https://yanxuan-item.nosdn.127.net/8896b897b3ec6639bbd1134d66b9715c.jpg'
}
</script>
<template>
<div class="list">
<!-- ★ 父传:子组件标签上通过 :自定义属性 绑定数据 -->
<MyGoods
:imgUrl="goodsObj.picture"
:title="goodsObj.name"
:price="goodsObj.price"
/>
</div>
</template>
<style lang="scss">
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.list {
width: 1000px;
margin: 0 auto;
display: flex;
flex-wrap: wrap;
}
</style>
子组件 MyGoods.vue:
<script setup>
// ★ 子接:defineProps() 声明接收的属性,之后即可在模板中使用
defineProps(['imgUrl', 'title', 'price'])
</script>
<template>
<div class="item">
<img :src="imgUrl" />
<p class="name">{{ title }}</p>
<p class="price">{{ price }}.00</p>
</div>
</template>
<style scoped lang="scss">
.item {
width: 240px;
margin-left: 10px;
padding: 20px 30px;
transition: all 0.5s;
margin-bottom: 20px;
// 第 4n 个清除左边距
.item:nth-child(4n) {
margin-left: 0;
}
&:hover {
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.2);
transform: translate3d(0, -4px, 0);
cursor: pointer;
}
img {
width: 100%;
}
.name {
font-size: 18px;
margin-bottom: 10px;
color: #666;
}
.price {
font-size: 22px;
color: firebrick;
&::before {
content: '¥';
font-size: 22px;
}
}
}
</style>
配合 v-for 循环渲染列表是 Props 最经典的实战场景。父组件维护一个商品列表数组,通过 v-for 循环将每一项数据逐个传给子组件:
<script setup>
import MyGoods from './components/MyGoods.vue'
// 商品列表 — 8 件商品
const goodsList = [
{ id: '4001172', name: '称心如意手摇咖啡磨豆机咖啡豆研磨机', price: 289,
picture: 'https://yanxuan-item.nosdn.127.net/84a59ff9c58a77032564e61f716846d6.jpg' },
{ id: '4001594', name: '日式黑陶功夫茶组双侧把茶具礼盒装', price: 288,
picture: 'https://yanxuan-item.nosdn.127.net/3346b7b92f9563c7a7e24c7ead883f18.jpg' },
{ id: '4001009', name: '竹制干泡茶盘正方形沥水茶台品茶盘', price: 109,
picture: 'https://yanxuan-item.nosdn.127.net/2d942d6bc94f1e230763e1a5a3b379e1.png' },
{ id: '4001874', name: '古法温酒汝瓷酒具套装白酒杯莲花温酒器', price: 488,
picture: 'https://yanxuan-item.nosdn.127.net/44e51622800e4fceb6bee8e616da85fd.png' },
{ id: '4001649', name: '大师监制龙泉青瓷茶叶罐', price: 139,
picture: 'https://yanxuan-item.nosdn.127.net/4356c9fc150753775fe56b465314f1eb.png' },
{ id: '3997185', name: '与众不同的口感汝瓷白酒杯套组1壶4杯', price: 108,
picture: 'https://yanxuan-item.nosdn.127.net/8e21c794dfd3a4e8573273ddae50bce2.jpg' },
{ id: '3997403', name: '手工吹制更厚实白酒杯壶套装6壶6杯', price: 100,
picture: 'https://yanxuan-item.nosdn.127.net/af2371a65f60bce152a61fc22745ff3f.jpg' },
{ id: '3998274', name: '德国百年工艺高端水晶玻璃红酒杯2支装', price: 139,
picture: 'https://yanxuan-item.nosdn.127.net/8896b897b3ec6639bbd1134d66b9715c.jpg' }
]
</script>
<template>
<div class="list">
<!-- 自定义组件配合 v-for 循环使用,每次迭代把 item 字段传给子组件 -->
<MyGoods
v-for="item in goodsList"
:key="item.id"
:imgUrl="item.picture"
:title="item.name"
:price="item.price"
/>
</div>
</template>
运行效果:1000px 宽容器内,8 张商品卡片自动排列(每行 4 个,共 2 行),每张卡片独立显示各自的图片、名称和价格。
3. 关键特性:单向数据流
Props 属于只读数据:数据的"所有权"属于父组件,子组件只能读取展示,不允许直接修改 props 变量的值。如果子组件尝试 props.price = 200,Vue 会在控制台抛出警告:
[Vue warn] Set operation on key "price" failed: target is readonly.
设计理念:数据只有一个入口(父组件传入)和一个出口(父组件修改)。如果允许子组件随意修改父组件的数据,数据流向将变得不可追踪,调试成本会爆炸式增长。这是 Vue 单向数据流的核心约束。
四、子传父(自定义事件 emit)详解
1. 使用场景
子组件需要"反向"修改父组件的数据。但 Props 是只读的,子组件不能直接动手改。于是通过自定义事件,子组件通知父组件:“数据该改了,你来执行修改逻辑”。
2. 两步流程(口诀:父监听,子触发)
第一步:父组件(监听事件)——在子组件标签上绑定自定义事件:@自定义事件名="父组件处理函数"。
第二步:子组件(触发事件)——使用 defineEmits 声明自定义事件,在合适时机调用 emit('事件名', 需要回传给父组件的参数),触发父组件绑定的函数,同时携带数据。
举例场景——砍价功能:子组件有一个"砍一刀"按钮,点击后需要减少父组件中对应商品的价格。Props 中的价格是只读的,子组件不能直接改,于是触发 cutPrice 事件通知父组件去改。
父组件 App.vue:
<script setup>
import { ref } from 'vue'
import MyGoods from './components/MyGoods.vue'
// 商品列表
const goodsList = ref([
{ id: '4001172', name: '称心如意手摇咖啡磨豆机咖啡豆研磨机', price: 289,
picture: 'https://yanxuan-item.nosdn.127.net/84a59ff9c58a77032564e61f716846d6.jpg' },
{ id: '4001594', name: '日式黑陶功夫茶组双侧把茶具礼盒装', price: 288,
picture: 'https://yanxuan-item.nosdn.127.net/3346b7b92f9563c7a7e24c7ead883f18.jpg' },
// ... 共 8 件商品
{ id: '3998274', name: '德国百年工艺高端水晶玻璃红酒杯2支装', price: 139,
picture: 'https://yanxuan-item.nosdn.127.net/8896b897b3ec6639bbd1134d66b9715c.jpg' }
])
// i: 下标,指定让哪个对象的 price 减少
// price: 每次减少的价格
const subPrice = (i, price) => {
goodsList.value[i].price -= price
}
</script>
<template>
<div class="list">
<MyGoods
v-for="(item, index) in goodsList"
:key="item.id"
:imgUrl="item.picture"
:title="item.name"
:price="item.price"
:idx="index"
@ccc="subPrice" <!-- ★ 父监听:绑定自定义事件 @ccc -->
/>
</div>
</template>
子组件 MyGoods.vue:
<script setup>
import { ref } from 'vue'
// 子传父的语法(口诀):2 步
// 1. 父组件内,子组件的自定义标签上,绑定自定义事件,@自定义事件="父修改数据的函数"(父监听/绑定)
// 2. 子组件在恰当时机,触发自定义事件,导致父组件修改数据函数的执行,
// emit('自定义事件', 携带的参数...) (子触发/通知)
const props = defineProps(['imgUrl', 'title', 'price', 'idx'])
// 拿到触发自定义事件的函数 emit
const emit = defineEmits()
// 子组件自己的数据:每次砍价减少的金额
const x = ref(3)
// 点击子组件砍价按钮
const onCut = () => {
// ★ 子触发:emit('事件名', 回传给父组件的参数...)
emit('ccc', props.idx, x.value)
}
</script>
<template>
<div class="item">
<img :src="imgUrl" />
<p class="name">{{ title }}</p>
<p class="price">
<span>{{ price }}.00</span>
<button @click="onCut">砍一刀</button>
</p>
</div>
</template>
<style scoped lang="scss">
.item {
width: 240px;
margin-left: 10px;
padding: 20px 30px;
transition: all 0.5s;
margin-bottom: 20px;
.item:nth-child(4n) {
margin-left: 0;
}
&:hover {
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.2);
transform: translate3d(0, -4px, 0);
cursor: pointer;
}
img {
width: 100%;
}
.name {
font-size: 18px;
margin-bottom: 10px;
color: #666;
}
.price {
display: flex;
align-items: center;
height: 36px;
font-size: 22px;
color: firebrick;
button {
margin-left: 50px;
font-size: 14px;
}
&::before {
content: '¥';
font-size: 22px;
}
}
}
</style>
数据流追踪:
用户点击子组件 [砍一刀]
│
▼
子组件 onCut() 执行
│
▼
emit('ccc', props.idx, x.value) ← 携带参数:商品下标 + 砍价金额
│
▼
父组件 @ccc="subPrice" 监听到事件
│
▼
subPrice(商品下标, 砍价金额) 执行
│
▼
goodsList.value[i].price -= 砍价金额 ← 父组件修改自己的数据
│
▼
响应式更新 → 页面价格自动刷新 ✅
核心理解:子组件不做修改,只做"通知"——它告诉父组件"有人对第 N 个商品砍了 M 元"。父组件收到通知后,自己动手修改自己的数据。整个过程中,数据的所有权始终在父组件手里。
五、Props 校验
1. 目的
约束父组件传入数据的类型、必填项、默认值、自定义规则。当父组件传错参数时,控制台会给出明确警告,保障代码健壮性,把 Bug 扼杀在开发阶段。
2. 两种写法
① 简易写法(仅约束类型)
const props = defineProps({
opacity: Number,
list: Array
})
多个合法类型可以写成数组:
opacity: [Number, String]
② 完整高阶写法(四项配置)
const props = defineProps({
opacity: {
type: Number, // ① 限定数据类型
default: 0.5, // ② 不传时的默认值
required: true, // ③ 是否必填
validator(value) { // ④ 自定义校验函数,返回布尔值
return value >= 0 && value <= 1
}
}
})
⚠️ 注意:
default和required互斥,二选一使用。如果设了required: true,说明父组件一定会传值,默认值就没有意义了。
另外,引用类型(数组/对象)的 default 必须写成函数返回的形式,否则所有组件实例会共享同一个引用:
// ❌ 错误 — 所有实例共享同一个数组引用
list: {
type: Array,
default: []
}
// ✅ 正确 — 每个实例拿到独立的新数组
list: {
type: Array,
default: () => []
}
// ✅ 对象同理
obj: {
type: Object,
default: () => ({})
}
六、props 与组件内部 ref/reactive 的核心区别
很多初学者会把 Props 和组件内部的 ref/reactive 混为一谈。它们虽然都是响应式数据,但本质完全不同:
| 维度 | props 数据 | ref / reactive 组件内部响应式数据 |
|---|---|---|
| 数据来源 | 父组件传入,外部数据 | 组件内部自己声明创建 |
| 读写权限 | 只读,子组件不可直接修改 | 可读可写,组件完全自主控制 |
| 数据流 | 严格单向:仅父 → 子 | 完全内部闭环,不受单向数据流限制 |
核心总结一句话:谁的数据,谁负责。
- 父组件的数据,只能由父组件自己修改。子组件想改?只能 emit 通知父组件。
- 组件自身创建的数据(ref / reactive),自己想怎么改就怎么改,完全自主控制。
<script setup>
import { ref } from 'vue'
// props — 父组件的数据,只读
const props = defineProps(['price'])
// props.price = 100 ← ❌ 报错!不能改
// ref — 组件自己的数据,随便改
const cutAmount = ref(3)
cutAmount.value = 5 // ← ✅ 可以改,数据是我自己创建的
</script>
七、非父子组件通信方案
1. 祖先组件 → 深层后代组件:provide / inject
使用场景:数据要从"爷爷"组件直接传给"孙子"组件,中间隔着一层或多层父组件。如果用 Props,每一层都要手动传递,中间的父组件被迫接收自己不需要的数据——这叫 Props 逐层透传(Prop Drilling)。
方案:
- 祖先组件:
provide('key', 要传递的数据)向外暴露数据; - 任意后代组件:
const data = inject('key')直接获取,不受层级深度限制。
完整示例——爷爷给孙子留"小目标":
祖先组件(App.vue,爷爷):
<script setup>
import { provide, ref } from 'vue'
import MyParent from './components/MyParent.vue'
// 爷爷给孙子留下的小目标
const money = ref(100000000)
// ★ 爷爷提供的数据
provide('qian', money)
</script>
<template>
<div class="app">
<h3>App组件</h3>
<MyParent />
</div>
</template>
<style scoped>
.app {
width: 400px;
padding: 20px;
background: paleturquoise;
}
</style>
中间组件(MyParent.vue,父亲):
<script setup>
import MyChild from './MyChild.vue'
</script>
<template>
<div class="my-parent">
<h3>MyParent组件</h3>
<MyChild />
</div>
</template>
<style scoped>
.my-parent {
padding: 20px;
background: plum;
}
</style>
后代组件(MyChild.vue,孙子):
<script setup>
import { inject } from 'vue'
// ★ 获取爷爷给的小目标 — 直接 inject,无需经过父组件
const money = inject('qian')
</script>
<template>
<div class="my-child">
<h3>MyChild组件</h3>
<p>收到来自爷爷的小目标 = {{ money }}</p>
</div>
</template>
<style scoped>
.my-child {
padding: 20px;
background: palegoldenrod;
}
</style>
组件层级与数据流动:
App.vue(爷爷)
│ provide('qian', 100000000)
│
└─▶ MyParent.vue(父亲)
│ ← 不需要写任何 props 传递代码!
│
└─▶ MyChild.vue(孙子)
│ inject('qian') → 100000000 ✅
│
└─ "收到来自爷爷的小目标 = 100000000"
弊端:数据响应式追踪较弱,不适合高频复杂数据场景。数据来源不如 props 直观,后续维护时需要在组件树中搜索
provide的出处。
2. 任意无关联组件(兄弟组件为主):EventBus 事件总线
原理:创建独立公共 JS 模块作为事件中间媒介,组件双方都引入。发送方 emit 发射,接收方 on 监听——双方互不感知。
使用 mitt 库(Vue 3 官方推荐,替代已废弃的 $on/$emit)。
eventBus.js:
import mitt from 'mitt'
const meipo = mitt() // 创建中间人
export default meipo
发送方 MyLeft.vue(牛郎):
<script setup>
import { ref } from 'vue'
import meipo from '../eventBus.js'
const msg = ref('两情若是长久时, 又岂在朝朝暮暮')
const word = ref('')
const onSend = () => { meipo.emit('biubiubiu', msg.value) } // ★ 发送
meipo.on('ZhaNan', (str) => { word.value = str }) // 监听回话
</script>
<template>
<div class="my-left"><h3>牛郎</h3>
<button @click="onSend">Send</button>
<p>回话={{ word }}</p></div>
</template>
接收方 MyRight.vue(织女):
<script setup>
import { ref } from 'vue'
import meipo from '../eventBus.js'
const str = ref('')
meipo.on('biubiubiu', (msg) => { // ★ 接收
str.value = msg
meipo.emit('ZhaNan', '渣男') // 回复
})
</script>
<template>
<div class="my-right"><h3>织女</h3><p>消息={{ str }}</p></div>
</template>
通信流程: 牛郎 emit('biubiubiu') → 织女 on('biubiubiu') 收到 → 织女 emit('ZhaNan') → 牛郎 on('ZhaNan') 收到。双方通过 meipo 中转,互不感知。
┌─────────────────┐ ┌─────────────────┐
│ MyLeft(牛郎) │ │ MyRight(织女) │
├─────────────────┤ ├─────────────────┤
│ │ emit('biubiubiu', msg) │
│ 点击 Send ──────┼──────┐ ┌─────┼── on('biubiubiu')
│ │ │ │ │ 收到消息 │
│ │ ▼ ▼ │ │
│ │ ┌──────────┐ │ │
│ │ │ meipo │ │ │
│ │ │ (EventBus)│ │ │
│ │ └──────────┘ │ │
│ │ │ │ │ │
│ on('ZhaNan') ◀──┼─────┘ └─────┼── emit('ZhaNan','渣男')
│ 收到回话 ✅ │ │ 回复消息 │
└─────────────────┘ └─────────────────┘
适合中小型简单跨组件通信。大型项目推荐 Pinia 全局状态库,因为 EventBus 在复杂场景下会变成"事件地狱"——事件名满天飞,数据流向难以追踪和调试。
全文总结
本文严格按"样式隔离 → 组件关系 → 父子通信 → 数据校验 → 跨级/兄弟通信"的递进路径,覆盖了 Vue 3 组件化开发的核心知识体系:
- scoped 样式隔离:通过编译时给 DOM 添加唯一
data-v-xxx属性 + CSS 选择器自动拼接属性选择器,从根源上解决样式污染。理解这个机制是排查样式相关 Bug 的基础。 - Props 父传子:组件复用的基石,遵循"子接父传"两步走 + 单向数据流铁律,配合
v-for实现列表循环渲染。 - Emit 子传父:突破 Props 只读限制的正确途径,本质是"通知"而非"修改",数据所有权始终在父组件手中。
- Props 校验:从简易类型约束到完整四件套配置(type / default / required / validator),是代码健壮性的重要防线。引用类型的
default必须用函数返回。 - Provide / Inject:解决深层后代数据传递的利器,一次 provide,所有后代直接 inject,不受层级限制。
- EventBus(mitt):兄弟组件/无关联组件通信的轻量方案,通过公共 JS 模块作为事件中转站,
emit发送、on接收。
核心知识点复盘
| 知识点 | 核心要点 |
|---|---|
| scoped 原理 | 编译时:① 每个 DOM 标签加 data-v-hash 属性;② CSS 选择器拼接 [data-v-hash],形成交集选择器,样式只匹配本组件 |
| scoped 渗透规则 | 子组件根节点会继承父级哈希(父 scoped 样式可影响子组件根节点),但子组件内部 DOM 不会继承 |
| 父子组件判定 | A 组件的 <template> 中以自定义标签引入 B → A 是父,B 是子 |
| 父传子(Props) | 父::自定义属性="数据" 绑定;子:defineProps(['属性名']) 接收;口诀"父传子接" |
| 单向数据流 | Props 只读,子组件不能直接修改;数据所有权属于父组件,修改只能由父组件执行 |
| 子传父(Emit) | 父:@自定义事件="处理函数" 监听;子:defineEmits() + emit('事件', 参数) 触发;口诀"父监听子触发" |
| Props 简易校验 | defineProps({ opacity: Number }),仅约束类型;多类型用数组 [Number, String] |
| Props 完整校验 | 四项:type(类型)、default(默认值)、required(必填)、validator(value)(自定义校验,返回布尔值) |
| 引用类型 default 坑 | 数组/对象默认值必须用函数 default: () => [],否则所有实例共享同一引用导致互相影响 |
| Provide / Inject | 祖先 provide('key', data),后代 inject('key') 获取;跨多层,中间组件无需参与 |
| EventBus(mitt) | 独立 JS 公共模块作为事件中间人;emit 发送、on 接收;适合中小型跨组件通信,大型项目推荐 Pinia |
| Props vs ref/reactive | Props = 外部传入只读,ref/reactive = 内部创建可读写;核心原则"谁的数据谁负责" |
常见问题 / 避坑指南
Q1:子组件不小心修改了 Props 中的对象属性,为什么有时不报错?
当你写 props.obj.name = 'xxx' 时,修改的是对象内部属性,不是 props 引用本身,Vue 可能不会立即警告。但这依然是错误做法,会破坏单向数据流,埋下难以追踪的 Bug。正确做法永远是 emit 通知父组件修改。
Q2:defineProps 和 defineEmits 需要 import 吗?
不需要。这两个是 Vue 的编译宏(Compiler Macro),在 <script setup> 中可以直接使用,Vue 编译器自动处理。手动 import 反而会报错。
Q3:EventBus 中组件销毁后,监听器还在吗?
依然在内存中!需要手动在组件卸载时调用 meipo.off('事件名', 处理函数) 取消监听,否则可能导致内存泄漏。使用 onUnmounted 钩子处理:
import { onUnmounted } from 'vue'
const handler = (data) => { /* ... */ }
meipo.on('someEvent', handler)
onUnmounted(() => {
meipo.off('someEvent', handler)
})
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/suger__salt/article/details/163279406



