Vue 组件间通信
原文件只保留了文章标题和一个旧版通信文档链接。本文补充 Vue 2.7.16 与 Vue 3.5.39 常见通信方式,并标注已经移除或不推荐继续使用的 API。
一、先按组件关系选择方案
| 关系 | 优先方案 | 适用场景 |
|---|---|---|
| 父 → 子 | props | 明确、可追踪的输入数据 |
| 子 → 父 | emit | 子组件通知父组件发生了事件 |
| 父子双向模型 | v-model | 表单、可编辑组件 |
| 祖先 → 后代 | provide/inject | 主题、表单上下文、依赖注入 |
| 兄弟/跨层级共享状态 | Pinia、组合式函数 | 多个组件都需要读取和修改 |
| 父调用子方法 | ref/defineExpose | 聚焦、滚动、打开弹窗等命令式操作 |
| 父提供模板 | slots/scoped slots | 布局和内容定制 |
| 路由相关数据 | params/query/meta | 页面级状态与导航状态 |
通信方式越隐式,越难追踪。通常应先使用 props/emits,只有确实需要跨层级或共享状态时才引入 provide/inject、store 或事件系统。
二、父组件向子组件:props
Vue 3
<!-- Parent.vue -->
<template>
<UserCard
:user="user"
:disabled="isSaving"
title="用户资料"
/>
</template>
<!-- UserCard.vue -->
<script setup lang="ts">
interface User {
id: string
name: string
}
defineProps<{
user: User
disabled?: boolean
title?: string
}>()
</script>
Options API:
export default {
props: {
user: {
type: Object,
required: true,
},
disabled: Boolean,
},
}
props 的规则
- props 是单向下行的,父级更新后会传给子级;
- 子组件不应该直接修改 prop;
- 如果要编辑初始值,应复制为本地状态,或使用
v-model/emit; - 对象和数组 prop 传递的是引用,子组件不应通过嵌套属性悄悄修改父级状态;
- 类型校验主要用于开发期提示,不替代运行时业务校验。
错误:
// 不要直接修改 prop
props.user.name = 'new name'
更清晰的写法:
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
user: { id: string; name: string }
}>()
const emit = defineEmits<{
save: [payload: { id: string; name: string }]
}>()
const draftName = ref(props.user.name)
function save() {
emit('save', {
...props.user,
name: draftName.value,
})
}
</script>
三、子组件向父组件:事件
Vue 3 defineEmits
<!-- ChildButton.vue -->
<script setup lang="ts">
const emit = defineEmits<{
save: [payload: { id: string }]
cancel: []
}>()
function save() {
emit('save', { id: 'user-1' })
}
</script>
<template>
<button @click="save">保存</button>
</template>
<!-- Parent.vue -->
<ChildButton
@save="handleSave"
@cancel="closeDialog"
/>
Options API:
export default {
emits: ['save', 'cancel'],
methods: {
save() {
this.$emit('save', { id: this.id })
},
},
}
Vue 3 中声明 emits 可以帮助类型检查和区分组件事件与 fallthrough 事件。组件事件不会像 DOM 事件那样自动冒泡到任意祖先;需要跨多层通知时,应由中间组件转发,或改用 store/provide。
Vue 2.7
<!-- Parent.vue -->
<Child @save="handleSave" />
// Child.vue
export default {
methods: {
save() {
this.$emit('save', { id: this.id })
},
},
}
Vue 2.7 也支持 emits 选项用于类型检查,但它不会像 Vue 3 那样改变运行时行为;Vue 2 的主要事件 API 仍然是 $emit。
四、组件 v-model
4.1 Vue 2.7 默认协议
<LegacyInput v-model="title" />
默认等价于:
<LegacyInput
:value="title"
@input="title = $event"
/>
子组件需要声明 value 并触发 input。Vue 2 可以通过 model 选项改成 checked/change 等协议;.sync 是另一个基于 update:prop 的历史语法。
4.2 Vue 3.5 默认协议
<ModernInput v-model="title" />
等价于:
<ModernInput
:modelValue="title"
@update:modelValue="title = $event"
/>
<!-- ModernInput.vue -->
<script setup lang="ts">
const modelValue = defineModel<string>()
</script>
<template>
<input v-model="modelValue" />
</template>
defineModel 从 Vue 3.4 开始可用,Vue 3.5.39 可以使用。多个模型:
<UserName
v-model:first-name="firstName"
v-model:last-name="lastName"
/>
<script setup lang="ts">
const firstName = defineModel<string>('firstName')
const lastName = defineModel<string>('lastName')
</script>
4.3 自定义 v-model 修饰符
<MyInput v-model.capitalize="text" />
Vue 3.4+ 可以读取修饰符:
<script setup lang="ts">
const [model, modifiers] = defineModel({
set(value: string) {
if (modifiers.capitalize) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
return value
},
})
</script>
五、祖先和后代:provide/inject
Vue 3
<!-- App.vue -->
<script setup lang="ts">
import { provide, readonly, ref } from 'vue'
const locale = ref('zh-CN')
const setLocale = (value: string) => {
locale.value = value
}
provide('locale', readonly(locale))
provide('setLocale', setLocale)
</script>
<!-- DeepChild.vue -->
<script setup lang="ts">
import { inject } from 'vue'
const locale = inject('locale')
const setLocale = inject<(value: string) => void>('setLocale')
</script>
生产代码建议使用 Symbol 作为 key,并集中定义类型:
// keys.ts
import type { InjectionKey, Ref } from 'vue'
export const localeKey: InjectionKey<Ref<string>> = Symbol('locale')
provide(localeKey, locale)
const locale = inject(localeKey)
Vue 3 注入的 ref 会保持 ref 语义;如果提供的是 reactive 对象,也会保持响应式。但 provide/inject 不应该替代所有 props:它会隐藏数据来源,适合主题、表单上下文、依赖服务等稳定关系。通过 readonly 和操作函数可以避免后代任意修改上层状态。
Vue 2.7
// 祖先
export default {
provide() {
return {
theme: this.theme,
}
},
}
// 后代
export default {
inject: ['theme'],
}
Vue 2 的 provide/inject 默认不是响应式通信协议。要共享响应式状态,需要提供可观察对象、Vue.observable 或显式的 store/事件机制,并注意 Vue 2.7 与 Vue 3 的响应式实现不同。
六、$attrs 与 fallthrough attributes
Vue 2.7
$attrs 包含父作用域中没有被 props 声明接收的 attribute;Vue 2 还提供 $listeners 保存父级监听器:
<input
v-bind="$attrs"
v-on="$listeners"
/>
export default {
inheritAttrs: false,
}
Vue 3.5
Vue 3 移除了 $listeners,未被 props 和 emits 声明的监听器也属于 $attrs:
<script setup lang="ts">
import { useAttrs } from 'vue'
defineOptions({ inheritAttrs: false })
const attrs = useAttrs()
</script>
<template>
<label class="field-label">
<span>名称</span>
<input v-bind="attrs" />
</label>
</template>
单根组件会自动把 fallthrough attributes 传给根元素,多根组件需要明确 v-bind="$attrs" 的落点。useAttrs() 返回的 attrs 对象不应作为响应式状态源;需要响应式、类型明确的输入时使用 props。
class、style 和未声明的事件监听器也属于 Vue 3 fallthrough 处理的一部分,实际合并结果由组件根节点和 inheritAttrs 决定。不要为了“透传所有属性”而无条件把 $attrs 绑定到多个节点。
七、插槽:父组件传递模板
普通插槽:
<!-- Parent.vue -->
<Card>
<template #header>
<h2>标题</h2>
</template>
<p>正文内容</p>
</Card>
<!-- Card.vue -->
<template>
<article>
<header><slot name="header" /></header>
<main><slot /></main>
</article>
</template>
作用域插槽允许子组件把数据传给父组件提供的模板:
<!-- ChildList.vue -->
<template>
<ul>
<li v-for="item in items" :key="item.id">
<slot name="item" :item="item">
{{ item.label }}
</slot>
</li>
</ul>
</template>
<ChildList :items="items">
<template #item="{ item }">
<strong>{{ item.label }}</strong>
</template>
</ChildList>
插槽适合“父级控制展示结构、子级提供上下文数据”,不要用 slot 代替所有状态通信。
八、模板 ref 与 defineExpose
父组件访问子组件
<script setup lang="ts">
import { ref } from 'vue'
import SearchPanel from './SearchPanel.vue'
const panel = ref<InstanceType<typeof SearchPanel> | null>(null)
function focusSearch() {
panel.value?.focus()
}
</script>
<template>
<SearchPanel ref="panel" />
<button @click="focusSearch">聚焦</button>
</template>
<script setup> 组件默认是私有的,子组件需要显式暴露方法:
<script setup lang="ts">
import { ref } from 'vue'
const input = ref<HTMLInputElement | null>(null)
function focus() {
input.value?.focus()
}
defineExpose({ focus })
</script>
<template>
<input ref="input" />
</template>
ref 适合聚焦、滚动、打开/关闭这类命令式操作,不适合让父组件直接修改子组件内部状态。状态变更仍优先使用 props/emits。
Vue 2 中可以通过 $refs 获取组件实例,但 $refs 只在挂载后可用,不是响应式数据;$parent、$children 会形成强耦合,尤其 $children 顺序不稳定,不建议作为通用通信方式。
九、兄弟和跨层级通信
9.1 让共同父组件中转
<!-- Parent.vue -->
<template>
<FilterPanel v-model="keyword" />
<ResultList :keyword="keyword" />
</template>
兄弟组件之间最清晰的方式通常是:状态提升到共同父组件,再通过 props 和 emit 分发。
9.2 使用 Pinia/store
当状态跨越多个页面或组件树时,可以使用 Pinia:
// stores/cart.ts
import { defineStore } from 'pinia'
export const useCartStore = defineStore('cart', {
state: () => ({
count: 0,
}),
actions: {
add() {
this.count++
},
},
})
<script setup lang="ts">
import { useCartStore } from '@/stores/cart'
const cart = useCartStore()
</script>
<template>
<button @click="cart.add()">购物车 {{ cart.count }}</button>
</template>
Vuex 仍可维护旧项目,但 Vue 3 新项目通常优先 Pinia。应用入口还需要安装 Pinia:
import { createPinia } from 'pinia'
app.use(createPinia())
store 适合跨页面共享的状态,不要把每个局部输入框都提升成全局状态。
9.3 路由状态
页面级组件可以通过 route params、query、meta 和 router.push 传递导航相关状态:
router.push({
name: 'search',
query: { keyword: 'vue', page: '1' },
})
URL 中的数据需要考虑可分享性、刷新恢复、敏感信息和编码,不要把 token、密码等敏感内容放在 query 中。
十、事件总线的版本边界
Vue 2 历史项目常见:
// Vue 2
Vue.prototype.$bus = new Vue()
// 组件 A
this.$bus.$emit('refresh')
// 组件 B
this.$bus.$on('refresh', this.refresh)
使用时必须在销毁前 $off,否则会造成重复监听和内存泄漏:
beforeDestroy() {
this.$bus.$off('refresh', this.refresh)
}
Vue 3 移除了组件实例上的 $on、$off、$once,不能继续使用 new Vue() 作为事件总线。需要事件总线时可以使用 mitt 等独立库,并在 onUnmounted 清理监听;如果事件代表持久状态,优先使用 Pinia 或组合式函数。
十一、版本对照
| 方式 | Vue 2.7 | Vue 3.5 |
|---|---|---|
| 父 → 子 | props | props |
| 子 → 父 | $emit | defineEmits/emit |
| 默认组件 v-model | value + input | modelValue + update:modelValue |
| 多个 v-model | .sync/自定义 model | v-model:arg、defineModel |
| 透传监听器 | $listeners | $attrs,并由 emits 区分 |
| 祖先 → 后代 | provide/inject | provide/inject、InjectionKey |
| 父调子 | $refs | template ref + defineExpose |
| 实例事件总线 | $on/$off/$once | 移除,使用独立事件库/store |
$children | 存在但不稳定 | 移除 |
| 全局状态 | Vuex/事件总线 | Pinia(新项目优先) |
十二、选择建议
- 先用 props 和 emit,保证数据流可读;
- 表单组件用
v-model,不要手写一堆互相同步的 props; - 祖先提供的是上下文或服务时使用 provide/inject;
- 多页面共享状态使用 Pinia,不要把事件总线当作状态库;
- 只需要执行聚焦、滚动、打开等命令时使用 ref;
- 复杂展示结构用 slots/scoped slots;
- Vue 3 不要使用
$listeners、$children、实例$on/$off和.sync; - 跨组件通信要考虑卸载清理、类型、响应式边界和 SSR 环境。
十三、总结
- props 是父到子的声明式输入,emit 是子到父的事件通知;
- 组件事件不会自动跨层冒泡,兄弟组件通常通过共同父组件或 store 通信;
- Vue 2 的
value/input、$listeners、事件总线和.sync需要标注历史版本; - Vue 3 使用
modelValue/update:modelValue、defineModel、$attrs、defineEmits和defineExpose; - provide/inject 适合依赖注入,Pinia 适合跨组件持久共享状态,ref 适合少量命令式操作;
- 通信方式不应只看“能不能传值”,还要看数据所有权、类型、清理、可测试性和 SSR。
官方参考
- Vue 3 Props
- Vue 3 Events
- Vue 3 Component
v-model - Vue 3 Fallthrough Attributes
- Vue 3 Provide/Inject
- Vue 3 Slots
- Vue 3 Template Refs
- Vue 3
defineExpose - Vue 2 组件通信
- Vue 2 自定义事件
- Pinia 官方文档
原文出处
原文参考:learnVue:Vue 组件通信