返回

Vue3 组件通信常见方式整理

从父子传值、事件回传、双向绑定到 provide/inject 和 Pinia,整理 Vue3 项目中常用的组件通信方式,以及各自适合的场景。

组件通信没有唯一标准答案,关键是看数据属于谁、要经过几层、是否要跨页面共享。日常 Vue3 项目里,父子组件优先用 propsemit;层级较深但作用范围有限时用 provide/inject;真正跨页面的公共状态再交给 Pinia。

父组件传给子组件:props

用户信息由父组件管理,子组件只负责展示时,直接传 props 最合适。数据流是从上到下的,阅读起来也最直接。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<!-- UserCard.vue -->
<script setup lang="ts">
interface User {
  id: number
  name: string
  role: string
}

defineProps<{ user: User }>()
</script>

<template>
  <article>
    <strong>{{ user.name }}</strong>
    <span>{{ user.role }}</span>
  </article>
</template>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<!-- UserList.vue -->
<script setup lang="ts">
import UserCard from './UserCard.vue'

const currentUser = {
  id: 1,
  name: '林达',
  role: '管理员',
}
</script>

<template>
  <UserCard :user="currentUser" />
</template>

子组件不要直接修改 props。这会破坏单向数据流,也容易在控制台出现警告。

子组件通知父组件:emit

删除、保存、切换状态这类动作,由子组件触发、父组件决定怎么处理,适合用事件回传。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<!-- UserCard.vue -->
<script setup lang="ts">
const emit = defineEmits<{
  remove: [id: number]
}>()

const props = defineProps<{ id: number; name: string }>()
</script>

<template>
  <button type="button" @click="emit('remove', props.id)">
    删除 {{ props.name }}
  </button>
</template>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!-- UserList.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const users = ref([
  { id: 1, name: '林达' },
  { id: 2, name: '小王' },
])

const removeUser = (id: number) => {
  users.value = users.value.filter((user) => user.id !== id)
}
</script>

<template>
  <UserCard
    v-for="user in users"
    :key="user.id"
    v-bind="user"
    @remove="removeUser"
  />
</template>

事件名最好描述行为,例如 saveremovechange-status。不要用 callback 这种看不出意图的名字。

需要双向绑定时:v-model

表单组件最常见。Vue3 的 v-model 本质上是一个 modelValue 属性和一个 update:modelValue 事件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<!-- SearchInput.vue -->
<script setup lang="ts">
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()
</script>

<template>
  <input
    :value="props.modelValue"
    placeholder="搜索用户"
    @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
  />
</template>

父组件就可以像使用原生输入框一样使用它:

1
<SearchInput v-model="keyword" />

跨多层但只在局部使用:provide / inject

主题配置、表单上下文、页面级权限等数据,经过多层组件逐级传递会很麻烦。这时可以由页面根组件提供,再由后代组件取用。

1
2
3
4
5
// 页面根组件
import { provide, ref } from 'vue'

const readonly = ref(false)
provide('formReadonly', readonly)
1
2
3
4
// 深层子组件
import { inject, type Ref } from 'vue'

const readonly = inject<Ref<boolean>>('formReadonly')

这适合“局部共享”,不建议把所有数据都塞进去。过多的注入会让数据来源变得不清楚。

跨页面共享:Pinia

登录用户、权限、全局主题、购物车这类状态需要多个页面共用,才值得放进 Pinia。页面临时的弹窗开关、单个表单字段仍然留在组件内更轻。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    name: '',
    token: '',
  }),
  actions: {
    setUser(name: string, token: string) {
      this.name = name
      this.token = token
    },
  },
})

选择建议

  • 一层父子:props + emit
  • 表单值双向同步:v-model
  • 多层组件的局部上下文:provide/inject
  • 跨路由、跨模块的共享状态:Pinia。

先从最简单的方案开始。通信方式越重,维护成本越高;数据范围越小,代码通常越容易整理。

使用 Hugo 构建
主题 StackJimmy 设计