Skip to content

一个页面拆成几十个组件后,最大的麻烦不是写组件,而是数据怎么在组件之间流转。父组件想让子组件显示自己的数据、子组件操作完要告诉父组件、爷孙组件隔着两层怎么传、两个八竿子打不着的组件要共享登录态……每个场景都有对应的招式,用错了代码就绕。

这篇把 Vue 组件通信的几种方式摆开:各自解决什么问题、怎么写、什么时候用,最后一张对比表收口。下文基于 Vue 3(版本脉络见 Vue 版本演进:Vue 2 与 Vue 3 全对比)。

一、先立规矩:单向数据流

Vue(和 React 一样)推崇单向数据流:数据从父组件流向子组件,子组件不能直接改父组件传下来的数据,只能「汇报」让父组件改。

这个规矩带来两个好处:数据流向清楚(出 bug 顺着链路往上找就行)、数据源唯一(一个数据只有一处会改它,不会多处乱改互相打架)。

二、父子通信:props 向下传,emit 向上抛

最常用、也是唯一绕不开的组合。

html
<!-- 子组件:AssetCard.vue -->
<script setup>
const props = defineProps({
  name: String,
  price: Number,
})
const emit = defineEmits(['select'])

// 子组件不能改 props.name,只能抛事件让父组件改
const handleClick = () => emit('select', props.name)
</script>

<template>
  <div class="card" @click="handleClick">
    <h3>{{ name }}</h3>
    <p>¥{{ price }}</p>
  </div>
</template>
html
<!-- 父组件 -->
<script setup>
import AssetCard from './AssetCard.vue'

const assets = ref([
  { name: 'ThinkPad X1', price: 8999 },
  { name: 'MacBook Pro', price: 12999 },
])
const onSelect = (name) => {
  // 收到子组件抛上来的事件,这里改数据
  console.log('选中了', name)
}
</script>

<template>
  <AssetCard
    v-for="item in assets"
    :key="item.name"
    :name="item.name"
    :price="item.price"
    @select="onSelect"
  />
</template>

两条线:

  • 父 → 子defineProps 声明收什么,模板里 :name 传过去。只读,别在子组件里给 props.name = xxx
  • 子 → 父defineEmits 声明能抛什么事件,emit('select', name) 抛出去,父组件 @select 接住。

三、v-model:双向绑定的语法糖

表单场景(输入框、下拉框、弹窗开关)频繁用「子组件改一个值,父组件要同步」,写 props + emit 两行代码有点啰嗦,Vue 给了 v-model 语法糖:

html
<!-- 子组件:PriceInput.vue -->
<script setup>
// modelValue 是约定好的 prop 名
defineProps({ modelValue: Number })
// 约定好的事件名:update:modelValue
const emit = defineEmits(['update:modelValue'])

const onInput = (e) => emit('update:modelValue', Number(e.target.value))
</script>

<template>
  <input :value="modelValue" @input="onInput" type="number" />
</template>
html
<!-- 父组件:一行搞定,等价于 :modelValue + @update:modelValue -->
<PriceInput v-model="price" />

一个组件可以绑多个值,自定义 v-model 参数名(v-model:title 对应 title + update:title)。凡是「子组件收一个值、要改了就上报」的形态,都值得用 v-model 简化——弹窗的 visible 开关、表单控件的值,全是典型场景。

四、跨层级:provide / inject

爷孙组件隔着中间层,一层层 props 传下去很痛苦(中间那层根本不用这个数据,纯中转)。provide/inject 让祖先直接供给后代,跨多少层都行:

html
<!-- 祖先组件:提供 -->
<script setup>
import { provide, ref } from 'vue'
const tenant = ref('cxtech')
provide('tenant', tenant) // 供给后代,名字自己定
</script>
html
<!-- 任意后代组件:注入 -->
<script setup>
import { inject } from 'vue'
const tenant = inject('tenant') // 直接用,中间组件不用管
</script>

注意两点:inject 的键名全项目要约定好,否则容易冲突;「谁 provide 的、谁改了数据」对中间组件不可见,只适合少量跨层共享的只读/半只读数据(主题、租户、当前用户),不适合大范围状态管理。

五、全局状态:Pinia 与「事件总线」的过时

真正多组件共享、还要改来改去的状态(登录态、购物车、筛选条件),用 Pinia(Vue 官方推荐,Vuex 的后继者):

javascript
// stores/user.js
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({ name: '', role: '' }),
  actions: {
    login(name, role) {
      this.name = name
      this.role = role
    },
  },
})
html
<script setup>
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
userStore.login('BinMaker', 'admin') // 任何组件都能改、都能读
</script>

老项目还能见到「事件总线」(mitt / 空的 Vue.prototype.$bus)——谁都能 $emit 谁都能 $on调试时你不知道事件从哪来的,项目一大就是灾难。新代码一律用 Pinia,别再开历史倒车。

六、什么时候用哪种:对比表

Vue 组件通信方式全景

通信场景首选方式原因
父 → 子(直接)props单向数据流,最简单直接
子 → 父(直接)emit 事件只汇报,不改父数据
子组件改一个值、父要同步v-model语法糖,少写两行
跨多层(爷 → 孙)provide/inject免去中间层无谓中转
多组件共享、频繁修改Pinia全局单一数据源,可调试
任何「事件总线」方案❌ 不要用来源不可追踪,难维护

判断口诀:能就近解决就别拉远——父子能搞定就 props/emit,跨层少量用 provide/inject,全局大状态才上 Pinia。通信方式本身不复杂,复杂的是「数据该放哪」的判断,放错了层级,后面每次改动都要来回传。

响应式怎么让 props 变更自动触发视图更新,见 响应式原理详解;组件代码按功能怎么组织,见 Composition API 详解