技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

Vue 组件间通信

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/50-Vue组件间通信
本文目录15 个章节
  1. 一、先按组件关系选择方案
  2. 二、父组件向子组件:props
  3. 三、子组件向父组件:事件
  4. 四、组件 v-model
  5. 五、祖先和后代:provide/inject
  6. 六、$attrs 与 fallthrough attributes
  7. 七、插槽:父组件传递模板
  8. 八、模板 ref 与 defineExpose
  9. 九、兄弟和跨层级通信
  10. 十、事件总线的版本边界
  11. 十一、版本对照
  12. 十二、选择建议
  13. 十三、总结
  14. 官方参考
  15. 原文出处

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。

classstyle 和未声明的事件监听器也属于 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.7Vue 3.5
父 → 子propsprops
子 → 父$emitdefineEmits/emit
默认组件 v-modelvalue + inputmodelValue + update:modelValue
多个 v-model.sync/自定义 modelv-model:argdefineModel
透传监听器$listeners$attrs,并由 emits 区分
祖先 → 后代provide/injectprovide/inject、InjectionKey
父调子$refstemplate ref + defineExpose
实例事件总线$on/$off/$once移除,使用独立事件库/store
$children存在但不稳定移除
全局状态Vuex/事件总线Pinia(新项目优先)

十二、选择建议

  1. 先用 props 和 emit,保证数据流可读;
  2. 表单组件用 v-model,不要手写一堆互相同步的 props;
  3. 祖先提供的是上下文或服务时使用 provide/inject;
  4. 多页面共享状态使用 Pinia,不要把事件总线当作状态库;
  5. 只需要执行聚焦、滚动、打开等命令时使用 ref;
  6. 复杂展示结构用 slots/scoped slots;
  7. Vue 3 不要使用 $listeners$children、实例 $on/$off.sync
  8. 跨组件通信要考虑卸载清理、类型、响应式边界和 SSR 环境。

十三、总结

  • props 是父到子的声明式输入,emit 是子到父的事件通知;
  • 组件事件不会自动跨层冒泡,兄弟组件通常通过共同父组件或 store 通信;
  • Vue 2 的 value/input$listeners、事件总线和 .sync 需要标注历史版本;
  • Vue 3 使用 modelValue/update:modelValuedefineModel$attrsdefineEmitsdefineExpose
  • provide/inject 适合依赖注入,Pinia 适合跨组件持久共享状态,ref 适合少量命令式操作;
  • 通信方式不应只看“能不能传值”,还要看数据所有权、类型、清理、可测试性和 SSR。

官方参考

原文出处

原文参考:learnVue:Vue 组件通信

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS