前端开发整理(五): Vue框架的组件通信和全局状态管理

2025/08/12 technical_talk 77 分钟阅读
技术前端

组件间通信

父子组件通信

父子组件通信是指,B 组件引入到 A 组件里渲染,此时 A 是 B 的父级;B 组件的一些数据需要从 A 组件拿,B 组件有时也要告知 A 组件一些数据变化情况;Child.vue 是直接挂载在 Father.vue 下面的。

# 父组件
Father.vue
 # 子组件
└─Child.vue

常用的方法有:

方案父组件向子组件子组件向父组件
props / emitspropsemits
v-model / emitsv-modelemits
ref / emitsrefemits
provide / injectprovideinject
EventBusemit / onemit / on
Reactive State--
Vuex--
Pinia--

为了方便阅读,下面的父组件统一叫 Father.vue ,子组件统一叫 Child.vue 。

props / emits

这是 Vue 跨组件通信最常用,也是基础的一个方案,它的通信过程是:

  1. 父组件 Father.vue 通过 props 向子组件 Child.vue 传值
  2. 子组件 Child.vue 则可以通过 emits 向父组件 Father.vue 发起事件通知

最常见的场景就是统一在父组件发起 AJAX 请求,拿到数据后,再根据子组件的渲染需要传递不同的 props 给不同的子组件使用。

下发 props

下发的过程是在 Father.vue 里完成的,父组件在向子组件下发 props 之前,需要导入子组件并启用它作为自身的模板,然后在setup 里处理好数据并 return 给<template /> 用。

在 Father.vue 的<script /> 里:

// Father.vue
import { defineComponent } from 'vue'
import Child from '@cp/Child.vue'

interface Member {
  id: number
  name: string
}

export default defineComponent({
  // 需要启用子组件作为模板
  components: {
    Child,
  },

  // 定义一些数据并 `return` 给 `<template />` 用
  setup() {
    const userInfo: Member = {
      id: 1,
      name: 'Petter',
    }

    // 不要忘记 `return` ,否则 `<template />` 拿不到数据
    return {
      userInfo,
    }
  },
})

然后在 Father.vue 的<template /> 这边拿到 return 出来的数据,把要传递的数据通过属性的方式绑定在组件标签上:

<!-- Father.vue -->
<template>
  <Child
    title="用户信息"
    :index="1"
    :uid="userInfo.id"
    :user-name="userInfo.name"
  />
</template>

<template /> 绑定属性这里,如果是普通的字符串,比如上面的title,则直接给属性名赋值就可以。如果是变量名,或者其他类型如number 、boolean 等,比如上面的index,则需要通过属性动态绑定的方式来添加,使用v-bind: 或者: 符号进行绑定。

这样就完成了 props 数据的下发。

接收 props

接收的过程是在 Child.vue 里完成的,在<script /> 部分,子组件通过与setup 同级的 props 来接收数据。

它可以是一个string[] 数组,把要接受的变量名放到这个数组里,直接放进来作为数组的item :

// Child.vue
export default defineComponent({
  props: ['title', 'index', 'userName', 'uid'],
})

但这种情况下,使用者不知道这些属性的使用限制,例如是什么类型的值、是否必传等等。

带有类型限制的 props

和 TypeScript 一样,类型限制可以为程序带来更好的健壮性, Vue 的 props 也支持增加类型限制。

相对于传递一个string[] 类型的数组,更推荐的方式是把 props 定义为一个对象,以对象形式列出,每个 Property 的名称和值分别是各自的名称和类型,只有合法的类型才允许传入。

支持的类型有:

类型含义
String字符串
Number数值
Boolean布尔值
Array数组
Object对象
Date日期数据,e.g.new Date()
Function函数,e.g. 普通函数、箭头函数、构造函数
PromisePromise 类型的函数
SymbolSymbol 类型的值

给 props 加上类型限制的示例代码为:

// Child.vue
export default defineComponent({
  props: {
    title: String,
    index: Number,
    userName: String,
    uid: Number,
  },
})

如果需要对某个 Prop 允许多类型,比如这个uid 字段,可能是数值,也可能是字符串,那么可以在类型这里,使用一个数组,把允许的类型都加进去:

// Child.vue
export default defineComponent({
  props: {
    // 单类型
    title: String,
    index: Number,
    userName: String,

    // 这里使用了多种类型
    uid: [Number, String],
  },
})

可选以及带有默认值的 props

所有 props 默认都是可选的,如果不传递具体的值,则默认值都是undefined ,可能引起程序运行崩溃, Vue 支持对可选的 props 设置默认值,也是通过对象的形式配置 props 的选项。

其中支持配置的选项有:

选项类型含义
typestring类型
requiredboolean是否必传,true 代表必传,false 代表可选
defaultanytype 选项的类型相对应的默认值,如果required 选项是false ,但这里不设置默认值,则会默认为undefined
validatorfunction自定义验证函数,需要 return 一个布尔值,true 代表校验通过,false 代表校验不通过,当校验不通过时,控制台会抛出警告信息

了解了配置选项后,接下来再对 props 进行改造,将其中部分选项设置为可选,并提供默认值:

// Child.vue
export default defineComponent({
  props: {
    // 可选,并提供默认值
    title: {
      type: String,
      required: false,
      default: '默认标题',
    },

    // 默认可选,单类型
    index: Number,

    // 添加一些自定义校验
    userName: {
      type: String,

      // 在这里校验用户名必须至少 3 个字
      validator: (v) => v.length >= 3,
    },

    // 默认可选,但允许多种类型
    uid: [Number, String],
  },
})

使用 props

该部分也是在Child.vue中操作。

<template /> 部分, Vue 3 的使用方法和 Vue 2 是一样的,比如要渲染父组件传入的 props :

<!-- Child.vue -->
<template>
  <p>标题:{{ title }}</p>
  <p>索引:{{ index }}</p>
  <p>用户id:{{ uid }}</p>
  <p>用户名:{{ userName }}</p>
</template>

在 Vue 2 里,只需要通过this.uid 、this.userName 就可以使用父组件传下来的 Prop ,但是 Vue 3 没有了this ,所以是通过setup 的入参进行操作:

// Child.vue
export default defineComponent({
  props: {
    title: String,
    index: Number,
    userName: String,
    uid: Number,
  },

  // 在这里需要添加一个入参
  setup(props) {
    // 该入参包含了当前组件定义的所有 props
    console.log(props)
  },
})

关于 Setup 函数的第一个入参props :

  1. 该入参包含了当前组件定义的所有 props (如果父组件 Father.vue 传进来的数据在 Child.vue 里未定义,不仅不会拿到,并且在控制台会有警告信息)。
  2. 该入参可以随意命名,比如可以写成一个下划线_ ,通过_.uid 也可以拿到数据,但是语义化命名是一个良好的编程习惯。
  3. 该入参具备响应性,父组件修改了传递下来的值,子组件也会同步得到更新,因此请不要直接解构,可以通过 toRef 或 toRefs API 转换为响应式变量

传递和获取非props属性

如果父组件 Father.vue 传进来的数据在 Child.vue 里未定义,不仅不会拿到,并且在控制台会有警告信息。这种情况虽然无法从 props 里拿到对应的数据,但也不意味着不能传递任何未定义的属性数据,在父组件,除了可以给子组件绑定 props ,还可以根据实际需要去绑定一些特殊的属性。

比如给子组件设置classid,或者data-xxx 之类的一些自定义属性,如果子组件 Child.vue 的<template /> 里只有一个根节点,那么这些属性默认会自动继承并渲染在 Node 节点上。

这一类非 props 属性通常称之为attrs

在 Child.vue 里,可以通过setup 的第二个参数context 里的attrs 来获取到这些属性,并且父组件传递了什么类型的值,获取到的也是一样的类型,这一点和使用Element.getAttribute() 完全不同。Vue 3 允许多个根节点,多个根节点的情况下,无法直接继承这些 attrs 属性(在inheritAttrs: true 的情况也下无法默认继承),需要在子组件 Child.vue 里通过v-bind 绑定到要继承在节点上。

绑定、接收、调用与校验emits

父组件 Father.vue 需要获取子组件 Child.vue 的数据更新情况,可以由子组件通过 emits 进行通知。事件的逻辑是由父组件决定的,因此需要在父组件 Father.vue 的<script /> 里先声明数据变量和一个更新函数,并且这个更新函数通常会有一个入参作为数据的新值接收。

和 props 一样,可以指定是一个数组,把要接收的 emit 事件名称写进去;如果子组件需要更新数据并通知父组件,可以使用setup 第二个参数context 里的emit 方法触发。emit 方法最少要传递一个参数:事件名称。

事件名称是指父组件 Father.vue 绑定事件时@update-age="updateAge" 里的update-age ,如果改成@hello="updateAge" ,那么事件名称就需要使用hello ,一般情况下事件名称和更新函数的名称会保持一致,方便维护。

对于需要更新数据的情况,emit 还支持传递更多的参数,对应更新函数里的入参,所以可以看到上面例子里的emit('update-age', 2) 有第二个参数,传递了一个2 的数值,就是作为父组件updateAge 的入参newAge 传递。

如果需要通信的数据很多,建议第二个入参使用一个对象来管理数据;子组件在传递新数据时,就应该使用对象的形式传递;这对于更新表单等数据量较多的场景非常好用。

和 props 一样,子组件在接收 emits 时也可以对这些事件做一些验证,这个时候就需要将 emits 配置为对象,然后把事件名称作为key ,value 则对应为一个用来校验的方法。

v-model / emits

使用 v-model 的方式更为简单:

  1. 在 Father.vue ,通过 v-model 向 Child.vue 传值
  2. Child.vue 通过自身设定的 emits 向 Father.vue 通知数据更新

v-model 的用法和 props 非常相似,但是很多操作上更为简化,但操作简单带来的 “副作用” ,就是功能上也没有 props 那么多。

绑定 v-model

在 Father.vue 里操作,和下发 props 的方式类似,都是在子组件上绑定 Father.vue 定义好的数据,这是绑定一个数据的例子:

<!-- Father.vue -->
<template>
  <Child v-model:username="userInfo.name" />
</template>

Vue 3 可以直接绑定 v-model ,而无需在子组件指定 model 选项,并且 Vue 3 的 v-model 需要使用英文冒号 : 指定要绑定的属性名,同时也支持绑定多个 v-model 。如果要绑定多个数据,写多个 v-model 即可:

<!-- Father.vue -->
<template>
  <Child
    v-model:uid="userInfo.id"
    v-model:username="userInfo.name"
    v-model:age="userInfo.age"
  />
</template>

一个 v-model 其实就是一个 prop ,它支持的数据类型和 prop 是一样的,所以子组件在接收数据的时候,完全按照 props 去定义就可以了。

配置emits

使用 props / emits ,如果要更新父组件的数据,还需要在父组件声明一个更新函数并绑定事件给子组件,才能够更新。

而使用 v-model / emits ,无需在父组件声明更新函数,只需要在子组件 Child.vue 里通过update: 前缀加上 v-model 的属性名这样的格式,即可直接定义一个更新事件:

// Child.vue
export default defineComponent({
  props: {
    uid: Number,
    username: String,
    age: Number,
  },
  // 注意这里的 `update:` 前缀
  emits: ['update:uid', 'update:username', 'update:age'],
})

ref / emits

父组件可以给子组件绑定ref 属性,然后通过 Ref 变量操作子组件的数据或者调用子组件里面的方法。先在<template /> 处给子组件标签绑定ref 属性:

<!-- Father.vue -->
<template>
  <Child ref="child" />
</template>

然后在<script /> 部分定义好对应的变量名称child (记得要 return 出来哦),即可通过该变量操作子组件上的变量或方法:

// Father.vue
import { defineComponent, onMounted, ref } from 'vue'
import Child from '@cp/Child.vue'

export default defineComponent({
  components: {
    Child,
  },
  setup() {
    // 给子组件定义一个 `ref` 变量
    const child = ref<InstanceType<typeof Child>>()

    // 请保证视图渲染完毕后再执行操作
    onMounted(async () => {
      // 执行子组件里面的 AJAX 请求函数
      await child.value!.queryList()

      // 显示子组件里面的弹窗
      child.value!.isShowDialog = true
    })

    // 必须 `return` 出去才可以给到 `<template />` 使用
    return {
      child,
    }
  },
})

需要注意的是,在子组件 Child.vue 里,变量和方法也需要在setup 里 return 出来才可以被父组件调用到。

爷孙组件通信

爷孙组件是比父子组件通信要更深层次的引用关系(也有称之为 “隔代组件” )。

C 组件被引入到 B 组件里, B 组件又被引入到 A 组件里渲染,此时 A 是 C 的爷爷级别(可能还有更多层级关系),它们之间的关系可以假设如下:

Grandfather.vue
└─Son.vue
  └─Grandson.vue

Grandson.vue 并非直接挂载在 Grandfather.vue 下面,他们之间还隔着至少一个 Son.vue (在实际业务中可能存在更多层级),如果使用 props ,只能一级组件一级组件传递下去,太繁琐。

描述文字

因此需要更直接的通信方式来解决这种问题,这一 Part 就是讲一讲 C 和 A 之间的数据传递,常用的方法有:

方案爷组件向孙组件孙组件向爷组件
provide / injectprovideinject
EventBusemit / onemit / on
Reactive State--
Vuex--
Pinia--

因为上下级的关系的一致性,爷孙组件通信的方案也适用于父子组件通信,只需要把爷孙关系换成父子关系即可,为了方便阅读,下面的爷组件统一叫 Grandfather.vue,子组件统一叫 Grandson.vue 。

provide / inject

这个通信方式也是有两部分:

  1. Grandfather.vue 通过 provide 向孙组件 Grandson.vue 提供数据和方法
  2. Grandson.vue 通过 inject 注入爷爷组件 Grandfather.vue 的数据和方法

无论组件层次结构有多深,发起 provide 的组件都可以作为其所有下级组件的依赖提供者。provide 和 inject 绑定并不是可响应的,这是刻意为之的,除非传入了一个可侦听的对象。

描述文字

发起 provide

在 Vue 3 , provide 需要导入并在setup 里启用,并且现在是一个全新的方法,每次要 provide 一个数据的时候,就要单独调用一次。provide 的 TS 类型如下:

// `provide` API 本身的类型
function provide<T>(key: InjectionKey<T> | string, value: T): void

// 入参 `key` 的其中一种类型
interface InjectionKey<T> extends Symbol {}

每次调用 provide 的时候都需要传入两个参数:

参数说明
key数据的名称
value数据的值

其中 key 一般使用string 类型就可以满足大部分业务场景,如果有特殊的需要(例如开发插件时可以避免和用户的业务冲突),可以使用InjectionKey<T> 类型,这是一个继承自 Symbol 的泛型:

import type { InjectionKey } from 'vue'
const key = Symbol() as InjectionKey<string>

一个具体案例如下:

// Grandfather.vue
import { defineComponent, provide, ref } from 'vue'

export default defineComponent({
  setup() {
    // 声明一个响应性变量并 provide 其自身
    // 孙组件获取后可以保持响应性
    const msg = ref('Hello World!')
    provide('msg', msg)

    // 只 provide 响应式变量的值
    // 孙组件获取后只会得到当前的值
    provide('msgValue', msg.value)

    // 声明一个方法并 provide
    function printMsg() {
      console.log(msg.value)
    }
    provide('printMsg', printMsg)
  },
})

接收 inject

在 Vue 3 , inject 和 provide 一样,也是需要先导入然后在setup 里启用,也是一个全新的方法,每次要 inject 一个数据的时候,也是要单独调用一次。

另外还有一个特殊情况需要注意,当 Grandson.vue 的父级、爷级组件都 provide 了相同名字的数据下来,那么在 inject 的时候,会优先选择离它更近的组件的数据。

根据不同的场景, inject 可以接受不同数量的入参,入参类型也各不相同。

默认情况下, inject API 的 TS 类型如下:

function inject<T>(key: InjectionKey<T> | string): T | undefined

每次调用时只需要传入一个参数:

参数类型说明
keystring与 provide 相对应的数据名称

在孙组件里 inject 爷组件 provide 下来的数据:

// Grandson.vue
import { defineComponent, inject } from 'vue'
import type { Ref } from 'vue'

export default defineComponent({
  setup() {
    // 获取响应式变量
    const msg = inject<Ref<string>>('msg')
    console.log(msg!.value)

    // 获取普通的字符串
    const msgValue = inject<string>('msgValue')
    console.log(msgValue)

    // 获取函数
    const printMsg = inject<() => void>('printMsg')
    if (typeof printMsg === 'function') {
      printMsg()
    }
  },
})

在每个 inject 都使用尖括号<> 添加了相应的 TS 类型,并且在调用变量的时候都进行了判断,这是因为默认的情况下, inject 除了返回指定类型的数据之外,还默认带上undefined 作为可能的值。

如果明确数据不会是undefined ,也可以在后面添加as 关键字指定其 TS 类型,这样 TypeScript 就不再因为可能出现undefined 而提示代码有问题:

// Grandson.vue
import { defineComponent, inject } from 'vue'
import type { Ref } from 'vue'

export default defineComponent({
  setup() {
    // 获取响应式变量
    const msg = inject('msg') as Ref<string>
    console.log(msg.value)

    // 获取普通的字符串
    const msgValue = inject('msgValue') as string
    console.log(msgValue)

    // 获取函数
    const printMsg = inject('printMsg') as () => void
    printMsg()
  },
})

inject API 还支持设置默认值,可以接受更多的参数。默认情况下,只需要传入第二个参数指定默认值即可,此时它的 TS 类型如下:

function inject<T>(key: InjectionKey<T> | string, defaultValue: T): T

对于不可控的情况,建议在 inject 时添加一个兜底的默认值,防止程序报错:

// Grandson.vue
import { defineComponent, inject, ref } from 'vue'
import type { Ref } from 'vue'

export default defineComponent({
  setup() {
    // 获取响应式变量
    const msg = inject<Ref<string>>('msg', ref('Hello'))
    console.log(msg.value)

    // 获取普通的字符串
    const msgValue = inject<string>('msgValue', 'Hello')
    console.log(msgValue)

    // 获取函数
    const printMsg = inject<() => void>('printMsg', () => {
      console.log('Hello')
    })
    printMsg()
  },
})

需要注意的是, inject 的什么类型的数据,其默认值也需要保持相同的类型。

inject API 在第二个 TS 类型的基础上,还有第三个 TS 类型,可以传入第三个参数:

function inject<T>(
  key: InjectionKey<T> | string,
  defaultValue: () => T,
  treatDefaultAsFactory?: false,
): T

当第二个参数是一个工厂函数,那么可以添加第三个值,将其设置为 true ,此时默认值一定会是其 return 的值。在 Grandson.vue 里新增一个 inject ,接收一个不存在的函数名,并提供一个工厂函数作为默认值:

// Grandson.vue
import { defineComponent, inject } from 'vue'

interface Food {
  name: string
  count: number
}

export default defineComponent({
  setup() {
    // 获取工厂函数
    const getFood = inject<() => Food>('nonexistentFunction', () => {
      return {
        name: 'Pizza',
        count: 1,
      }
    })
    console.log(typeof getFood) // function

    const food = getFood()
    console.log(food) // {name: 'Pizza', count: 1}
  },
})

此时因为第三个参数默认为 Falsy 值 ,所以可以得到一个函数作为默认值,并可以调用该函数获得一个 Food 对象。如果将第三个参数传入为true ,再运行程序则会在const food = getFood() 这一行报错。因为此时第三个入参告知 inject ,默认值是一个工厂函数,因此默认值不再是函数本身,而是函数的返回值,所以typeof getFood 得到的不再是一个function 而是一个object :

// Grandson.vue
import { defineComponent, inject } from 'vue'

interface Food {
  name: string
  count: number
}

export default defineComponent({
  setup() {
    // 获取工厂函数
    const getFood = inject<() => Food>(
      'nonexistentFunction',
      () => {
        return {
          name: 'Pizza',
          count: 1,
        }
      },
      true
    )
    console.log(typeof getFood) // object

    // 此时下面的代码无法运行
    // 报错 Uncaught (in promise) TypeError: getMsg is not a function
    const food = getFood()
    console.log(food)
  },
})

兄弟组件通信

兄弟组件是指两个组件都挂载在同一个 Father.vue 下,但两个组件之间并没有什么直接的关联,先看看它们的关系:

Father.vue
├─Brother.vue
└─LittleBrother.vue

这种层级关系下,如果组件之间要进行通信,目前通常有这两类选择:

  1. 【不推荐】先把数据传给 Father.vue ,再使用父子组件通信 方案处理
  2. 【推荐】借助全局组件通信 的方案达到目的

下面的内容将进入全局组件通信的讲解。

全局组件通信

全局组件通信是指项目下两个任意组件,不管是否有直接关联(例如父子关系、爷孙关系)都可以直接进行交流的通信方案。

举个例子,像下面这种项目结构, B2.vue 可以采用全局通信方案直接向 D2.vue 发起交流,而无需经过它们各自的父组件。

A.vue
├─B1.vue
├───C1.vue
├─────D1.vue
├─────D2.vue
├───C2.vue
├─────D3.vue
└─B2.vue

常用的方法有:

方案发起方接收方
EventBusemiton
Reactive State--
Vuex--
Pinia--

EventBus

EventBus 通常被称之为 “全局事件总线” ,是用在全局范围内通信的一个常用方案;Vue 3 应用实例不再实现事件触发接口,因此移除了$on 、$off 和$once 这几个事件 API ,无法像 Vue 2 一样利用 Vue 实例创建 EventBus 。

以 mitt 为例,示范如何创建一个 Vue 3 的 EventBus ,首先需要安装它。

npm i mitt

然后在 src/libs 文件夹下,创建一个名为 eventBus.ts 的文件,文件内容和 Vue 2 的写法其实是一样的,只不过是把 Vue 实例换成了 mitt 实例。

// src/libs/eventBus.ts
import mitt from 'mitt'
export default mitt()

接下来就可以定义通信的相关事件了,常用的 API 和参数如下:

方法名称作用
on注册一个侦听事件,用于接收数据
emit调用方法发起数据传递
off用来移除侦听事件

on 的参数:

参数类型作用
typestringsymbol
handlerfunction接收到数据之后要做什么处理的回调函数

这里的handler 建议使用具名函数,因为匿名函数无法销毁。

emit 的参数:

参数类型作用
typestringsymbol
dataany与 on 对应的,允许接收的数据

off 的参数:

参数类型作用
typestringsymbol
handlerfunction要被删除的,与 on 对应的 handler 函数名

创建和移除侦听事件

在需要暴露交流事件的组件里,通过on 配置好接收方法,同时为了避免路由切换过程中造成事件多次被绑定,从而引起多次触发,需要在适当的时机off 掉:

import { defineComponent, onBeforeUnmount } from 'vue'
import eventBus from '@libs/eventBus'

export default defineComponent({
  setup() {
    // 声明一个打招呼的方法
    function sayHi(msg = 'Hello World!') {
      console.log(msg)
    }

    // 启用侦听
    eventBus.on('sayHi', sayHi)

    // 在组件卸载之前移除侦听
    onBeforeUnmount(() => {
      eventBus.off('sayHi', sayHi)
    })
  },
})

调用侦听事件

在需要调用交流事件的组件里,通过emit 进行调用:

import { defineComponent } from 'vue'
import eventBus from '@libs/eventBus'

export default defineComponent({
  setup() {
    // 调用打招呼事件,传入消息内容
    eventBus.emit('sayHi', 'Hello')
  },
})

Reactive State

在 Vue 3 里,使用响应式的 reactive API 也可以实现一个小型的状态共享库,如果运用在一个简单的 H5 活动页面这样小需求里,完全可以满足使用。

创建状态中心

首先在 src 目录下创建一个 state 文件夹,并添加一个 index.ts 文件,写入以下代码:

// src/state/index.ts
import { reactive } from 'vue'

// 如果有多个不同业务的内部状态共享
// 使用具名导出更容易维护
export const state = reactive({
  // 设置一个属性并赋予初始值
  message: 'Hello World',

  // 添加一个更新数据的方法
  setMessage(msg: string) {
    this.message = msg
  },
})

设定状态更新逻辑

接下来在一个组件 Child.vue 的<script /> 里添加以下代码,分别进行了以下操作:

  1. 打印初始值
  2. 对 state 里的数据启用侦听器
  3. 使用 state 里的方法更新数据
  4. 直接更新 state 的数据
// Child.vue
import { defineComponent, watch } from 'vue'
import { state } from '@/state'

export default defineComponent({
  setup() {
    console.log(state.message)
    // Hello World

    // 因为是响应式数据,所以可以侦听数据变化
    watch(
      () => state.message,
      (val) => {
        console.log('Message 发生变化:', val)
      },
    )

    setTimeout(() => {
      state.setMessage('Hello Hello')
      // Message 发生变化: Hello Hello
    }, 1000)

    setTimeout(() => {
      state.message = 'Hi Hi'
      // Message 发生变化: Hi Hi
    }, 2000)
  },
})

观察全局状态变化

继续在另外一个组件 Father.vue 里写入以下代码,导入 state 并在<template /> 渲染其中的数据:

<!-- Father.vue -->
<template>
  <div>{{ state.message }}</div>
  <Child />
</template>

<script lang="ts">
import { defineComponent } from 'vue'
import Child from '@cp/Child.vue'
import { state } from '@/state'

export default defineComponent({
  components: {
    Child,
  },
  setup() {
    return {
      state,
    }
  },
})
</script>

可以观察到当 Child.vue 里的定时器执行时, Father.vue 的视图也会同步得到更新。

Vuex

Vuex 是 Vue 生态里面非常重要的一个成员,运用于状态管理模式。它也是一个全局的通信方案,对比 EventBus,Vuex 的功能更多,更灵活,但对应的学习成本和体积也相对较大,通常大型项目才会用上 Vuex 。

Pinia 已经成为 Vue 生态最新的官方状态管理库,不仅适用于 Vue 3 ,也支持 Vue 2 ,而 Vuex 将进入维护状态,不再增加新功能, Vue 官方强烈建议在新项目中使用 Pinia 。

Pinia

Pinia 和 Vuex 一样,也是 Vue 生态里面非常重要的一个成员,也都是运用于全局的状态管理。但面向 Componsition API 而生的 Pinia ,更受 Vue 3 喜爱,已被钦定为官方推荐的新状态管理工具。下一部分会对其进行详细介绍。

全局状态管理

Pinia的安装和启用

Pinia 目前还没有被广泛的默认集成在各种脚手架里,所以如果原来创建的项目没有 Pinia ,则需要手动安装它:

# 需要 cd 到的项目目录下
npm install pinia

查看的 package.json ,看看里面的dependencies 是否成功加入了 Pinia 和它的版本号(下方是示例代码,以实际安装的最新版本号为准),从而验证Pinia是否集成到的项目里:

{
  "dependencies": {
    "pinia": "^2.0.11"
  }
}

然后打开src/main.ts 文件,添加下面那两行有注释的新代码:

import { createApp } from 'vue'
import { createPinia } from 'pinia' // 导入 Pinia
import App from '@/App.vue'

createApp(App)
  .use(createPinia()) // 启用 Pinia
  .mount('#app')

也可以通过 Create Preset 创建新项目(选择 vue 技术栈进入,选择 vue3-ts-vite 模板),可以得到一个集成常用配置的项目启动模板,该模板现在使用 Pinia 作为全局状态管理工具。

状态树结构

开始写代码之前,先来看一个对比,直观的了解 Pinia 的状态树构成,才能在后面的环节更好的理解每个功能的用途。

作用Vue ComponentVuexPinia
数据管理datastatestate
数据计算computedgettersgetters
行为方法methodsmutations / actionsactions

可以看到 Pinia 的结构和用途都和 Vuex 与 Component 非常相似,并且 Pinia 相对于 Vuex ,在行为方法部分去掉了 mutations (同步操作)和 actions (异步操作)的区分,更接近组件的结构,入门成本会更低一些。

下面来创建一个简单的 Store ,开始用 Pinia 来进行状态管理。

创建 Store

Pinia 的核心也是称之为 Store 。参照 Pinia 官网推荐的项目管理方案,也是先在src 文件夹下创建一个stores 文件夹,并在里面添加一个index.ts 文件,然后就可以来添加一个最基础的 Store 。Store 是通过defineStore 方法来创建的,它有两种入参形式:

形式1:接收两个参数

接收两个参数,第一个参数是 Store 的唯一 ID ,第二个参数是 Store 的选项:

// src/stores/index.ts
import { defineStore } from 'pinia'

export const useStore = defineStore('main', {
  // Store 选项...
})

形式2:接收一个参数

接收一个参数,直接传入 Store 的选项,但是需要把唯一 ID 作为选项的一部分一起传入:

// src/stores/index.ts
import { defineStore } from 'pinia'

export const useStore = defineStore({
  id: 'main',
  // Store 选项...
})

不论是哪种创建形式,都必须为 Store 指定一个唯一 ID 。另外可以看到这里把导出的函数名命名为useStore ,以use 开头是 Vue 3 对可组合函数的一个命名约定。并且使用的是export const 而不是export default,这样在使用的时候可以和其他的 Vue 组合函数保持一致,都是通过import { xxx } from 'xxx' 来导入。

如果有多个 Store ,可以分模块管理,并根据实际的功能用途进行命名( e.g.useMessageStore 、useUserStore 、useGameStore … )。

管理 state

给 Store 添加 state

通过一个箭头函数的形式来返回数据,并且能够正确的帮推导 TypeScript 类型:

// src/stores/index.ts
import { defineStore } from 'pinia'

export const useStore = defineStore('main', {
  // 先定义一个最基本的 message 数据
  state: () => ({
    message: 'Hello World',
  }),
  // ...
})

需要注意一点的是,如果不显式 return ,箭头函数的返回值需要用圆括号 () 套起来,这个是箭头函数的要求。所以相当于这样写:

// ...
export const useStore = defineStore('main', {
  state: () => {
    return {
      message: 'Hello World',
    }
  },
  // ...
})

手动指定数据类型

虽然 Pinia 会帮推导 TypeScript 的数据类型,但有时候可能不太够用,比如下面这段代码,请留意代码注释的说明:

// ...
export const useStore = defineStore('main', {
  state: () => {
    return {
      message: 'Hello World',
      // 添加了一个随机消息数组
      randomMessages: [],
    }
  },
  // ...
})

randomMessages 的预期应该是一个字符串数组string[] ,但是这个时候 Pinia 会帮推导成never[] ,那么类型就对不上了。这种情况下就需要手动指定 randomMessages 的类型,可以通过as 来指定:

// ...
export const useStore = defineStore('main', {
  state: () => {
    return {
      message: 'Hello World',
      // 通过 as 关键字指定 TS 类型
      randomMessages: [] as string[],
    }
  },
  // ...
})

或者使用尖括号<> 来指定,这两种方式是等价的:

// ...
export const useStore = defineStore('main', {
  state: () => {
    return {
      message: 'Hello World',
      // 通过尖括号指定 TS 类型
      randomMessages: <string[]>[],
    }
  },
  // ...
})

获取和更新 state

获取 state 有多种方法,略微有区别,但相同的是,他们都是响应性的。

使用 store 实例

对于Pinia,数据直接是挂在store 上的,而不是store.state 上的。所以,可以直接通过store.message 直接调用 state 里的数据:

import { defineComponent } from 'vue'
import { useStore } from '@/stores'

export default defineComponent({
  setup() {
    // 像 useRouter 那样定义一个变量拿到实例
    const store = useStore()

    // 直接通过实例来获取数据
    console.log(store.message)

    // 这种方式需要把整个 store 给到 template 去渲染数据
    return {
      store,
    }
  },
})

但一些比较复杂的数据这样写会很长,所以有时候更推荐用下面介绍的 computed API 和 storeToRefs API 等方式来获取。

使用 computed API

现在 state 里已经有定义好的数据了,下面这段代码是在 Vue 组件里导入的 Store ,并通过计算数据computed 拿到里面的message 数据传给 template 使用:

<script lang="ts">
import { computed, defineComponent } from 'vue'
import { useStore } from '@/stores'

export default defineComponent({
  setup() {
    // 像 useRouter 那样定义一个变量拿到实例
    const store = useStore()

    // 通过计算拿到里面的数据
    const message = computed(() => store.message)
    console.log('message', message.value)

    // 传给 template 使用
    return {
      message,
    }
  },
})
</script>

如果要更新数据:

  1. 可以通过提前定义好的 Store Actions 方法进行更新。
  2. 在定义 computed 变量的时候,配置好setter的行为:
    // 其他代码和上一个例子一样,这里省略...
    
    // 修改:定义 computed 变量的时候配置 getter 和 setter
    const message = computed({
        // getter 还是返回数据的值
        get: () => store.message,
        // 配置 setter 来定义赋值后的行为
        set(newVal) {
            store.message = newVal
        },
    })
    
    // 此时不再抛出 Write operation failed: computed value is readonly 的警告
    message.value = 'New Message.'
    
    // store 上的数据已成功变成了 New Message.
    console.log(store.message)

使用 storeToRefs API

这是一个专门为 Pinia Stores 设计的 API ,类似于 toRefs ,区别在于,它会忽略掉 Store 上面的方法和非响应性的数据,只返回 state 上的响应性数据:

import { defineComponent } from 'vue'
import { useStore } from '@/stores'

// 记得导入这个 API
import { storeToRefs } from 'pinia'

export default defineComponent({
  setup() {
    const store = useStore()

    // 通过 storeToRefs 来拿到响应性的 message
    const { message } = storeToRefs(store)
    console.log('message', message.value)

    return {
      message,
    }
  },
})

通过这个方式拿到的 message 变量是一个 Ref 类型的数据,所以可以像普通的 ref 变量一样进行读取和赋值。

// 直接赋值即可
message.value = 'New Message.'

// store 上的数据已成功变成了 New Message.
console.log(store.message)

使用 toRefs API

如 使用storeToRefs API 部分所说,该 API 本身的设计就是类似于 toRefs ,所以也可以直接用 toRefs 把 state 上的数据转成 ref 变量:

// 注意 toRefs 是 vue 的 API ,不是 Pinia
import { defineComponent, toRefs } from 'vue'
import { useStore } from '@/stores'

export default defineComponent({
  setup() {
    const store = useStore()

    // 跟 storeToRefs 操作都一样,只不过用 Vue 的这个 API 来处理
    const { message } = toRefs(store)
    console.log('message', message.value)

    return {
      message,
    }
  },
})

像上面这样,对 store 执行 toRefs 会把 store 上面的 getters 、 actions 也一起提取,如果只需要提取 state 上的数据,可以这样做:

// 只传入 store.$state
const { message } = toRefs(store.$state)

使用 toRef API

toRef 是 toRefs 的兄弟 API ,一个是只转换一个字段,一个是转换所有字段,所以它也可以用来转换 state 数据变成 ref 变量:

// 注意 toRef 是 vue 的 API ,不是 Pinia
import { defineComponent, toRef } from 'vue'
import { useStore } from '@/stores'

export default defineComponent({
  setup() {
    const store = useStore()

    // 遵循 toRef 的用法即可
    const message = toRef(store, 'message')
    console.log('message', message.value)

    return {
      message,
    }
  },
})

使用 actions 方法

Pinia 所有操作都集合为 action ,无需区分同步和异步,按照平时的函数定义即可更新 state ,具体操作详见管理 actions一节。

批量更新 state

获取和更新state 部分说的都是如何修改单个 state 数据,那么有时候要同时修改很多个,会显得比较繁琐。

Pinia 也提供了一个$patch API 用于同时修改多个数据,它接收一个参数:

参数类型语法
partialState对象 / 函数store.$patch(partialState)

传入一个对象

当参数类型为对象时,key 是要修改的 state 数据名称,value 是新的值(支持嵌套传值),用法如下:

// 继续用前面的数据,这里会打印出修改前的值
console.log(JSON.stringify(store.$state))
// 输出 {"message":"Hello World","randomMessages":[]}

/**
 * 注意这里,传入了一个对象
 */
store.$patch({
  message: 'New Message',
  randomMessages: ['msg1', 'msg2', 'msg3'],
})

// 这里会打印出修改后的值
console.log(JSON.stringify(store.$state))
// 输出 {"message":"New Message","randomMessages":["msg1","msg2","msg3"]}

对于简单的数据,直接修改成新值是非常好用的。

但有时候并不单单只是修改,而是要对数据进行拼接、补充、合并等操作,相对而言开销就会很大,这种情况下,更适合传入一个函数 来处理。

传入一个函数

当参数类型为函数时,该函数会有一个入参state ,是当前实例的 state ,等价于store.$state ,用法如下:

// 这里会打印出修改前的值
console.log(JSON.stringify(store.$state))
// 输出 {"message":"Hello World","randomMessages":[]}

/**
 * 注意这里,这次是传入了一个函数
 */
store.$patch((state) => {
  state.message = 'New Message'

  // 数组改成用追加的方式,而不是重新赋值
  for (let i = 0; i < 3; i++) {
    state.randomMessages.push(`msg${i + 1}`)
  }
})

// 这里会打印出修改后的值
console.log(JSON.stringify(store.$state))
// 输出 {"message":"New Message","randomMessages":["msg1","msg2","msg3"]}

全量更新 state

虽然可以对所有数据都执行一次 “补丁更新” 来达到 “全量更新” 的目的,但 Pinia 也提供了一个更好的办法。从前面多次提到 state 数据可以通过store.$state 来拿到,而这个属性本身是可以直接赋值的。

state 上现在有messagerandomMessages 这两个数据,那么要全量更新为新的值,就这么操作:

store.$state = {
  message: 'New Message',
  randomMessages: ['msg1', 'msg2', 'msg3'],
}

注意必须遵循 state 原有的数据和对应的类型。

重置 state

Pinia 提供了一个$reset API 挂在每个实例上面,用于重置整棵 state 树为初始数据:

// 这个 store 是上面定义好的实例
store.$reset()

具体例子:

// 修改数据
store.message = 'New Message'
console.log(store.message) // 输出 New Message

// 3s 后重置状态
setTimeout(() => {
  store.$reset()
  console.log(store.message) // 输出最开始的 Hello World
}, 3000)

订阅 state

Pinia 提供了一个用于订阅 state 的$subscribe API 。

订阅 API 的 TS 类型

在了解这个 API 的使用之前,先看一下它的 TS 类型定义:

// $subscribe 部分的 TS 类型
// ...
$subscribe(
 callback: SubscriptionCallback<S>,
 options?: { detached?: boolean } & WatchOptions
): () => void
// ...

添加订阅

$subscribe API 的功能类似于 watch ,但它只会在 state 被更新的时候才触发一次,并且在组件被卸载时删除(参考:组件的生命周期)。

订阅API的TS类型 可以看到,它可以接受两个参数,第一个参数是必传的 callback 函数,一般情况下默认用这个方式即可,使用例子:

// 可以在 state 出现变化时,更新本地持久化存储的数据
store.$subscribe((mutation, state) => {
  localStorage.setItem('store', JSON.stringify(state))
})

这个 callback 里面有 2 个入参:

入参作用
mutation本次事件的一些信息
state当前实例的 state

其中 mutation 包含了以下数据:

字段
storeId发布本次订阅通知的 Pinia 实例的唯一 ID(由创建 Store 时指定)
type有 3 个值:返回direct 代表直接更改数据;返回patch object 代表是通过传入一个对象 更改;返回patch function 则代表是通过传入一个函数 更改
events触发本次订阅通知的事件列表
payload通过传入一个函数 更改时,传递进来的荷载信息,只有type 为patch object 时才有

如果不希望组件被卸载时删除订阅,可以传递第二个参数 options 用以保留订阅状态,传入一个对象。

可以简单指定为{ detached: true },也可以搭配 watch API 的选项一起用:

store.$subscribe(
  (mutation, state) => {
    // ...
  },
  { detached: true },
)

移除订阅

添加订阅 部分已了解过,默认情况下,组件被卸载时订阅也会被一并移除,但如果之前启用了 detached 选项,就需要手动取消了。前面在订阅API的TS类型 里提到,在启用 $subscribe API 之后,会有一个函数作为返回值,这个函数可以用来取消该订阅。

// 定义一个退订变量,它是一个函数
const unsubscribe = store.$subscribe(
  (mutation, state) => {
    // ...
  },
  { detached: true },
)

// 在合适的时期调用它,可以取消这个订阅
unsubscribe()

管理 getters

Pinia 的getters 是用来计算数据的。

给 Store 添加 getter

添加普通的 getter

继续用刚才的message ,来定义一个 Getter ,用于返回一句拼接好的句子:

// src/stores/index.ts
import { defineStore } from 'pinia'

export const useStore = defineStore('main', {
  state: () => ({
    message: 'Hello World',
  }),
  // 定义一个 fullMessage 的计算数据
  getters: {
    fullMessage: (state) => `The message is "${state.message}".`,
  },
  // ...
})

添加引用 getter 的 getter

有时候可能要引用另外一个 getter 的值来返回数据,这个时候不能用箭头函数了,需要定义成普通函数而不是箭头函数,并在函数内部通过this 来调用当前 Store 上的数据和方法。继续在上面的例子里,添加多一个emojiMessage 的 getter ,在返回fullMessage 的结果的同时,拼接多一串 emoji :

export const useStore = defineStore('main', {
  state: () => ({
    message: 'Hello World',
  }),
  getters: {
    fullMessage: (state) => `The message is "${state.message}".`,
    // 这个 getter 返回了另外一个 getter 的结果
    emojiMessage(): string {
      return `🎉🎉🎉 ${this.fullMessage}`
    },
  },
})

如果只写 JavaScript ,可能对这一条所说的限制觉得很奇怪,事实上用 JS 写箭头函数来引用确实不会报错,但如果用的是 TypeScript ,不按照这个写法,在 VSCode 提示和执行 TSC 检查的时候都会给抛出一条错误。

另外关于普通函数的 TS 返回类型,官方建议显式的进行标注,就像这个例子里的emojiMessage(): string 里的: string 。

给 getter 传递参数

getter 本身是不支持参数的,但和 Vuex 一样,支持返回一个具备入参的函数,用来满足需求。

import { defineStore } from 'pinia'

export const useStore = defineStore('main', {
  state: () => ({
    message: 'Hello World',
  }),
  getters: {
    // 定义一个接收入参的函数作为返回值
    signedMessage: (state) => {
      return (name: string) => `${name} say: "The message is ${state.message}".`
    },
  },
})

调用的时候是这样:

const signedMessage = store.signedMessage('Petter')
console.log('signedMessage', signedMessage)
// Petter say: "The message is Hello World".

这种情况下,这个 getter 只是调用的函数的作用,不再有缓存,如果通过变量定义了这个数据,那么这个变量也只是普通变量,不具备响应性。

// 通过变量定义一个值
const signedMessage = store.signedMessage('Petter')
console.log('signedMessage', signedMessage)
// Petter say: "The message is Hello World".

// 2s 后改变 message
setTimeout(() => {
  store.message = 'New Message'

  // signedMessage 不会变
  console.log('signedMessage', signedMessage)
  // Petter say: "The message is Hello World".

  // 必须这样再次执行才能拿到更新后的值
  console.log('signedMessage', store.signedMessage('Petter'))
  // Petter say: "The message is New Message".
}, 2000)

获取和更新 getter

getter 和 state 都属于数据管理,读取和赋值的方法是一样的,请参考上方获取和更新 state 一节的内容。

管理 actions

Pinia 只需要用actions 就可以解决各种数据操作,无需像 Vuex 一样区分为mutations / actions 两大类。

给 Store 添加 action

可以为当前 Store 封装一些可以开箱即用的方法,支持同步和异步。

// src/stores/index.ts
import { defineStore } from 'pinia'

export const useStore = defineStore('main', {
  state: () => ({
    message: 'Hello World',
  }),
  actions: {
    // 异步更新 message
    async updateMessage(newMessage: string): Promise<string> {
      return new Promise((resolve) => {
        setTimeout(() => {
          // 这里的 this 是当前的 Store 实例
          this.message = newMessage
          resolve('Async done.')
        }, 3000)
      })
    },
    // 同步更新 message
    updateMessageSync(newMessage: string): string {
      // 这里的 this 是当前的 Store 实例
      this.message = newMessage
      return 'Sync done.'
    },
  },
})

在 action 里,如果要访问当前实例的 state 或者 getter ,只需要通过this 即可操作,方法的入参完全不再受 Vuex 那样有固定形式的困扰。

调用 action

在 Pinia 中只要像普通的函数一样使用即可,不需要和 Vuex 一样调用 commit 或者 dispatch。

export default defineComponent({
  setup() {
    const store = useStore()
    const { message } = storeToRefs(store)

    // 立即执行
    console.log(store.updateMessageSync('New message by sync.'))

    // 3s 后执行
    store.updateMessage('New message by async.').then((res) => console.log(res))

    return {
      message,
    }
  },
})

添加多个 Store

到这里,对单个 Store 的配置和调用相信都已经清楚了,实际项目中会涉及到很多数据操作,还可以用多个 Store 来维护不同需求模块的数据状态。

这一点和 Vuex 的 Module 比较相似,目的都是为了避免状态树过于臃肿,但用起来会更为简单。

目录结构建议

建议统一存放在src/stores 下面管理,根据业务需要进行命名,比如user 就用来管理登录用户相关的状态数据。

src
└─stores
 # 入口文件
  ├─index.ts
 # 多个 store
  ├─user.ts
  ├─game.ts
  └─news.ts

里面暴露的方法就统一以use 开头加上文件名,并以Store 结尾,作为小驼峰写法,比如user 这个 Store 文件里面导出的函数名就是:

// src/stores/user.ts
export const useUserStore = defineStore('user', {
  // ...
})

然后以index.ts 里作为统一的入口文件,index.ts 里的代码写为:

export * from './user'
export * from './game'
export * from './news'

这样在使用的时候,只需要从@/stores 里导入即可,无需写完整的路径,例如只需要这样:import { useUserStore } from '@/stores',而无需这样:import { useUserStore } from '@/stores/user'

在 Vue 组件 / TS 文件里使用

这里以一个比较简单的业务场景举例,希望能够方便的理解如何同时使用多个 Store 。

假设目前有一个userStore 是管理当前登录用户信息,gameStore 是管理游戏的信息,而 “个人中心” 这个页面需要展示 “用户信息” ,以及 “该用户绑定的游戏信息”,那么就可以这样:

import { defineComponent, onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
// 这里导入要用到的 Store
import { useUserStore, useGameStore } from '@/stores'
import type { GameItem } from '@/types'

export default defineComponent({
  setup() {
    // 先从 userStore 获取用户信息(已经登录过,所以可以直接拿到)
    const userStore = useUserStore()
    const { userId, userName } = storeToRefs(userStore)

    // 使用 gameStore 里的方法,传入用户 ID 去查询用户的游戏列表
    const gameStore = useGameStore()
    const gameList = ref<GameItem[]>([])
    onMounted(async () => {
      gameList.value = await gameStore.queryGameList(userId.value)
    })

    return {
      userId,
      userName,
      gameList,
    }
  },
})

切记每个 Store 的 ID 必须不同,如果 ID 重复,在同一个 Vue 组件 / TS 文件里定义 Store 实例变量的时候,会以先定义的为有效值,后续定义的会和前面一样。

Store 之间互相引用

如果在定义一个 Store 的时候,要引用另外一个 Store 的数据,也是很简单,回到那个 message 的例子,添加一个 getter ,它会返回一句问候语欢迎用户:

// src/stores/message.ts
import { defineStore } from 'pinia'

// 导入用户信息的 Store 并启用它
import { useUserStore } from './user'
const userStore = useUserStore()

export const useMessageStore = defineStore('message', {
  state: () => ({
    message: 'Hello World',
  }),
  getters: {
    // 这里就可以直接引用 userStore 上面的数据了
    greeting: () => `Welcome, ${userStore.userName}!`,
  },
})

假设现在userName 是 Petter ,那么会得到一句对 Petter 的问候:

const messageStore = useMessageStore()
console.log(messageStore.greeting) // Welcome, Petter!

专属插件的使用

Pinia 拥有非常灵活的可扩展性,有专属插件可以开箱即用满足更多的需求场景。

如何查找插件

插件有统一的命名格式pinia-plugin-* ,所以可以在 npmjs 上搜索这个关键词来查询目前有哪些插件已发布。

点击查询:pinia-plugin - npmjs

如何使用插件

这里以 pinia-plugin-persistedstate 为例,这是一个让数据持久化存储的 Pinia 插件。插件也是独立的 npm 包,需要先安装,再激活,然后才能使用。

激活方法会涉及到 Pinia 的初始化过程调整,这里不局限于某一个插件,通用的插件用法如下(请留意代码注释):

// src/main.ts
import { createApp } from 'vue'
import App from '@/App.vue'
import { createPinia } from 'pinia' // 导入 Pinia
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' // 导入 Pinia 插件

const pinia = createPinia() // 初始化 Pinia
pinia.use(piniaPluginPersistedstate) // 激活 Pinia 插件

createApp(App)
  .use(pinia) // 启用 Pinia ,这一次是包含了插件的 Pinia 实例
  .mount('#app')

使用前

Pinia 默认在页面刷新时会丢失当前变更的数据,没有在本地做持久化记录:

// 其他代码省略
const store = useMessageStore()

// 假设初始值是 Hello World
setTimeout(() => {
  // 2s 后变成 Hello World!
  store.message = store.message + '!'
}, 2000)

// 页面刷新后又变回了 Hello World

使用后

按照 persistedstate 插件的文档说明,在其中一个 Store 启用它,只需要添加一个persist: true 的选项即可开启:

// src/stores/message.ts
import { defineStore } from 'pinia'
import { useUserStore } from './user'

const userStore = useUserStore()

export const useMessageStore = defineStore('message', {
  state: () => ({
    message: 'Hello World',
  }),
  getters: {
    greeting: () => `Welcome, ${userStore.userName}`,
  },
  // 这是按照插件的文档,在实例上启用了该插件,这个选项是插件特有的
  persist: true,
})

回到的页面,现在这个 Store 具备了持久化记忆的功能了,它会从 localStorage 读取原来的数据作为初始值,每一次变化后也会将其写入 localStorage 进行记忆存储。

// 其他代码省略
const store = useMessageStore()

// 假设初始值是 Hello World
setTimeout(() => {
  // 2s 后变成 Hello World!
  store.message = store.message + '!'
}, 2000)

// 页面刷新后变成了 Hello World!!
// 再次刷新后变成了 Hello World!!!
// 再次刷新后变成了 Hello World!!!!

可以在浏览器查看到 localStorage 的存储变化,以 Chrome 浏览器为例,按 F12 ,打开 Application 面板,选择 Local Storage ,可以看到以当前 Store ID 为 Key 的存储数据。这是其中一个插件使用的例子,更多的用法请根据自己选择的插件的 README 说明操作。

关联路线图节点

关联成果

相关文章