Skip to content

Vue3 组件通信与 Pinia 高级

本页关键词:Getters、$subscribe、Store 组合式、props、自定义事件、mitt、v-model 组件、$attrs、$refs/$parent、provide/inject、插槽


一、Pinia 的 Getters:数据加工与计算属性

知识点讲解

Getters 的作用

state 中的数据需经处理后再使用时,可使用 getters 配置。Getters 类似于 Vue 组件中的 computed 计算属性

  • 对原始 state 进行逻辑加工
  • 结果会被缓存,依赖未变化时不会重新计算
  • 可在其他 getters 或组件中使用

Getters 的两种写法

1. 接收 state 参数(推荐用箭头函数)

ts
import { defineStore } from 'pinia'

export const useCountStore = defineStore('count', {
  state() {
    return {
      sum: 1,
      school: 'atguigu'
    }
  },
  getters: {
    bigSum: (state): number => state.sum * 10
  }
})

2. 使用 this 访问(必须用普通函数)

ts
getters: {
  bigSum: (state): number => state.sum * 10,
  upperSchool(): string {
    return this.school.toUpperCase()
  }
}

使用 this 可访问 state、其他 getters、甚至 actions;必须用普通函数(箭头函数的 this 不指向 store)。


组件中使用 Getters

vue
<template>
  <div>
    <h3>原始sum:{{ sum }}</h3>
    <h3>放大后的sum:{{ bigSum }}</h3>
    <h3>学校:{{ school }}</h3>
    <h3>大写学校:{{ upperSchool }}</h3>
  </div>
</template>

<script setup lang="ts">
import { useCountStore } from '@/store/count'
import { storeToRefs } from 'pinia'

const countStore = useCountStore()
let { sum, school, bigSum, upperSchool } = storeToRefs(countStore)
</script>

Getters 与直接在组件中用 computed 计算有什么区别? Getters 定义在 store 中,可被多个组件复用;computed 定义在组件内,只能在当前组件使用。若多个组件需要相同计算逻辑,应使用 Getters。
为什么使用 this 时必须用普通函数而不能用箭头函数? 箭头函数的 this 是词法作用域,指向定义时的上下文;普通函数的 this 由 Pinia 绑定为当前 store 实例,才能访问到 state、getters 和 actions。

二、$subscribe:状态变化监听与持久化

知识点讲解

通过 store 的 $subscribe() 方法可侦听 state 及其变化。常用于:数据持久化(同步到 localStorage)、日志记录、数据变化的副作用处理。

基本用法

ts
import { useTalkStore } from '@/store/loveTalk'

const talkStore = useTalkStore()

talkStore.$subscribe((mutate, state) => {
  console.log('LoveTalk', mutate, state)
  localStorage.setItem('talk', JSON.stringify(state.talkList))
})

参数说明:

  • mutate:变化的详细信息(typestoreIdevents
  • state:变化后的完整 state 对象

$subscribe 和 Vue 的 watch 有什么区别? `$subscribe` 是 Pinia 专门用于监听整个 store 变化的 API,会在任何 state 变化时触发;`watch` 需明确指定监听的响应式数据。`$subscribe` 更适合全局持久化等场景。
如果在多个组件中都调用了 $subscribe,会执行几次? 会执行多次。每次调用 `$subscribe` 都会注册一个新的监听器。建议在应用入口或根组件中调用一次即可。

三、Store 组合式(Setup)写法

知识点讲解

Pinia 支持组合式写法(Composition API),与 Vue3 的 <script setup> 风格一致,可直接使用 ref、reactive、computed。

核心规则:

  • 使用 ref()reactive() 定义 state
  • 使用 computed() 定义 getters
  • 使用 function 定义 actions
  • 必须通过 return 暴露需要给外部使用的属性和方法

完整示例

ts
import { defineStore } from 'pinia'
import axios from 'axios'
import { nanoid } from 'nanoid'
import { reactive } from 'vue'

export const useTalkStore = defineStore('talk', () => {
  const talkList = reactive(
    JSON.parse(localStorage.getItem('talkList') as string) || []
  )

  async function getATalk() {
    let { data: { content: title } } = await axios.get('https://api.uomg.com/api/rand.qinghua?format=json')
    let obj = { id: nanoid(), title }
    talkList.unshift(obj)
  }

  return { talkList, getATalk }
})

选项式 vs 组合式对照表

类型选项式写法组合式写法
Statestate() { return {...} }ref()reactive()
Gettersgetters: {...}computed()
Actionsactions: {...}普通 function
暴露自动暴露需要 return { }

组合式写法中,为什么必须 return? 组合式写法本质是一个函数,函数内部的变量默认是局部的。只有通过 `return` 暴露的属性和方法,才能被组件访问到。
如果要在组合式 store 中定义 getter,应该怎么写?

使用 Vue 的 computed() API:

ts
import { computed } from 'vue'

const bigSum = computed(() => sum.value * 10)
return { sum, bigSum }

四、组件通信概述与变化

Vue3 组件通信的变化

变化类型Vue2Vue3
事件总线$bus移除,使用 mitt 代替
状态管理Vuex推荐使用 Pinia
双向绑定v-model + .sync统一为 v-model(支持多个)
属性透传$attrs + $listeners合并为 $attrs
子组件访问$children移除,使用 ref

常见通信方式与适用场景

方式适用关系说明
props父 ↔ 子最常用,父传子用数据,子传父用函数
自定义事件子 → 父子组件通过 emit 向父组件发送消息
v-model父 ↔ 子双向绑定语法糖,支持多个绑定
$attrs祖 → 孙属性透传,跨层级传递 props
$refs父 → 子父组件直接访问子组件实例
$parent子 → 父子组件访问父组件实例
provide/inject祖 ↔ 孙依赖注入,祖先向后代提供数据
mitt任意组件事件总线,适合兄弟或远距离组件
pinia任意组件全局状态管理
slot父 → 子内容分发,父组件定制子组件结构

五、Props:最基础的父子通信

知识点讲解

Props 用于父子组件间的数据传递

  • 父传子:属性值是非函数(数据)
  • 子传父:属性值是函数(回调)

父组件:

vue
<template>
  <div class="father">
    <h3>父组件</h3>
    <h4>我的车:{{ car }}</h4>
    <h4>儿子给的玩具:{{ toy }}</h4>
    <Child :car="car" :getToy="getToy"/>
  </div>
</template>

<script setup lang="ts" name="Father">
import Child from './Child.vue'
import { ref } from "vue";

const car = ref('奔驰')
const toy = ref()

function getToy(value: string) {
  toy.value = value
}
</script>

子组件:

vue
<template>
  <div class="child">
    <h3>子组件</h3>
    <h4>我的玩具:{{ toy }}</h4>
    <h4>父给我的车:{{ car }}</h4>
    <button @click="getToy(toy)">玩具给父亲</button>
  </div>
</template>

<script setup lang="ts" name="Child">
import { ref } from "vue";

const toy = ref('奥特曼')
defineProps(['car', 'getToy'])
</script>

为什么说"子传父"时传的是函数? 因为数据的所有权在父组件,子组件不能直接修改父组件的数据。通过传递函数,子组件可以调用并传入参数,从而间接让父组件更新自己的数据。
defineProps 返回的对象可以解构吗? 不建议直接解构,因为会失去响应式。若必须解构,需使用 `toRefs(props)`。在模板中可直接使用 props 的属性名。

六、自定义事件:更语义化的子传父

知识点讲解

自定义事件实现子 → 父通信,比 props 传函数更语义化。

关键区别:$event 的含义不同

事件类型$event 的含义示例
原生事件事件对象(pageX、pageY、target、keyCode 等)@click="handler"
自定义事件emit 触发时传递的数据(任意类型)@send-toy="toy = $event"

父组件:

vue
<template>
  <div class="father">
    <h3>父组件</h3>
    <h4>儿子给的玩具:{{ toy }}</h4>
    <Child @send-toy="toy = $event"/>
  </div>
</template>

<script setup lang="ts" name="Father">
import Child from './Child.vue'
import { ref } from "vue";
const toy = ref('')
</script>

子组件:

vue
<template>
  <div class="child">
    <h3>子组件</h3>
    <h4>我的玩具:{{ toy }}</h4>
    <button @click="emit('send-toy', toy)">玩具给父亲</button>
  </div>
</template>

<script setup lang="ts" name="Child">
import { ref } from "vue";
const toy = ref('奥特曼')
const emit = defineEmits(['send-toy'])
</script>

自定义事件相比 props 传函数有什么优势? 语义更清晰,可读性更好。`@send-toy` 明确表达"发送玩具"的意图。

七、Mitt:全局事件总线

知识点讲解

Mitt 是轻量级事件总线库,类似 Vue2 的 $bus,可实现任意组件间通信,适合兄弟组件、层级相隔较远的组件、不想用 Pinia 的简单场景。

安装与配置

bash
npm i mitt

新建 src/utils/emitter.ts

ts
import mitt from "mitt";
const emitter = mitt()
export default emitter

使用流程

接收方 - 绑定事件:

vue
<script setup lang="ts" name="Child2">
import emitter from "@/utils/emitter";
import { onUnmounted } from "vue";
import { ref } from "vue";

const toy = ref('')

emitter.on('send-toy', (value) => {
  toy.value = value as string
})

onUnmounted(() => {
  emitter.off('send-toy')
})
</script>

发送方 - 触发事件:

vue
<script setup lang="ts" name="Child1">
import emitter from "@/utils/emitter";
import { ref } from "vue";

const toy = ref('奥特曼')

function sendToy() {
  emitter.emit('send-toy', toy.value)
}
</script>

核心 API

API说明
emitter.on(event, handler)绑定事件监听
emitter.emit(event, data)触发事件
emitter.off(event)解绑指定事件
emitter.all.clear()清空所有事件

面试要点:必须在组件卸载前解绑事件,否则导致内存泄漏和重复监听。


Mitt 和 Pinia 都能实现任意组件通信,如何选择? * **Mitt**:适合简单的消息通知、事件触发 * **Pinia**:适合需要持久化、复杂状态管理的场景
为什么 emitter 要单独建一个文件? 需要确保整个应用使用**同一个 emitter 实例**。若每个组件都 `mitt()`,会创建多个独立实例,无法实现通信。

八、v-model:双向绑定的组件化应用

知识点讲解

在组件上使用 v-model

本质是 :modelValue + @update:modelValue

vue
<AtguiguInput v-model="userName"/>
<!-- 等价于 -->
<AtguiguInput 
  :modelValue="userName" 
  @update:modelValue="userName = $event"
/>

子组件实现:

vue
<template>
  <div class="box">
    <input 
      type="text" 
      :value="modelValue" 
      @input="emit('update:modelValue', $event.target.value)"
    >
  </div>
</template>

<script setup lang="ts" name="AtguiguInput">
defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>

自定义 v-model 参数名与多 v-model

vue
<AtguiguInput v-model:abc="userName"/>
<!-- 子组件:defineProps(['abc'])、emit('update:abc', ...) -->
vue
<AtguiguInput v-model:ming="userName" v-model:mima="password"/>

面试要点:Vue3 的 v-model 支持自定义参数名和多个绑定;子组件不能直接修改 modelValue,必须通过 emit。


为什么 Vue3 的 v-model 比 Vue2 更强大? Vue2 中一个组件只能有一个 v-model,多个需用 `.sync`。Vue3 统一为 v-model,支持自定义参数名,一个组件可绑定多个。
子组件能直接修改 modelValue 吗? 不能。Props 是单向数据流,必须通过 emit 触发 `update:modelValue` 事件通知父组件更新。

九、$attrs:属性透传机制

知识点讲解

$attrs 用于祖 → 孙通信。包含所有父组件传入的标签属性,自动排除 props 中声明的属性。可通过 v-bind="$attrs" 一次性透传。

中间层子组件:

vue
<template>
  <div class="child">
    <GrandChild v-bind="$attrs"/>
  </div>
</template>

<script setup lang="ts" name="Child">
import GrandChild from './GrandChild.vue'
</script>

若子组件声明了 defineProps(['a', 'b']),则 $attrs 中不包含 ab


$attrs 和 provide/inject 都能实现祖孙通信,如何选择? * **$attrs**:适合属性透传,中间组件不需要使用这些数据 * **provide/inject**:适合深层依赖注入,多个后代都要用
Vue3 的 $attrs 相比 Vue2 有什么变化? Vue2 有 `$attrs` 和 `$listeners`;Vue3 将事件监听器合并到 `$attrs` 中,统一管理。

十、$refs 与 $parent:实例访问

知识点讲解

属性方向说明
$refs父 → 子父组件访问子组件实例
$parent子 → 父子组件访问父组件实例

子组件必须用 defineExpose 暴露数据;父组件通过 ref 属性标记子组件。$parent 在模板中直接使用,<script setup> 中不可用;会导致强耦合,不推荐频繁使用。


为什么 script setup 中的数据默认不对外暴露? Vue3 的设计:组件应封装,只暴露必要接口。`defineExpose` 显式暴露可防止外部随意访问内部状态。
$refs 和 props 都能父传子,有什么区别? * **props**:数据流清晰,单向传递(推荐) * **$refs**:直接访问实例,可调用方法和修改数据,但强耦合

十一、provide 与 inject:依赖注入

知识点讲解

祖先通过 provide 提供数据,后代通过 inject 接收,中间层无需参与

祖先:

ts
provide('moneyContext', { money, updateMoney })
provide('car', car)

后代:

ts
let { money, updateMoney } = inject('moneyContext', {
  money: 0,
  updateMoney: (x: number) => {}
})
let car = inject('car')

面试要点:provide 时必须传递 ref()reactive() 对象,不能传 .value,否则失去响应式。


provide/inject 和 Vuex/Pinia 有什么区别? * **provide/inject**:组件树内依赖注入,作用域是"祖先及其后代" * **Pinia**:全局状态管理,任意组件可访问
如何保证 inject 的数据是响应式的? 祖先 provide 时传递 `ref()` 或 `reactive()` 对象,而非 `.value`。

十二、插槽(Slot):内容分发机制

核心理念

数据在子组件,结构由父组件定。子组件提供数据或布局框架,父组件决定如何展示,实现高度可定制化。

默认插槽

父组件:

vue
<Category title="今日热门游戏">
  <ul>
    <li v-for="g in games" :key="g.id">{{ g.name }}</li>
  </ul>
</Category>

子组件:

vue
<template>
  <div class="item">
    <h3>{{ title }}</h3>
    <slot></slot>
  </div>
</template>

具名插槽

父组件: v-slot:s1#s1,必须用 <template> 包裹。

子组件: <slot name="s1"></slot>

作用域插槽(重点)

数据在子组件,展示结构由父组件决定。 子组件通过 :games="games" 向插槽传递数据,父组件用 v-slot="params"#default="params" 接收。

子组件:

vue
<slot :games="games" a="哈哈"></slot>

父组件:

vue
<Game v-slot="{ games, a }">
  <ul>
    <li v-for="g in games" :key="g.id">{{ g.name }}</li>
  </ul>
  <p>{{ a }}</p>
</Game>

什么时候使用作用域插槽? 当子组件拥有数据,但不确定父组件要如何展示时。子组件负责提供数据,父组件负责定义展示结构。
作用域插槽和普通插槽的区别是什么? * **普通插槽**:父组件提供内容,子组件只负责展示位置 * **作用域插槽**:子组件提供数据,父组件决定如何使用数据渲染内容

总结:组件通信方式速查表

通信方式适用场景数据方向响应式难度
props父子组件父 ↔ 子
自定义事件父子组件子 → 父-
v-model父子组件父 ↔ 子
$attrs祖孙组件祖 → 孙
$refs/$parent父子组件父 ↔ 子
provide/inject祖孙组件祖 ↔ 孙
mitt任意组件任意方向-
pinia任意组件全局共享
slot父子组件父 → 子