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

显示模式

登录
ARCHIVE DOCUMENTVUE

Vue 3 的 8 种和 Vue 2 的 12 种组件通信,值得收藏

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/33-Vue3的8种和Vue2的12种组件通信,值得收藏
本文目录25 个章节
  1. Vue 3 组件通信方式
  2. 一、props:父组件传给子组件
  3. 二、emits / $emit:子组件通知父组件
  4. 三、模板 ref 与 defineExpose
  5. 四、$attrs:透传属性和监听器
  6. 五、v-model 与 defineModel
  7. 六、provide / inject:跨层级依赖注入
  8. 七、Pinia / Vuex:共享状态管理
  9. 八、mitt 等外部 emitter
  10. 1. Vue 2 props
  11. 2. Vue 2 .sync
  12. 3. Vue 2 v-model
  13. 4. Vue 2 $refs
  14. 5. Vue 2 $emit / v-on
  15. 6. Vue 2 $attrs / $listeners
  16. 7. Vue 2 $children / $parent
  17. 8. Vue 2 provide / inject
  18. 9. Vue 2 EventBus
  19. 10. Vuex(Vue 2 历史方案)
  20. 11. Vue 2 $root
  21. 12. Vue 2 slot 和 scoped slot
  22. 迁移对照表
  23. 总结
  24. 官方参考
  25. 原文出处

Vue 3 的 8 种和 Vue 2 的 12 种组件通信,值得收藏

“Vue 3 的 8 种、Vue 2 的 12 种”是原作者的经验分类,不是 Vue 官方规定的通信方式数量。原文把 API、机制和第三方库混在一起计数;本文保留这个标题和文章结构,同时按使用场景重新整理。

前半部分以 Vue 3.5 为当前写法,后半部分保留 Vue 2.7 的历史 API 和迁移参考。Vue 2.7.16 已是 Vue 2 最终版本并且已经 EOL,新项目应优先使用 Vue 3。

Vue 3 组件通信方式

原文列出的 8 项可以归纳为:

  1. props:父组件传给子组件;
  2. emits / $emit:子组件通知父组件;
  3. 模板 ref / defineExpose:必要时的命令式访问;
  4. $attrs:透传未声明的属性和事件监听器;
  5. v-model / defineModel:约定式双向模型;
  6. provide / inject:跨层级依赖注入;
  7. Pinia/Vuex:跨组件共享状态;
  8. mitt 等外部 emitter:确有事件总线需求时使用。

这不是官方固定清单。父子关系优先使用 props/emits;深层依赖优先考虑 provide/inject;多个页面共享状态再考虑 Pinia;不要为了凑通信方式数量而滥用 $parent、全局 EventBus 或组件 ref。

原文配图(本地化)

一、props:父组件传给子组件

<script setup> 写法

父组件:

<!-- Parent.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'

const msg1 = ref('这是传给子组件的信息 1')
const msg2 = ref('这是传给子组件的信息 2')
</script>

<template>
  <Child :msg1="msg1" :msg2="msg2" />
</template>

子组件:

<!-- Child.vue -->
<script setup lang="ts">
const props = defineProps<{
  msg1: string
  msg2: string
}>()

console.log(props.msg1, props.msg2)
</script>

defineProps<script setup> 编译器宏,不需要从 vue 导入。也可以使用运行时声明:

const props = defineProps({
  msg1: String,
  msg2: {
    type: String,
    default: '',
  },
})

普通 setup() 写法

export default {
  props: {
    msg: String,
  },
  setup(props) {
    console.log(props.msg)
  },
}

父组件使用 Options API 的 data,还是使用 <script setup> 的顶层变量,并不决定子组件能不能收到 props。只要父模板使用 :msg="value" 传入,子组件声明了 msg,它就可以在 props 中得到该值。

Props 是单向下行绑定,子组件不应直接修改 prop:

const props = defineProps<{ title: string }>()

// ❌ props.title = 'new title'

如果需要改变父状态,应由子组件 emit 事件,让父组件完成修改;如果只是根据 prop 派生值,可以使用 computed。

二、emits / $emit:子组件通知父组件

<script setup> 写法

<!-- Child.vue -->
<script setup lang="ts">
const emit = defineEmits<{
  'my-click': [message: string]
}>()

function handleClick() {
  emit('my-click', '这是子组件发送的信息')
}
</script>

<template>
  <button @click="handleClick">发送</button>
</template>

父组件监听:

<Child @my-click="handleMyClick" />
function handleMyClick(message: string) {
  console.log(message)
}

也可以使用简单数组声明:

const emit = defineEmits(['my-click'])

普通 setup() 使用第二个参数中的 emit

export default {
  emits: ['my-click'],
  setup(_props, { emit }) {
    emit('my-click', 'message')
  },
}

Vue 3 组件事件不会像原生 DOM 事件那样冒泡,只能由直接父组件监听。事件名建议在模板中使用 kebab-case,并显式声明 emits,以便文档、类型检查和 $attrs fallthrough 行为更清晰。

三、模板 ref 与 defineExpose

组件 ref 是命令式通信方式,适合聚焦输入、调用公开方法等少量场景,不应代替普通 props/emits。

子组件:

<!-- Child.vue -->
<script setup lang="ts">
const childName = 'Child'

function focusInput() {
  // ...
}

defineExpose({
  childName,
  focusInput,
})
</script>

父组件在 Vue 3.5+ 可以使用 useTemplateRef

<script setup lang="ts">
import { onMounted, useTemplateRef } from 'vue'
import Child from './Child.vue'

const child = useTemplateRef('child')

onMounted(() => {
  child.value?.focusInput()
})
</script>

<template>
  <Child ref="child" />
</template>

兼容更早 Vue 3 版本时,可以使用普通 ref:

import { ref, onMounted } from 'vue'

const child = ref<InstanceType<typeof Child> | null>(null)
onMounted(() => child.value?.focusInput())

<script setup> 组件默认不会把所有内部绑定暴露给父组件;只有 defineExpose() 中的属性属于公开接口。模板 ref 只有在挂载后才可安全读取,并且可能为 null

普通 setup() 中对应的是 setup context 的 expose

export default {
  setup(_props, { expose }) {
    expose({
      focusInput() {},
    })
  },
}

原文的 useContext() 已在 Vue 3.1 废弃并在 3.2 移除,不能继续使用。

四、$attrs:透传属性和监听器

Vue 3 的 fallthrough attrs 是没有声明为 props 或 emits 的所有属性和事件监听器,包括:

  • classstyleid 等普通属性;
  • 未声明的 @click 等监听器,在 JavaScript 中通常表现为 $attrs.onClick

单根组件默认会把 attrs 透传到根元素:

<!-- MyButton.vue -->
<template>
  <button>Click</button>
</template>
<MyButton class="large" id="save" @click="save" />

如果组件有多根节点,Vue 无法自动判断 attrs 应该放到哪里,需要显式绑定:

<template>
  <header>Header</header>
  <main v-bind="$attrs">Content</main>
</template>

如果希望手动决定透传位置:

<script setup>
defineOptions({ inheritAttrs: false })
</script>

<template>
  <div class="wrapper">
    <button v-bind="$attrs">Click</button>
  </div>
</template>

在脚本中读取 attrs:

<script setup>
import { useAttrs } from 'vue'

const attrs = useAttrs()
console.log(attrs)
</script>

attrs 会反映最新值,但本身不是响应式对象,不能用 watch 监听它的变化;需要响应式通信时应声明明确的 prop。

五、v-modeldefineModel

Vue 3.4+ 推荐写法

普通 v-model 对应 modelValue prop 和 update:modelValue 事件,Vue 3.4+ 推荐使用 defineModel()

<!-- CustomInput.vue -->
<script setup>
const model = defineModel()
</script>

<template>
  <input v-model="model" />
</template>

父组件:

<CustomInput v-model="searchText" />

带参数的 model:

<!-- Parent.vue -->
<UserEditor v-model:item-key="selectedKey" v-model:value="value" />
<!-- UserEditor.vue -->
<script setup>
const itemKey = defineModel('itemKey')
const value = defineModel('value')
</script>

<template>
  <input v-model="itemKey" />
  <input v-model="value" />
</template>

这里使用 itemKey,而不是 keykey 是 Vue VNode 的保留属性,不适合作为业务 model 名。

Vue 3.3 及更早版本的展开写法

<script setup>
const props = defineProps({
  itemKey: String,
  value: String,
})

const emit = defineEmits([
  'update:itemKey',
  'update:value',
])

function updateItemKey(event) {
  emit('update:itemKey', event.target.value)
}
</script>

不能只声明 defineEmits(['itemKey', 'value']),因为父组件 v-model:item-key 需要的是 update:itemKey 事件。defineModel 是编译器宏的便利封装,本质仍然是 prop + update 事件。

六、provide / inject:跨层级依赖注入

父组件可以向所有后代提供依赖,后代不需要逐层传 props:

// keys.ts
import type { InjectionKey, Ref } from 'vue'

export interface LocationContext {
  location: Readonly<Ref<string>>
  updateLocation: (value: string) => void
}

export const locationKey: InjectionKey<LocationContext> = Symbol('location')
<!-- Provider.vue -->
<script setup lang="ts">
import { provide, readonly, ref } from 'vue'
import { locationKey } from './keys'

const location = ref('North Pole')

function updateLocation(value: string) {
  location.value = value
}

provide(locationKey, {
  location: readonly(location),
  updateLocation,
})
</script>
<!-- DeepChild.vue -->
<script setup lang="ts">
import { inject } from 'vue'
import { locationKey } from './keys'

const provided = inject(locationKey)
if (!provided) {
  throw new Error('location was not provided')
}

const { location, updateLocation } = provided
</script>

<template>
  <button @click="updateLocation('South Pole')">
    {{ location }}
  </button>
</template>

要点:

  • 提供 refreactive 或 computed 才能保留响应式连接;
  • 推荐由 provider 持有修改逻辑,inject 方调用更新函数;
  • 可以用 readonly() 防止后代直接修改状态;
  • 组件库或大型项目优先用 Symbol injection key,避免字符串冲突;
  • provide/inject 是依赖注入,不是任意组件之间的全局事件总线。

七、Pinia / Vuex:共享状态管理

当多个页面或多个不相干组件依赖同一状态时,可以使用 Pinia。Pinia 是 Vue 3 新项目优先考虑的状态管理方案,Vuex 仍可用于旧项目维护。

// stores/counter.ts
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: {
    double: state => state.count * 2,
  },
  actions: {
    increment() {
      this.count++
    },
  },
})
<script setup>
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'

const store = useCounterStore()
const { count, double } = storeToRefs(store)
</script>

Vuex 4 的 Vue 3 历史写法:

// store/index.ts
import { createStore } from 'vuex'

export default createStore({
  state: () => ({ count: 1 }),
  getters: {
    count: state => state.count,
  },
  mutations: {
    add(state) {
      state.count++
    },
  },
})
import { createApp } from 'vue'
import App from './App.vue'
import store from './store'

createApp(App).use(store).mount('#app')

Vuex 3 对应 Vue 2,Vuex 4 对应 Vue 3。共享状态管理不应代替每一个父子事件;局部通信仍优先使用 props/emits。

原文 Vuex/状态管理截图(本地化)

八、mitt 等外部 emitter

Vue 3 移除了 Vue 2 实例上的 $on$off$once,但仍可以使用 mitt 等独立事件库。它适合确实需要跨组件树广播事件的场景,不应默认替代 props/emits 或 Pinia。

pnpm add mitt
// emitter.ts
import mitt from 'mitt'

export type Events = {
  'handle-change': string
}

const emitter = mitt<Events>()
export default emitter

发送方:

<script setup lang="ts">
import emitter from './emitter'

function handleClick() {
  emitter.emit('handle-change', 'new value')
}
</script>

<template>
  <button @click="handleClick">发送</button>
</template>

接收方必须保存同一个 handler 引用,并在卸载时精确移除:

<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import emitter from './emitter'

function handleChange(value: string) {
  console.log(value)
}

onMounted(() => emitter.on('handle-change', handleChange))
onUnmounted(() => emitter.off('handle-change', handleChange))
</script>

不要写成 import mitt from 'mitt'; const mitt = mitt(),这会造成导入绑定和局部变量同名。也不要只调用 off('handle-change'),因为它可能移除其他组件注册的同类型监听器。

在 SSR/Nuxt 中还要避免无意间使用跨请求共享的模块级可变 emitter;需要请求隔离时,应按 app/request 作用域创建并通过插件或依赖注入提供。

Vue 2.7 组件通信方式(历史参考)

原文列出的 12 项可以保留为 Vue 2 时代的经验分类:

  1. props;
  2. $emit / v-on
  3. .sync
  4. v-model
  5. ref
  6. $attrs / $listeners
  7. $children / $parent
  8. provide / inject;
  9. EventBus;
  10. Vuex;
  11. $root
  12. slot。

其中 $listeners$children、Vue 实例 EventBus 和 .sync 都不是 Vue 3 的当前 API。保留它们的意义在于维护 Vue 2.7 项目和理解迁移差异。

1. Vue 2 props

<!-- Parent.vue -->
<template>
  <Child :msg="msg" />
</template>

<script>
export default {
  data() {
    return {
      msg: '来自父组件',
    }
  },
}
</script>
// Child.vue
export default {
  props: {
    msg: {
      type: String,
      default: '',
    },
  },
  mounted() {
    console.log(this.msg)
  },
}

Props 是单向下行,子组件不能直接修改。若要修改,应通过 $emit 通知父组件;若只是根据 prop 派生值,可以使用 computed。

2. Vue 2 .sync

.syncupdate:propName 事件的语法糖,不是子组件直接修改父组件数据:

<!-- Parent.vue -->
<Child :page.sync="page" />

等价于:

<Child
  :page="page"
  @update:page="page = $event"
/>
// Child.vue
export default {
  props: ['page'],
  computed: {
    currentPage: {
      get() {
        return this.page
      },
      set(value) {
        this.$emit('update:page', value)
      },
    },
  },
}

Vue 3 已移除 .sync,对应写法是 v-model:page

3. Vue 2 v-model

Vue 2 组件默认把 v-model 编译为 value prop 和 input 事件:

<!-- Parent.vue -->
<Child v-model="value" />
// Child.vue
export default {
  props: ['value'],
  methods: {
    handleChange(event) {
      this.$emit('input', event.target.value)
    },
  },
}

也可以使用 model 选项自定义 prop 和事件:

export default {
  model: {
    prop: 'checked',
    event: 'change',
  },
  props: ['checked'],
}

Vue 3 默认改为 modelValue + update:modelValue,命名模型使用 v-model:arg

4. Vue 2 $refs

ref 放在普通 DOM 上时得到 DOM 元素,放在子组件上时得到子组件实例:

<Child ref="child" />
export default {
  mounted() {
    const child = this.$refs.child
    child.someMethod()
  },
}

$refs 只有在渲染完成后才填充,并且不是响应式数据。它适合聚焦、测量和调用明确的命令式公开方法,不适合传递普通业务状态。

5. Vue 2 $emit / v-on

子组件:

export default {
  data() {
    return {
      message: '这是子组件的信息',
    }
  },
  methods: {
    handleClick() {
      this.$emit('send-msg', this.message)
    },
  },
}

父组件:

<Child @send-msg="getMessage" />
export default {
  methods: {
    getMessage(message) {
      console.log(message)
    },
  },
}

Vue 2 自定义事件名需要父子完全匹配,不依赖 Vue 3 那样的大小写自动转换;项目中建议统一使用 kebab-case。

6. Vue 2 $attrs / $listeners

Vue 2 的 $attrs 包含父作用域中未被 props 声明的属性,且排除 classstyle$listeners 包含父作用域的监听器。它们可以在多层组件中透传:

<!-- Parent.vue -->
<Child name="Vue" title="title" @change="handleChange" />
<!-- Child.vue -->
<template>
  <GrandChild v-bind="$attrs" v-on="$listeners" />
</template>

<script>
export default {
  props: ['name'],
}
</script>

原文中的 $linteners 是拼写错误,应为 $listeners。Vue 3 已移除 $listeners,监听器和 class/style 都进入 $attrs,多根节点需要显式 v-bind="$attrs"

7. Vue 2 $children / $parent

// Parent.vue
export default {
  mounted() {
    this.$children[0].someMethod()
  },
}
// Child.vue
export default {
  mounted() {
    this.$parent.someMethod()
  },
}

这些属性会造成强耦合:

  • $children 只包含直接子组件,顺序不保证,也不是响应式集合;
  • $parent 依赖组件层级结构;
  • Vue 3 已移除 $children,应改用明确的 props/emits、provide/inject 或模板 ref。

8. Vue 2 provide / inject

// Provider.vue
export default {
  provide() {
    return {
      name: 'Vue',
      someMethod: this.someMethod,
    }
  },
  methods: {
    someMethod() {
      console.log('provided method')
    },
  },
}
// DeepChild.vue
export default {
  inject: ['name', 'someMethod'],
  mounted() {
    console.log(this.name)
    this.someMethod()
  },
}

Vue 2 Options API 的普通 provide/inject 默认不是响应式连接;如果需要共享更新状态,通常应注入可观察对象或方法,并清楚标注版本边界。Vue 3 可以直接 provide ref/reactive/computed,并推荐由 provider 负责修改逻辑。

9. Vue 2 EventBus

Vue 2 时代可以创建一个空 Vue 实例作为事件总线:

// Bus.js
import Vue from 'vue'

export default new Vue()

发送:

import Bus from './Bus'

Bus.$emit('send-msg', 'hello')

接收时应保存 handler,并精确清理:

import Bus from './Bus'

export default {
  mounted() {
    Bus.$on('send-msg', this.handleMessage)
  },
  beforeDestroy() {
    Bus.$off('send-msg', this.handleMessage)
  },
  methods: {
    handleMessage(message) {
      console.log(message)
    },
  },
}

原文的 Bus.$off('sendMsg') 会移除该事件的所有监听器,可能误伤其他组件。Vue 3 已移除实例 $on/$off/$once,新项目可用 mitt,但仍要在卸载时使用相同 handler 调用 off

10. Vuex(Vue 2 历史方案)

Vue 2 通常使用 Vuex 3:

// store/index.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    count: 0,
  },
  getters: {
    double: state => state.count * 2,
  },
  mutations: {
    add(state) {
      state.count++
    },
  },
})
// main.js
import Vue from 'vue'
import App from './App.vue'
import store from './store'

new Vue({
  store,
  render: h => h(App),
}).$mount('#app')

组件中可使用 this.$store.statemapGettersmapMutations。Vue 3 对应 Vuex 4,但新项目通常优先 Pinia;Vuex 已进入维护状态。

11. Vue 2 $root

$root 指向 new Vue() 创建的根实例,而不一定就是 App.vue 组件实例:

this.$root

小 demo 中可以使用,但大型项目不应通过 $root 隐式共享任意数据和方法,优先使用 Pinia、props/emits 或 provide/inject。

12. Vue 2 slot 和 scoped slot

普通 slot 是父组件提供模板内容,子组件通过 <slot> 渲染:

<!-- Child.vue -->
<template>
  <button class="fancy-button">
    <slot>默认文本</slot>
  </button>
</template>
<!-- Parent.vue -->
<Child>保存</Child>

如果需要把子组件的数据传给父组件提供的 slot 内容,则使用 scoped slot:

<!-- Child.vue -->
<template>
  <slot :user="user" />
</template>

<script>
export default {
  data() {
    return {
      user: { name: 'Vue' },
    }
  },
}
</script>
<!-- Parent.vue,Vue 2.6/2.7 推荐 v-slot -->
<Child v-slot="slotProps">
  {{ slotProps.user.name }}
</Child>

Vue 3 的 slots 语义基本延续了 scoped slots,但使用 <template #name>v-slot 的写法更加统一。

迁移对照表

Vue 2.7 历史写法Vue 3 当前写法
props + $emitprops + defineEmits / $emit
.syncv-model:prop
value + input 组件 v-modelmodelValue + update:modelValue,或 defineModel
$attrs + $listeners$attrs 同时包含 fallthrough 属性和监听器
$children模板 ref、明确公开接口或 provide/inject
Vue 实例 EventBusmitt 等外部 emitter,精确清理 handler
Vuex 3Pinia;Vuex 4 仅作 Vue 3 旧项目兼容
beforeDestroy / destroyedbeforeUnmount / unmounted

总结

  • 父子组件优先使用 props 和 emits,不要直接修改 props。
  • 组件双向模型在 Vue 3.4+ 优先使用 defineModel();多个模型使用不同且非保留的名称。
  • defineExpose 与模板 ref 只公开必要的命令式能力。
  • Vue 3 $attrs 包含未声明 props/emits 的属性、classstyle 和事件监听器。
  • provide/inject 适合依赖注入,Pinia 适合跨页面共享状态,mitt 只适合明确的事件广播需求。
  • Vue 2 的 .sync$listeners$children 和实例 EventBus 应标记为历史 API。
  • Vue 2.7 只适合旧项目维护和迁移参考,新项目以 Vue 3.5 为准。

官方参考

原文出处

作者:沐华

原文链接:https://juejin.cn/post/6999687348120190983

来源:稀土掘金。著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

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

按分类浏览

10 COLLECTIONS