8、Vue 组件通信完整指南(Vue2 vs Vue3 深度对比)
目录
6. attrs/attrs/listeners(属性透传)
一、父子组件通信(6种方式)
1. Props(父 → 子)
Vue2 实现
// 父组件
<template>
<Child :user="userInfo" :count="10" />
</template>
<script>
export default {
data() {
return {
userInfo: { name: 'Alice', age: 25 }
}
}
}
</script>
// 子组件
<script>
export default {
props: {
user: {
type: Object,
required: true,
default: () => ({})
},
count: {
type: Number,
validator: (val) => val >= 0
}
},
mounted() {
console.log(this.user.name) // 通过 this 访问
}
}
</script>
Vue3 实现
// Options API(与 Vue2 几乎相同)
export default {
props: {
user: Object,
count: Number
}
}
<!-- Composition API(<script setup>) -->
<script setup>
// 方式1:运行时声明
const props = defineProps({
user: {
type: Object,
required: true
},
count: Number
})
// 方式2:TypeScript 类型声明(推荐)
const props = defineProps<{
user: { name: string; age: number }
count?: number
}>()
// 方式3:带默认值的 TS 声明
interface Props {
user: User
count?: number
}
const props = withDefaults(defineProps<Props>(), {
count: 0
})
// 访问 props
console.log(props.user.name) // 不需要 this
</script>
关键差异:
- Vue3 的
defineProps是编译时宏,无需 import <script setup>中无this,直接用props.xxx- TypeScript 支持更强大
2. Emit(子 → 父)
Vue2 实现
// 子组件
<template>
<button @click="handleClick">提交</button>
</template>
<script>
export default {
methods: {
handleClick() {
// 触发自定义事件
this.$emit('submit', { id: 1, data: 'test' })
this.$emit('update:visible', false)
}
}
}
</script>
// 父组件
<template>
<Child @submit="onSubmit" @update:visible="visible = $event" />
</template>
<script>
export default {
methods: {
onSubmit(payload) {
console.log(payload)
}
}
}
</script>
Vue3 实现
<!-- 子组件 -->
<script setup>
// 方式1:数组声明
const emit = defineEmits(['submit', 'update:visible'])
// 方式2:对象声明(带校验)
const emit = defineEmits({
submit: (payload) => {
return payload.id > 0 // 返回 false 会警告
},
'update:visible': null
})
// 方式3:TypeScript 声明
const emit = defineEmits<{
(e: 'submit', payload: { id: number; data: string }): void
(e: 'update:visible', value: boolean): void
}>()
const handleClick = () => {
emit('submit', { id: 1, data: 'test' })
}
</script>
关键差异:
- Vue3 强制要求声明 emits(开发模式会警告)
defineEmits返回 emit 函数,不再用this.$emit- 支持 TypeScript 类型约束
3. v-model(双向绑定)⭐ 变化最大
Vue2 实现
// 默认 v-model(绑定 value + input)
// 父组件
<CustomInput v-model="searchText" />
// 等价于
<CustomInput :value="searchText" @input="searchText = $event" />
// 子组件
export default {
props: ['value'],
methods: {
handleInput(e) {
this.$emit('input', e.target.value)
}
}
}
// 自定义 v-model
export default {
model: {
prop: 'checked',
event: 'change'
},
props: ['checked'],
methods: {
toggle() {
this.$emit('change', !this.checked)
}
}
}
<!-- 多个双向绑定用 .sync -->
<Dialog :visible.sync="show" :title.sync="dialogTitle" />
<!-- 等价于 -->
<Dialog
:visible="show"
@update:visible="show = $event"
:title="dialogTitle"
@update:title="dialogTitle = $event"
/>
Vue3 实现
<!-- 默认 v-model(绑定 modelValue) -->
<!-- 父组件 -->
<CustomInput v-model="searchText" />
<!-- 等价于 -->
<CustomInput
:modelValue="searchText"
@update:modelValue="searchText = $event"
/>
<!-- 子组件 -->
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
const handleInput = (e) => {
emit('update:modelValue', e.target.value)
}
</script>
<!-- 多个 v-model(取代 .sync) -->
<!-- 父组件 -->
<Dialog v-model:visible="show" v-model:title="dialogTitle" />
<!-- 子组件 -->
<script setup>
defineProps(['visible', 'title'])
const emit = defineEmits(['update:visible', 'update:title'])
const close = () => emit('update:visible', false)
const changeTitle = (val) => emit('update:title', val)
</script>
<!-- 自定义修饰符 -->
<CustomInput v-model.capitalize="text" />
<script setup>
const props = defineProps({
modelValue: String,
modelModifiers: { default: () => ({}) }
})
const emit = defineEmits(['update:modelValue'])
const emitValue = (val) => {
if (props.modelModifiers.capitalize) {
val = val.charAt(0).toUpperCase() + val.slice(1)
}
emit('update:modelValue', val)
}
</script>
对比表格:
| 特性 | Vue2 | Vue3 |
|---|---|---|
| 默认 prop | value |
modelValue |
| 默认事件 | input |
update:modelValue |
| 自定义 | model 选项 |
具名 v-model |
| 多个绑定 | .sync 修饰符 |
v-model:xxx |
| 自定义修饰符 | ❌ 不支持 | ✅ 支持 |
4. $refs(获取子组件实例)
Vue2 实现
<!-- 父组件 -->
<template>
<Child ref="childRef" />
<button @click="callChild">调用子组件方法</button>
</template>
<script>
export default {
methods: {
callChild() {
// 可以访问子组件所有数据和方法
this.$refs.childRef.childMethod()
console.log(this.$refs.childRef.childData)
}
}
}
</script>
// 子组件
export default {
data() {
return {
childData: 'secret'
}
},
methods: {
childMethod() {
console.log('child method called')
}
}
}
Vue3 实现
<!-- 父组件 Options API -->
<template>
<Child ref="childRef" />
</template>
<script>
export default {
mounted() {
this.$refs.childRef.exposedMethod()
}
}
</script>
<!-- 父组件 Composition API -->
<template>
<Child ref="childRef" />
</template>
<script setup>
import { ref, onMounted } from 'vue'
const childRef = ref(null)
onMounted(() => {
childRef.value?.exposedMethod()
})
</script>
<!-- 子组件(<script setup>) -->
<script setup>
import { ref } from 'vue'
const privateData = ref('不能被访问')
const publicData = ref('可以被访问')
const privateMethod = () => {
console.log('私有方法')
}
const publicMethod = () => {
console.log('公开方法')
}
// ⚠️ 必须显式暴露
defineExpose({
publicData,
publicMethod
})
</script>
关键差异:
- Vue2:父组件可访问子组件所有属性和方法
- Vue3
<script setup>:默认完全封闭,必须用defineExpose暴露 - 这是 Vue3 的重大设计变更,强化组件封装性
5. parent/parent/children
Vue2 实现
// 子组件访问父组件
this.$parent.parentMethod()
this.$parent.parentData
// 父组件访问子组件
this.$children[0].childMethod()
this.$children.forEach(child => {
child.reset()
})
Vue3 实现
// $parent 仍可用(但不推荐)
this.$parent?.someMethod()
// ⚠️ $children 已完全移除
// 替代方案:
// 1. 使用 ref
// 2. 使用 provide/inject
// 3. 使用状态管理
迁移建议:
<!-- Vue2 代码 -->
<script>
export default {
methods: {
resetAllChildren() {
this.$children.forEach(child => child.reset())
}
}
}
</script>
<!-- Vue3 替代方案 -->
<template>
<Child ref="child1" />
<Child ref="child2" />
</template>
<script setup>
import { ref } from 'vue'
const child1 = ref(null)
const child2 = ref(null)
const resetAllChildren = () => {
child1.value?.reset()
child2.value?.reset()
}
</script>
6. attrs/attrs/listeners(属性透传)
Vue2 实现
<!-- 二次封装 Element UI 组件 -->
<template>
<el-input
v-bind="$attrs"
v-on="$listeners"
:value="value"
@input="handleInput"
/>
</template>
<script>
export default {
inheritAttrs: false, // 禁止自动绑定到根元素
props: ['value'],
computed: {
// $attrs: 未被 props 接收的属性
// $listeners: 父组件绑定的事件监听器
}
}
</script>
Vue3 实现
<!-- $listeners 已移除,事件合并到 $attrs -->
<template>
<el-input
v-bind="$attrs"
:modelValue="modelValue"
@update:modelValue="$emit('update:modelValue', $event)"
/>
</template>
<script setup>
import { useAttrs } from 'vue'
defineOptions({
inheritAttrs: false
})
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
// Composition API 中获取 attrs
const attrs = useAttrs()
console.log(attrs) // 包含属性和事件
</script>
对比:
| 项目 | Vue2 | Vue3 |
|---|---|---|
| 非 props 属性 | $attrs |
$attrs |
| 事件监听器 | $listeners |
合并到 $attrs |
| 透传写法 | v-bind="$attrs" v-on="$listeners" |
v-bind="$attrs" |
| Composition API | ❌ | useAttrs() |
二、兄弟/平级组件通信(3种方式)
7. 共同父组件中转
<!-- 父组件 -->
<template>
<ChildA @data-change="handleDataChange" />
<ChildB :shared-data="sharedData" />
</template>
<script setup>
import { ref } from 'vue'
const sharedData = ref(null)
const handleDataChange = (data) => {
sharedData.value = data
}
</script>
8. EventBus(事件总线)
Vue2 实现
// event-bus.js
import Vue from 'vue'
export const EventBus = new Vue()
// 或挂载到原型
Vue.prototype.$bus = new Vue()
// 组件 A(发送)
import { EventBus } from './event-bus'
export default {
methods: {
sendMessage() {
EventBus.$emit('message', { text: 'Hello' })
}
}
}
// 组件 B(接收)
import { EventBus } from './event-bus'
export default {
created() {
EventBus.$on('message', this.handleMessage)
},
beforeDestroy() {
EventBus.$off('message', this.handleMessage) // ⚠️ 必须手动销毁
},
methods: {
handleMessage(payload) {
console.log(payload.text)
}
}
}
Vue3 实现
// ⚠️ Vue3 移除了实例的 $on、$off、$once 方法
// 方案1:使用 mitt 库
// npm install mitt
// event-bus.js
import mitt from 'mitt'
export const emitter = mitt()
<!-- 组件 A -->
<script setup>
import { emitter } from './event-bus'
const sendMessage = () => {
emitter.emit('message', { text: 'Hello' })
}
</script>
<!-- 组件 B -->
<script setup>
import { onMounted, onUnmounted } from 'vue'
import { emitter } from './event-bus'
const handleMessage = (payload) => {
console.log(payload.text)
}
onMounted(() => {
emitter.on('message', handleMessage)
})
onUnmounted(() => {
emitter.off('message', handleMessage)
})
</script>
// 方案2:使用 tiny-emitter
import { TinyEmitter } from 'tiny-emitter'
export const emitter = new TinyEmitter()
为什么 Vue3 移除了 EventBus?
- 容易造成隐式依赖,难以追踪数据流
- 忘记销毁监听器导致内存泄漏
- 官方推荐用 Pinia 或 Provide/Inject
9. Vuex / Pinia(状态管理)
见后文"全局通信"部分
三、跨层级组件通信(2种方式)
10. Provide / Inject
Vue2 实现
// 祖先组件
export default {
provide() {
return {
theme: this.theme, // ⚠️ 非响应式
themeObject: this.themeObj, // 对象引用可保持响应式
getTheme: () => this.theme // 通过函数保持响应式
}
},
data() {
return {
theme: 'dark',
themeObj: { color: 'dark' }
}
}
}
// 后代组件
export default {
inject: ['theme', 'themeObject'],
mounted() {
console.log(this.theme)
}
}
Vue2 响应式问题:
// ❌ 错误:provide 的基本类型不是响应式
provide() {
return {
count: this.count // 后代拿到的是初始值,不会更新
}
}
// ✅ 正确:提供整个响应式对象
provide() {
return {
state: this.$data // 或者 Vue.observable({...})
}
}
Vue3 实现
<!-- 祖先组件 -->
<script setup>
import { provide, ref, readonly, reactive } from 'vue'
// 方式1:提供响应式数据
const theme = ref('dark')
const config = reactive({ size: 'large' })
provide('theme', readonly(theme)) // 只读保护
provide('config', config)
// 方式2:提供修改方法
const updateTheme = (val) => {
theme.value = val
}
provide('updateTheme', updateTheme)
// 方式3:使用 Symbol 作为 key(避免冲突)
const ThemeKey = Symbol()
provide(ThemeKey, theme)
</script>
<!-- 后代组件 -->
<script setup>
import { inject } from 'vue'
// 基本用法
const theme = inject('theme')
const updateTheme = inject('updateTheme')
// 带默认值
const theme = inject('theme', 'light')
// 带默认值工厂函数
const config = inject('config', () => ({ size: 'small' }), true)
// 使用 Symbol key
const ThemeKey = Symbol()
const theme = inject(ThemeKey)
</script>
TypeScript 类型支持:
// types.ts
import type { InjectionKey, Ref } from 'vue'
export interface Theme {
color: string
mode: 'light' | 'dark'
}
export const ThemeKey: InjectionKey<Ref<Theme>> = Symbol()
<!-- 祖先组件 -->
<script setup lang="ts">
import { provide, ref } from 'vue'
import { ThemeKey } from './types'
const theme = ref<Theme>({ color: '#fff', mode: 'light' })
provide(ThemeKey, theme)
</script>
<!-- 后代组件 -->
<script setup lang="ts">
import { inject } from 'vue'
import { ThemeKey } from './types'
const theme = inject(ThemeKey) // 自动推导类型
</script>
对比表格:
| 特性 | Vue2 | Vue3 |
|---|---|---|
| 响应式 | 需手动处理 | 配合 ref/reactive 天然响应式 |
| 只读保护 | ❌ | readonly() |
| TypeScript | 弱类型 | InjectionKey 强类型 |
| 默认值 | 不支持 | 支持 |
| API 风格 | Options API | Composition API |
11. $root(访问根实例)
// Vue2
this.$root.globalMethod()
this.$root.globalData
// Vue3(仍可用但不推荐)
// 推荐用 provide/inject 或 Pinia
四、全局通信(2种方式)
12. Vuex(Vue2 主流)
// store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
count: 0,
user: null
},
getters: {
doubleCount: state => state.count * 2,
isLogin: state => !!state.user
},
mutations: {
INCREMENT(state, payload) {
state.count += payload
},
SET_USER(state, user) {
state.user = user
}
},
actions: {
async login({ commit }, credentials) {
const user = await api.login(credentials)
commit('SET_USER', user)
},
increment({ commit }, amount) {
commit('INCREMENT', amount)
}
},
modules: {
cart: {
namespaced: true,
state: () => ({ items: [] }),
mutations: { ... },
actions: { ... }
}
}
})
// 组件中使用
export default {
computed: {
...mapState(['count', 'user']),
...mapGetters(['doubleCount', 'isLogin']),
...mapState('cart', ['items'])
},
methods: {
...mapMutations(['INCREMENT']),
...mapActions(['login']),
...mapActions('cart', ['addItem']),
handleClick() {
this.$store.commit('INCREMENT', 1)
this.$store.dispatch('login', { username, password })
this.$store.state.count
this.$store.getters.doubleCount
}
}
}
13. Pinia(Vue3 官方推荐)
// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// 方式1:Options Store(类似 Vuex)
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
user: null
}),
getters: {
doubleCount: (state) => state.count * 2,
isLogin: (state) => !!state.user
},
actions: {
increment(amount) {
this.count += amount // 直接修改,无需 mutation
},
async login(credentials) {
this.user = await api.login(credentials)
}
}
})
// 方式2:Setup Store(推荐,更灵活)
export const useCounterStore = defineStore('counter', () => {
// state
const count = ref(0)
const user = ref(null)
// getters
const doubleCount = computed(() => count.value * 2)
const isLogin = computed(() => !!user.value)
// actions
function increment(amount) {
count.value += amount
}
async function login(credentials) {
user.value = await api.login(credentials)
}
return {
count,
user,
doubleCount,
isLogin,
increment,
login
}
})
<!-- 组件中使用 -->
<script setup>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'
const counter = useCounterStore()
// ❌ 错误:解构会失去响应式
const { count, doubleCount } = counter
// ✅ 正确:使用 storeToRefs
const { count, doubleCount } = storeToRefs(counter)
// actions 可以直接解构
const { increment, login } = counter
// 使用
counter.count++
counter.increment(5)
counter.$patch({ count: 10 })
counter.$patch((state) => {
state.count++
state.user = null
})
counter.$reset() // 重置到初始状态
</script>
Vuex vs Pinia 对比:
| 特性 | Vuex | Pinia |
|---|---|---|
| Mutations | 必须 | ❌ 移除 |
| Actions | 异步操作 | 同步/异步都可 |
| 模块化 | modules 嵌套 |
多个独立 store |
| TypeScript | 需手动配置 | 原生支持 |
| DevTools | 支持 | 支持 |
| 体积 | ~2.5KB | ~1KB |
| 学习曲线 | 较陡 | 平缓 |
| 代码量 | 较多 | 更少 |
五、特殊场景通信(3种方式)
14. Slot(插槽通信)
默认插槽
<!-- 父组件 -->
<template>
<Card>
<p>这是插槽内容</p>
</Card>
</template>
<!-- 子组件 -->
<template>
<div class="card">
<slot></slot> <!-- 渲染父组件传入的内容 -->
</div>
</template>
具名插槽
<!-- 父组件 -->
<template>
<Layout>
<template #header>
<h1>标题</h1>
</template>
<template #default>
<p>主要内容</p>
</template>
<template #footer>
<p>页脚</p>
</template>
</Layout>
</template>
<!-- 子组件 -->
<template>
<div class="layout">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot> <!-- 默认插槽 -->
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>
</template>
作用域插槽(子传父数据)
<!-- 子组件 -->
<template>
<ul>
<li v-for="item in items" :key="item.id">
<slot :item="item" :index="index"></slot>
</li>
</ul>
</template>
<script setup>
defineProps(['items'])
</script>
<!-- 父组件 Vue2 -->
<template>
<List :items="users">
<template slot-scope="{ item, index }">
{{ index }}: {{ item.name }}
</template>
</List>
</template>
<!-- 父组件 Vue3 -->
<template>
<List :items="users">
<template #default="{ item, index }">
{{ index }}: {{ item.name }}
</template>
</List>
</template>
Vue2 vs Vue3 插槽语法:
| 特性 | Vue2 | Vue3 |
|---|---|---|
| 具名插槽 | slot="header" |
#header 或 v-slot:header |
| 作用域插槽 | slot-scope="props" |
#default="props" |
| 废弃语法 | slot-scope |
❌ 移除 |
15. Teleport(Vue3 新增)
<!-- 将内容传送到 DOM 的其他位置 -->
<template>
<button @click="open = true">打开弹窗</button>
<Teleport to="body">
<div v-if="open" class="modal">
<p>我被渲染到 body 下</p>
<button @click="open = false">关闭</button>
</div>
</Teleport>
</template>
<script setup>
import { ref } from 'vue'
const open = ref(false)
</script>
应用场景:
- 模态框
- 通知提示
- 全屏遮罩
- 避免 z-index 和 overflow 问题
16. 动态组件通信
<template>
<component
:is="currentComponent"
v-bind="componentProps"
@custom-event="handleEvent"
/>
</template>
<script setup>
import { ref, computed } from 'vue'
import ComponentA from './ComponentA.vue'
import ComponentB from './ComponentB.vue'
const currentTab = ref('a')
const currentComponent = computed(() => {
return currentTab.value === 'a' ? ComponentA : ComponentB
})
const componentProps = computed(() => {
return currentTab.value === 'a'
? { propA: 'valueA' }
: { propB: 'valueB' }
})
</script>
六、完整对比总结表
| 通信方式 | 适用场景 | Vue2 | Vue3 | 主要差异 |
|---|---|---|---|---|
| Props | 父→子 | props: {} |
defineProps() |
Vue3 支持 TS 泛型 |
| Emit | 子→父 | this.$emit() |
defineEmits() |
Vue3 强制声明 |
| v-model | 双向绑定 | value+input |
modelValue+update:modelValue |
Vue3 支持多个 v-model |
| .sync | 双向绑定 | ✅ | ❌ 移除 | 用 v-model:xxx 替代 |
| $refs | 获取实例 | 全暴露 | 需 defineExpose |
Vue |
七、面试怎么回答才精彩
🎯 初级回答(60分)
"Vue 组件通信主要有 props、emit、vuex 这几种方式。父子组件用 props 传数据,子组件用 emit 触发事件,全局状态用 vuex。"
问题: 太笼统,没有深度。
🎯 中级回答(80分)
"Vue 组件通信我会按场景分类:
- 父子通信:props down, events up,Vue3 支持多个 v-model
- 跨层级:provide/inject,Vue3 中支持响应式
- 兄弟组件:通过父组件中转或使用事件总线
- 全局状态:Vuex 或 Pinia,Pinia 是 Vue3 官方推荐
还有 refs、refs、attrs 等辅助方式,但要注意 Vue3 移除了 $children。"
优点: 有分类、有对比。
🎯 高级回答(95分)✨
"我会从设计原则和实际场景两个维度来回答:
一、设计原则
- 单向数据流:优先使用 props/emit,保持数据流向清晰
- 最小知道原则:组件只知道必要的信息,避免 parent/parent/children
- 关注点分离:UI 状态用组件通信,业务状态用状态管理
二、场景选择
- 父子组件(1-2层) :props/emit + v-model
- Vue3 的
defineProps有更好的类型推导- 多个 v-model 解决了 Vue2 的 .sync 问题
- 跨层级(3层+) :provide/inject
- Vue2 需要手动处理响应式(用函数返回)
- Vue3 原生支持响应式,配合 readonly 防止子组件修改
- 兄弟/无关组件:
- 简单场景:父组件中转
- 复杂场景:Pinia(Vue3)或 Vuex(Vue2)
- 避免 Event Bus,容易造成内存泄漏和难以追踪的 bug
- 全局状态:Pinia > Vuex
- Pinia 去掉了 mutations,代码更简洁
- 天然支持 TypeScript
- 自动代码分割,性能更好
三、实战经验
- 曾经遇到过 Event Bus 导致的内存泄漏,组件销毁时忘记
$off- 深层 props 传递用 provide/inject 重构后,代码可维护性提升 40%
- 迁移到 Pinia 后,状态管理代码量减少 30%,类型安全性大幅提升
四、Vue3 特别注意
$children被移除,需要用 ref + defineExpose$listeners合并到$attrs- Composition API 中用
useAttrs()、useSlots()访问五、性能优化
- 大数据量用 provide/inject 比 props 逐层传递性能更好
- 频繁变化的数据避免放 Vuex/Pinia,会触发所有订阅者更新
- 使用
shallowRef、shallowReactive优化大对象通信"
🔥 加分项
如果面试官追问,可以补充:
-
源码层面:
- "Props 的校验是在
initProps阶段完成的" - "provide/inject 通过原型链查找实现"
- "Props 的校验是在
-
TypeScript 支持:
// Vue3 + TS 最佳实践
interface Props {
msg: string
count?: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'update', value: string): void
}>()
-
性能对比:
- "测试过 10 层组件,provide/inject 比 props 传递快 15%"
- "Pinia 的 devtools 性能比 Vuex 好 2 倍"
-
架构设计:
- "我们项目用 Pinia 管理全局状态,provide/inject 处理主题、权限等配置,props/emit 处理业务逻辑"
💡 面试官可能的追问
Q1: "为什么不推荐 Event Bus?"
"三个原因:1) 事件难以追踪,调试困难;2) 容易忘记销毁导致内存泄漏;3) 命名冲突风险。实际项目中用 Pinia 替代更好。"
Q2: "provide/inject 如何保证响应式?"
"Vue2 需要 provide 返回函数或用 Vue.observable;Vue3 直接传 ref/reactive 即可,建议用 readonly 包裹防止子组件修改。"
Q3: "大型项目如何组织组件通信?"
"分层设计:UI 层用 props/emit,业务层用 Pinia modules,配置层用 provide/inject,避免混用导致数据流混乱。"
更多推荐


所有评论(0)