组件间的通信

在 Vue 项目开发中,组件间的通信是构建复杂应用的基础。Vue 2 和 Vue 3 在这方面的思路一脉相承,但具体实现上有不少差异,尤其是 Vue 3 引入了 Composition API 后更加灵活。下面我将常见的通信方式、适用场景、注意事项以及最常见的坑,配合详细的代码示例,一并整合输出。


1. 父子组件通信:Props + 自定义事件

最基础、标准的单向数据流。

父 → 子:通过 Props 传递数据

子 → 父:通过自定义事件通知父组件

Vue 2 写法

vue 复制代码
<!-- 父组件 Parent.vue -->
<template>
  <Child :msg="parentMsg" @update="handleUpdate" />
</template>

<script>
import Child from './Child.vue'

export default {
  components: { Child },
  data() {
    return { parentMsg: '来自父组件' }
  },
  methods: {
    handleUpdate(val) {
      console.log('子组件传来:', val)
    }
  }
}
</script>

<!-- 子组件 Child.vue -->
<template>
  <div>
    <p>{{ msg }}</p>
    <button @click="sendToParent">点击传值</button>
  </div>
</template>

<script>
export default {
  props: ['msg'],
  methods: {
    sendToParent() {
      this.$emit('update', '子组件数据')
    }
  }
}
</script>

Vue 3 写法(<script setup>

vue 复制代码
<!-- 父组件 Parent.vue -->
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'

const parentMsg = ref('来自父组件')
const handleUpdate = (val) => {
  console.log('子组件传来:', val)
}
</script>

<template>
  <Child :msg="parentMsg" @update="handleUpdate" />
</template>

<!-- 子组件 Child.vue -->
<script setup>
const props = defineProps(['msg'])
const emit = defineEmits(['update'])

const sendToParent = () => {
  emit('update', '子组件数据')
}
</script>

<template>
  <div>
    <p>{{ props.msg }}</p> <!-- 或直接 msg,模板中自动解包 -->
    <button @click="sendToParent">点击传值</button>
  </div>
</template>

使用场景

  • 纯父子组件间的数据传递,严格遵循单向数据流。
  • 组件库、表单控件的封装(父组件传入初始值,子组件向上抛出变更事件)。

注意事项

  • 单向数据流铁律 :子组件绝不能直接修改 prop 的值(包括通过 v-model 绑定的值)。
  • 事件命名建议使用 kebab-case(如 @update-value),模板中会自动转换。
  • Vue 3 中 definePropsdefineEmits 是编译宏,无需导入,且不能在运行时动态修改。

常见坑

  1. 直接修改 prop(特别是引用类型)

    子组件中对对象或数组类型的 prop 进行内部属性修改,Vue 可能不会报警告,但会破坏单向数据流,导致状态混乱。

    解决 :子组件应通过 computed 或本地 ref 拷贝 prop,并仅通过事件通知父组件修改。

  2. .sync 修饰符的误解(Vue 2)

    :visible.sync="visible" 等价于 :visible="visible" @update:visible="visible = $event",子组件必须触发 update:visible 事件,很多开发者因事件名拼写错误导致双向绑定失效。

  3. 事件名大小写

    Vue 2 中 $emit('updateValue') 可能需用 @update-value 监听,但习惯用驼峰可能导致监听失败。统一使用 kebab-case 最稳妥。


2. 父组件直接访问子组件:ref / $refs

用于调用子组件的方法或直接获取其数据(如聚焦输入框、重置表单等)。

Vue 2 写法

vue 复制代码
<!-- Parent.vue -->
<template>
  <Child ref="childRef" />
  <button @click="getChildData">获取子组件数据</button>
</template>

<script>
export default {
  methods: {
    getChildData() {
      // 通过 this.$refs.childRef 访问子组件实例
      console.log(this.$refs.childRef.childData)
      this.$refs.childRef.someMethod()
    }
  }
}
</script>

<!-- Child.vue -->
<script>
export default {
  data() {
    return { childData: '子组件内部数据' }
  },
  methods: {
    someMethod() {
      console.log('子组件方法被调用')
    }
  }
}
</script>

Vue 3 写法(需 defineExpose

vue 复制代码
<!-- Parent.vue -->
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'

const childRef = ref(null)

const getChildData = () => {
  // childRef.value 就是子组件暴露的实例
  console.log(childRef.value.childData)
  childRef.value.someMethod()
}
</script>

<template>
  <Child ref="childRef" />
  <button @click="getChildData">获取子组件数据</button>
</template>

<!-- Child.vue -->
<script setup>
import { ref } from 'vue'

const childData = ref('子组件内部数据')
const someMethod = () => {
  console.log('子组件方法被调用')
}

// 必须显式暴露!
defineExpose({
  childData,
  someMethod
})
</script>

使用场景

  • 调用子组件内部方法(聚焦输入框、播放动画、表单校验重置)。
  • 集成第三方库需要直接操作子组件的 DOM 或实例。

注意事项

  • Vue 2 中 $refsmounted 之后才可用,不能在 created 中访问。
  • Vue 3 的 <script setup> 默认是封闭的,必须 通过 defineExpose 暴露出去的属性和方法,父组件才能访问到,否则拿到空对象。
  • v-for 中使用 ref,会得到一个数组或对象,需要额外处理。

常见坑

  1. 忘记 defineExpose(Vue 3)

    父组件 childRef.value.someMethod() 报错 undefined,因为子组件什么都没暴露。这是最常见的"怎么用不了"的问题。

  2. v-if 控制的组件上使用 ref

    当条件为 false 时组件销毁,ref 指向变为 null,后续直接访问会报错。必须先判断 ref 是否存在。

  3. 过早访问 $refs

    createdsetup 顶部直接 this.$refs.xxxref.value,拿到的是 undefined/null,必须在 mounted/onMountednextTick 后操作。


3. 祖先与后代通信:provide / inject

适合跨层级传递数据,例如根组件向深层子组件传递主题、语言、用户认证信息等,避免 Props 逐层传递(Prop Drilling)。

Vue 2 选项式(非完全响应式)

js 复制代码
// 祖先组件
export default {
  provide() {
    return {
      theme: this.theme   // 注意:这里不是响应式的
    }
  },
  data() {
    return { theme: 'dark' }
  }
}

// 后代组件
export default {
  inject: ['theme'],
  mounted() {
    console.log(this.theme)   // 'dark',但后续祖先修改 theme,此处不更新
  }
}

若需响应式,需使用 Vue.observable 或传入计算属性,写法繁琐。

Vue 3 组合式(完全响应式)

vue 复制代码
<!-- 祖先组件 -->
<script setup>
import { provide, ref } from 'vue'

const theme = ref('dark')
provide('theme', theme)

// 同时提供修改方法,保证数据变更可控
const setTheme = (val) => { theme.value = val }
provide('setTheme', setTheme)
</script>

<!-- 后代组件 -->
<script setup>
import { inject } from 'vue'

const theme = inject('theme')
const setTheme = inject('setTheme')
</script>

<template>
  <div :class="theme">
    当前主题:{{ theme }}
    <button @click="setTheme('light')">切换</button>
  </div>
</template>

使用场景

  • 深层嵌套组件共享状态(主题、国际化语言、全局配置)。
  • 插件或组件库中向所有子孙组件提供公共 API。

注意事项

  • Vue 2 中 provide 提供的数据默认不是响应式 的,如果数据源是基本类型或对象被整体替换,后代不会更新。Vue 3 通过传递 ref/reactive 彻底解决了这个问题。
  • 使用 Symbol 作为注入键可以避免命名冲突,特别是在大型项目或插件开发中。
  • 绝对禁止 后代直接修改注入的数据,应由祖先提供修改方法(如 setTheme),保证数据流向可追踪。

常见坑

  1. Vue 2 响应式断裂

    provide() { return { user: this.user } } 中,当 this.user 整体被替换为一个新对象时,后代注入的 user 仍指向旧对象,页面不会刷新。这一问题在 Vue 2 中几乎没有完美的官方解法,Vue 3 是最好替代方案。

  2. 后代直接篡改数据

    注入一个 ref 后,在下级组件里直接 inject('count').value = 100,导致状态变化无法追溯,调试困难。一定通过方法修改。

  3. 忘记导入共享的 Symbol

    若提供和注入使用了同一个 Symbol,但没从同一文件导出导入,注入时会拿到 undefined,且不会报错,极难排查。建议将 Symbol 存放于公共常量文件。


4. 任意组件 / 兄弟组件通信(EventBus)

适用于没有直接关系的组件间的轻量级通信,不便于引入状态管理时的简单事件通知。

Vue 2 经典方案:使用空的 Vue 实例作为事件中心

js 复制代码
// eventBus.js
import Vue from 'vue'
export const bus = new Vue()
vue 复制代码
<!-- 组件 A 发送 -->
<script>
import { bus } from './eventBus'
export default {
  methods: {
    sendMsg() {
      bus.$emit('global-event', 'hello from A')
    }
  }
}
</script>

<!-- 组件 B 接收 -->
<script>
import { bus } from './eventBus'
export default {
  mounted() {
    bus.$on('global-event', this.handler)
  },
  beforeDestroy() {
    bus.$off('global-event', this.handler)   // 必须移除
  },
  methods: {
    handler(msg) {
      console.log(msg)
    }
  }
}
</script>

Vue 3 推荐替代:mitt

Vue 3 不再提供 $on/$off/$once,需使用第三方库 mitt

bash 复制代码
npm install mitt
js 复制代码
// eventBus.js
import mitt from 'mitt'
const emitter = mitt()
export default emitter
vue 复制代码
<!-- 组件 A 发送 -->
<script setup>
import emitter from './eventBus'
const sendMsg = () => {
  emitter.emit('global-event', 'hello from A')
}
</script>

<!-- 组件 B 接收 -->
<script setup>
import { onMounted, onUnmounted } from 'vue'
import emitter from './eventBus'

const handler = (msg) => {
  console.log(msg)
}

onMounted(() => {
  emitter.on('global-event', handler)
})

onUnmounted(() => {
  emitter.off('global-event', handler)   // 注意传入相同的函数引用
})
</script>

使用场景

  • 兄弟组件或任意无关组件间的简单通知(如全局点击关闭弹窗、聊天新消息提醒)。
  • 中小型项目,全局状态较少,不想引入 Pinia/Vuex 时的过渡方案。

注意事项

  • 必须手动移除监听器 。无论是 Vue 2 的 $off 还是 mittoff,组件销毁前要清理,否则会造成内存泄漏或重复触发。
  • 事件名称宜使用常量(如 export const EVENT_REFRESH = 'refresh'),避免拼写错误。
  • 不要滥用,过多的事件总线会让应用变成"事件地狱",难以调试和追踪数据流。

常见坑

  1. 忘记解绑导致重复触发

    在 Vue 2 中,每次组件创建都 bus.$on('event', handler),但未在 beforeDestroybus.$off('event', handler),当组件再次创建时会绑定多个监听器,事件触发时 handler 被执行多次。

    Vue 3 中 mitt 同理,必须在 onUnmountedoff,且要传入同一个函数引用,匿名函数无法移除。

  2. 数据流向混乱

    大量使用事件总线后,不知道该事件由谁发出、被谁接收、何时触发,调试困难。一旦项目扩大,建议尽早迁移到 Pinia 或 provide/inject。

  3. 传递引用类型数据的副作用

    通过事件总线传递的对象可能在接收方被直接修改,影响其他监听该事件的组件。建议只传递基本值或使用深拷贝。


5. 全局状态管理:Vuex / Pinia

用于中大型项目的跨组件、跨页面共享状态。

Vue 2:Vuex

js 复制代码
// store.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)

export default new Vuex.Store({
  state: { count: 0 },
  mutations: { increment(state) { state.count++ } },
  actions: { asyncIncrement({ commit }) { commit('increment') } },
  getters: { doubleCount: state => state.count * 2 }
})
vue 复制代码
<!-- 组件中 -->
<script>
export default {
  computed: {
    count() { return this.$store.state.count },
    double() { return this.$store.getters.doubleCount }
  },
  methods: {
    add() { this.$store.commit('increment') }
  }
}
</script>

Vue 3:Pinia(官方推荐)

js 复制代码
// store.js
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: {
    doubleCount: (state) => state.count * 2
  },
  actions: {
    increment() { this.count++ }
  }
})
vue 复制代码
<script setup>
import { useCounterStore } from './store'
const counter = useCounterStore()
// 注意:解构会丢失响应式,应使用 storeToRefs 或直接 counter.count
</script>

<template>
  <div>{{ counter.count }} - {{ counter.doubleCount }}</div>
  <button @click="counter.increment()">+1</button>
</template>

使用场景

  • 跨页面或全局共享的用户信息、权限、购物车、主题等。
  • 需要状态持久化、中间件、Devtools 时间旅行调试的中大型项目。

注意事项

  • Vuex 严格模式下,只能通过 mutation 同步修改 state,否则控制台报警告且无法追踪。
  • Pinia 中可以直接修改 state(底层仍被 action 包裹),但依然推荐在 actions 中组织复杂逻辑。
  • 只将真正需要全局共享的状态放入 Store,避免把所有状态都扔进去。
  • Pinia 解构时需用 storeToRefs() 保持响应性。

常见坑

  1. 直接修改 Vuex 的 state(严格模式)

    直接 this.$store.state.count = 1 会报错。必须通过 commitdispatch

  2. Pinia 解构丢失响应性

    const { count } = useCounterStore() 得到的是一个静态数字,后续变化不会更新。需使用 const { count } = storeToRefs(store),或始终通过 store.count 访问。actions 可以直接解构。

  3. 在组件外使用 Store 未注入 Pinia 实例

    在路由守卫、axios 拦截器等非组件上下文中,若直接调用 useXxxStore() 可能报错,需要确保 Pinia 实例已创建并作为参数传入(如 useXxxStore(pinia))。


6. 透传 Attributes:$attrs / useAttrs()

用于父组件传递的、未被声明为 Props 的属性(classstyleid 以及自定义属性、事件监听器等)向下传递,常用于二次封装基础组件。

Vue 2

vue 复制代码
<!-- Child.vue -->
<template>
  <div>
    <!-- 需同时传递 $attrs 和 $listeners,否则事件会丢失 -->
    <GrandChild v-bind="$attrs" v-on="$listeners" />
  </div>
</template>

<script>
export default {
  inheritAttrs: false   // 禁止自动挂载到根元素
}
</script>

Vue 3

vue 复制代码
<!-- Child.vue (组合式) -->
<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()   // 响应式对象,已包含属性及事件监听
</script>

<template>
  <GrandChild v-bind="attrs" />
</template>

使用场景

  • 对第三方 UI 库组件或原生 HTML 元素进行二次封装时,需要将所有非 prop 属性透传下去。
  • 构建高阶组件(HOC),不希望逐一声明所有可能的属性。

注意事项

  • Vue 2 需要设置 inheritAttrs: false 防止根元素自动继承非 prop 属性,并且事件监听器在 $listeners 中,需要与 $attrs 分开传递。
  • Vue 3 统一了属性和事件监听器,$attrs 中直接包含 onXxx 函数,且组合式 API 提供 useAttrs() 获取。
  • 如果组件有多个根节点(Vue 3 Fragments),则不会自动继承属性,需手动指定哪个节点用 v-bind="$attrs"

常见坑

  1. Vue 2 忘记传递 $listeners

    父组件绑定的事件(如 @click)在封装组件上无效,因为只 v-bind="$attrs" 不会包含事件。务必添加 v-on="$listeners"

  2. classstyle 合并问题

    当子组件根元素已有 class 时,透传的 class 会自动合并。但如果有多个根节点(Vue 3),就必须显式指定绑定位置,否则这些属性会丢失且无警告。

  3. useAttrs() 的响应性误区

    虽然 useAttrs() 返回对象是响应式的,但不建议在 watchcomputed 中深度依赖某个具体属性,因为整个对象引用可能更新,而单属性变化未必触发。


7. v-model 双向绑定

主要用于表单组件封装,实现父子组件数据双向同步。

Vue 2

  • 默认 v-model 传递 value prop,监听 input 事件。
  • 使用 .sync 修饰符实现其他 prop 的双向绑定(如 :title.sync="title" 会传递 title prop,并监听 update:title 事件)。
vue 复制代码
<!-- 父组件 -->
<Child v-model="name" :age.sync="age" />

<!-- 子组件 -->
<script>
export default {
  props: ['value', 'age'],
  methods: {
    updateName(e) { this.$emit('input', e.target.value) },
    updateAge(val) { this.$emit('update:age', val) }
  }
}
</script>

Vue 3

  • 移除 .sync,统一为参数化 v-model
  • 默认 v-model 使用 modelValue prop 和 update:modelValue 事件。
  • 多个绑定:v-model:title="title" 对应 title prop 和 update:title 事件。
vue 复制代码
<!-- 父组件 -->
<Child v-model="name" v-model:age="age" />

<!-- 子组件 -->
<script setup>
const props = defineProps(['modelValue', 'age'])
const emit = defineEmits(['update:modelValue', 'update:age'])

const updateName = (e) => emit('update:modelValue', e.target.value)
const updateAge = (val) => emit('update:age', val)
</script>

使用场景

  • 封装输入框、选择器、模态框、日期选择器等需要双向绑定的表单组件。

注意事项

  • 不要滥用双向绑定破坏单向数据流原则,仅在必要的表单或受控组件中使用。
  • Vue 3 中每个 v-model 都可以单独指定参数,极大增强了封装能力。
  • 自定义修饰符(如 v-model.trim)需要在子组件中通过 modelModifiers prop 处理(Vue 3),或通过 model 选项处理(Vue 2)。

常见坑

  1. 混淆 v-model.sync(Vue 2)

    一个组件中同时使用两者,父组件语法不一,子组件事件名也容易混淆。迁移到 Vue 3 后 .sync 已废弃,需改为 v-model:propName

  2. 子组件直接修改 prop 而不是触发事件

    在封装输入组件时,常见错误是用 computedset 直接修改 props.modelValue,这违背了单向数据流。必须通过 emit('update:modelValue', newVal) 通知父组件。

  3. 忘记处理修饰符导致功能失效

    如果在子组件中没有读取 modelModifiers 并手动应用修饰效果(如 trim、lazy),那么 v-model.trim 看起来像写了但完全无效。


8. 作用域插槽(Scoped Slots)

子组件将自身数据暴露给父组件,由父组件决定如何渲染,实现 UI 与逻辑的解耦。

Vue 2

vue 复制代码
<!-- Child.vue -->
<template>
  <ul>
    <li v-for="item in list" :key="item.id">
      <slot :item="item" />
    </li>
  </ul>
</template>

<!-- Parent.vue -->
<Child :list="data">
  <template v-slot:default="slotProps">
    <span>{{ slotProps.item.name }}</span>
  </template>
</Child>

Vue 3(几乎一致,支持 # 简写)

vue 复制代码
<!-- Child.vue -->
<script setup>
defineProps(['list'])
</script>

<template>
  <ul>
    <li v-for="item in list" :key="item.id">
      <slot :item="item" />
    </li>
  </ul>
</template>

<!-- Parent.vue -->
<Child :list="data">
  <template #default="{ item }">
    <span>{{ item.name }}</span>
  </template>
</Child>

使用场景

  • 数据由子组件管理,渲染结构完全交由父组件定制(如表格的自定义列、列表的每一项样式)。
  • 创建"无渲染组件",只提供逻辑(如鼠标位置追踪),不负责 UI。

注意事项

  • 插槽 prop 是只读的,父组件不应直接修改它们。
  • Vue 2 中 v-slot 只能用在 <template> 或组件标签上,Vue 3 中用法相同。
  • 动态插槽名需慎重,避免与已定义的具名插槽冲突。

常见坑

  1. 父组件直接修改插槽 prop 的引用数据

    在插槽内直接执行 slotProps.item.name = 'new' 会污染子组件的源数据,造成子组件内部状态异常。应视为只读。

  2. 无渲染组件的插槽遗漏

    有些组件只封装逻辑,不生成任何 DOM,若忘记在模板中写 <slot :data="..." />render 函数中调用 $scopedSlots.default({...}),会导致没有任何内容渲染。

  3. 具名插槽与默认插槽混淆

    v-slot:headerv-slot:default 同时使用时,默认插槽的内容必须包裹在 <template #default> 中,否则可能渲染错位。


9. 路由传参(Vue Router)

用于页面级组件间的参数传递。

Vue 2

js 复制代码
// 跳转
this.$router.push({ path: '/user', query: { id: 1 } })
// 或
this.$router.push({ name: 'user', params: { id: 1 } })

// 接收
this.$route.query.id
this.$route.params.id

Vue 3

vue 复制代码
<script setup>
import { useRouter, useRoute } from 'vue-router'

const router = useRouter()
const route = useRoute()

// 跳转
router.push({ name: 'user', params: { id: 1 } })

// 接收
console.log(route.params.id)
</script>

使用场景

  • 从一个页面跳转到另一个页面并携带 ID、搜索关键词等数据。
  • 需要在 URL 上反映筛选条件或分页信息(使用 query)。

注意事项

  • params 传参必须搭配命名路由 ,且路由路径中必须定义动态段(/user/:id),否则页面刷新后参数丢失。
  • query 参数会显示在 URL 上,适合分享或保持状态。
  • Vue 3 中解构 route.params 可能导致失去响应性,需使用 toRefcomputed 保持动态。

常见坑

  1. 刷新后 params 丢失

    使用 params 但路由配置中没有 :id 占位符,虽然跳转时能传递,但一旦刷新页面,params 变为空对象。解决方案:改用 query 或配置动态路由。

  2. 路由参数永远是字符串

    paramsquery 获取的 id 是字符串类型,如果后端需要数字,务必进行转换(如 Number(route.params.id)),或使用路由的 props 函数模式自动转换。

  3. 导航守卫中参数不一致

    在全局守卫中,to.params 可能因为重定向而与预期不同,需要小心处理。


总结

通信方式 Vue 2 常用实现 Vue 3 推荐实现 关键注意点
父子通信 props + $emit defineProps + defineEmits 单向数据流不可违背,事件名统一 kebab-case
访问子组件 $refs ref() + defineExpose Vue 3 必须显式暴露,避免在 v-if 组件上直接依赖
跨层级通信 provide/inject(非完全响应式) provide/inject + ref/reactive Vue 2 响应式断裂,后代禁止直接修改数据
全局事件总线 new Vue() 作为事件中心 mitt 必须解绑监听器,避免内存泄漏和重复触发
状态管理 Vuex Pinia 严格模式下不可直接修改 state;解构注意响应性
透传属性 $attrs + $listeners useAttrs() Vue 2 别忘了 $listeners,多根节点需手动指定绑定
双向绑定 v-model + .sync 参数化 v-model 子组件只能通过事件更新,注意修饰符处理
作用域插槽 v-slot:default="slotProps" #default="{ item }" 插槽数据只读,无渲染组件不要遗漏 <slot>
路由传参 this.$route.params / query useRoute() 内获取 params 需动态路由配合,参数类型注意转换,解构避免丢失响应性

无论是 Vue 2 还是 Vue 3,选择通信方式的核心原则始终是:明确数据归属、保证单向流动、及时清理副作用、保持响应性。Vue 3 的组合式 API 和 Pinia 极大地降低了传统通信方式中的心智负担,但在维护或迁移项目时仍需留意两代版本间的差异,合理选择最合适的方案。

相关推荐
左手吻左脸。3 小时前
Vue 全栈面试题大全(2026 最新版最详细)
前端·javascript·vue.js
Aphasia3113 小时前
手写KeepAlive组件
前端·react.js·面试
两个西柚呀3 小时前
js中的同步和异步,三种处理异步任务的方式
前端·javascript
pe7er3 小时前
软件设计不要“既要又要”
前端·后端·架构
kyriewen3 小时前
从Webpack到Vite:我们迁移了一个10万行代码的项目,总结了这7个坑
前端·webpack·vite
IT_陈寒4 小时前
Java Stream并行流的坑:我花了3小时才找到的线程安全问题
前端·人工智能·后端
小新1104 小时前
最简单但完整的 Vue 响应式示例(一个简单的计数器按钮)
前端·javascript·vue.js
川冰ICE4 小时前
JavaScript进阶④|Symbol与元编程,对象的隐藏身份
开发语言·javascript·ecmascript
水煮白菜王4 小时前
开源 AI 桌宠 Clawd on Desk:让 Claude Code 的状态从终端‘蹦‘到桌面
javascript·人工智能·开源