全局状态的管理

本来这部分打算放在 组件之间的通信 里,里面也简单介绍了一下 Vuex ,但 Pinia 作为被官方推荐在 Vue 3 项目里作为全局状态管理的新工具,写着写着我觉得还是单独开一章来写会更方便阅读和理解。

官方推出的全局状态管理工具目前有 Vuexopen in new windowPiniaopen in new window ,两者的作用和用法都比较相似,但 Pinia 的设计更贴近 Vue 3 组合式 API 的用法。

TIP

本章内的大部分内容都会和 Vuex 作对比,方便从 Vuex 项目向 Pinia 的迁移。

关于 Pinia{new}

由于 Vuex 4.x 版本只是个过渡版,Vuex 4 对 TypeScript 和 Composition API 都不是很友好,虽然官方团队在 GitHub 已有讨论 Vuex 5open in new window 的开发提案,但从 2022-02-07 在 Vue 3 被设置为默认版本开始, Pinia 已正式被官方推荐作为全局状态管理的工具。

Pinia 支持 Vue 3 和 Vue 2 ,对 TypeScript 也有很完好的支持,延续本指南的宗旨,我们在这里只介绍基于 Vue 3 和 TypeScript 的用法。

点击访问:Pinia 官网open in new window

安装和启用{new}

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

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

查看你的 package.json ,看看里面的 dependencies 是否成功加入了 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')

到这里, Pinia 就集成到你的项目里了。

TIP

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

状态树的结构{new}

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

鉴于可能有部分同学之前没有用过 Vuex ,所以我加入了 Vue 组件一起对比( Options API 写法)。

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

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

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

创建 Store{new}

和 Vuex 一样, 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 选项...
})

TIP

不论是哪种创建形式,都必须为 Store 指定一个唯一 ID 。

另外可以看到我把导出的函数名命名为 useStore ,以 use 开头是 Vue 3 对可组合函数的一个命名规范。

并且使用的是 export const 而不是 export default (详见:命名导出和默认导出open in new window),这样在使用的时候可以和其他的 Vue 组合函数保持一致,都是通过 import { xxx } from 'xxx' 来导入。

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

管理 state{new}

在上一小节的 状态树的结构 这里我们已经了解过, Pinia 是在 state 里面定义状态数据。

给 Store 添加 state

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

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

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

需要注意一点的是,如果不显式 return ,箭头函数的返回值需要用圆括号 () 套起来,这个是箭头函数的要求(详见:返回对象字面量open in new window)。

所以相当于这样写:

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

我个人还是更喜欢加圆括号的简写方式。

TIP

可能有同学会问: Vuex 可以用一个对象来定义 state 的数据, Pinia 可以吗?

答案是:不可以! state 的类型必须是 state?: (() => {}) | undefined ,要么不配置(就是 undefined ),要么只能是个箭头函数。

手动指定数据类型

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

// ...
export const useStore = defineStore('main', {
  state: () => {
    return {
      message: 'Hello World',
      // 添加了一个随机消息数组
      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 有多种方法,略微有区别(详见下方各自的说明),但相同的是,他们都是响应性的。

WARNING

不能直接通过 ES6 解构的方式( e.g. const { message } = store ),那样会破坏数据的响应性。

使用 store 实例

用法上和 Vuex 很相似,但有一点区别是,数据直接是挂在 store 上的,而不是 store.state 上面!

TIP

e.g. Vuex 是 store.state.message , Pinia 是 store.message

所以,你可以直接通过 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 APIstoreToRefs API 等方式来获取。

在数据更新方面,在 Pinia 可以直接通过 Store 实例更新 state (这一点与 Vuex 有明显的不同,更改 Vuex 的 store 中的状态的唯一方法是提交 mutationopen in new window),所以如果你要更新 message ,只需要像下面这样,就可以更新 message 的值了!

store.message = 'New Message.'

使用 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>

使用 store 实例 以及 使用 storeToRefs API 不同,这个方式默认情况下无法直接更新 state 的值。

TIP

这里的定义的 message 变量是一个只有 getter ,没有 setter 的 ComputedRef 数据,所以它是只读的。

如果你要更新数据怎么办?

  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 还提供了一个 storeToRefs API 用于把 state 的数据转换为 ref 变量。

这是一个专门为 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,
    }
  },
})

详见 使用 toRefs 一节的说明,可以像普通的 ref 变量一样进行读取和赋值。

另外,像上面这样,对 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,
    }
  },
})

详见 使用 toRef 一节的说明,可以像普通的 ref 变量一样进行读取和赋值。

使用 actions 方法

在 Vuex ,如果想通过方法来操作 state 的更新,必须通过 mutation 来提交;而异步操作需要更多一个步骤,必须先通过 action 来触发 mutation ,非常繁琐!

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

批量更新 state

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

如果你写过 React 或者微信小程序,应该非常熟悉这些用法:

// 下面不是 Vue 的代码,不要在你的项目里使用

// React
this.setState({
  foo: 'New Foo Value',
  bar: 'New bar Value',
})

// 微信小程序
this.setData({
  foo: 'New Foo Value',
  bar: 'New bar Value',
})

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"]}

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

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

TIP

使用这个方式时, key 只允许是实例上已有的数据,不可以提交未定义的数据进去。

强制提交的话,在 TypeScript 会抛出错误, JavaScript 虽然不会报错,但实际上, Store 实例上面依然不会有这个新增的非法数据。

传入一个函数

当参数类型为函数时,该函数会有一个入参 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"]}

传入一个对象 比,不一定说就是哪种方式更好,通常要结合业务场景合理使用。

TIP

使用这个方式时,和 传入一个对象 一样只能修改已定义的数据,并且另外需要注意,传进去的函数只能是同步函数,不可以是异步函数!

如果还不清楚什么是同步和异步,可以阅读 同步和异步 JavaScript - MDNopen in new window 一文。

全量更新 state

批量更新 state 我们了解到可以用 store.$patch 方法对数据进行批量更新操作,不过如其命名,这种方式本质上是一种 “补丁更新” 。

虽然你可以对所有数据都执行一次 “补丁更新” 来达到 “全量更新” 的目的,但 Pinia 也提供了一个更好的办法。

从前面多次提到 state 数据可以通过 store.$state 来拿到,而这个属性本身是可以直接赋值的。

还是继续用上面的例子, state 上现在有 messagerandomMessages 这两个数据,那么要全量更新为新的值,就这么操作:

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

同样的,必须遵循 state 原有的数据和对应的类型。

TIP

该操作不会使 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

和 Vuex 一样, Pinia 也提供了一个用于订阅 state 的 $subscribe API 。

订阅 API 的 TS 类型

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

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

可以看到,它可以接受两个参数:

  1. 第一个入参是 callback 函数,必传
  2. 第二个入参是一些选项,可选

它还会返回一个函数,执行它可以用于移除当前订阅(源码有注释,这里我先省略,放在下面讲),下面来看看具体用法。

添加订阅

$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通过 传入一个函数 更改时,传递进来的荷载信息,只有 typepatch object 时才有

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

可以简单指定为 { detached: true }

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

也可以搭配 watch API 的选项一起用。

移除订阅

添加订阅 部分已了解过,默认情况下,组件被卸载时订阅也会被一并移除,但如果你之前启用了 detached 选项,就需要手动取消了。

前面在 订阅 API 的 TS 类型 里提到,在启用 $subscribe API 之后,会有一个函数作为返回值,这个函数可以用来取消该订阅。

用法非常简单,做一下简单了解即可:

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

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

跟 watch API 的机制非常相似, 它也是返回 一个取消监听的函数 用于移除指定的 watch 。

管理 getters{new}

状态树的结构 了解过, Pinia 的 getters 是用来计算数据的。

给 Store 添加 getter

TIP

如果对 Vue 的计算数据不是很熟悉或者没接触过的话,可以先阅读 数据的计算 这一节,以便有个初步印象,不会云里雾里。

添加普通的 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}".`,
  },
  // ...
})

Options API 的 Computed 写法一样,也是通过函数来返回计算后的值,但在 Pinia ,只能使用箭头函数,通过入参的 state 来拿到当前实例的数据。

添加引用 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 检查的时候都会给你抛出一条错误:

src/stores/index.ts:9:42 - error TS2339: 
Property 'fullMessage' does not exist on type '{ message: string; } & {}'.

9     emojiMessage: (state) => `🎉🎉🎉 ${state.fullMessage}`,
                                           ~~~~~~~~~~~


Found 1 error in src/stores/index.ts:9

另外关于普通函数的 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{new}

状态树的结构 提到了, 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 那样有固定形式的困扰。

TIP

在 action 里, this 是当前的 Store 实例,所以如果你的 action 方法里有其他函数也要调用实例,请记得写成 箭头函数open in new window 来提升 this 。

调用 action

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

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{new}

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

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

目录结构建议

建议统一存放在 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 实例变量的时候,会以先定义的为有效值,后续定义的会和前面一样。

如果先定义了 userStore :

// 假设两个 Store 的 ID 一样
const userStore = useUserStore()  // 是想要的 Store
const gameStore = useGameStore()  // 得到的依然是 userStore 的那个 Store

如果先定义了 gameStore :

// 假设两个 Store 的 ID 一样
const gameStore = useGameStore()  // 是想要的 Store
const userStore = useUserStore()  // 得到的依然是 gameStore 的那个 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!

专属插件的使用{new}

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

如何查找插件

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

点击查询: pinia-plugin - npmjsopen in new window

如何使用插件

这里以 pinia-plugin-persistedstateopen in new window 为例,这是一个让数据持久化存储的 Pinia 插件。

TIP

数据持久化存储,指页面关闭后再打开,浏览器依然可以记录之前保存的本地数据,例如:浏览器原生的 localStorageopen in new windowIndexedDBopen in new window ,或者是一些兼容多种原生方案并统一用法的第三方方案,例如: localForageopen in new window

插件也是独立的 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 说明操作。

本章结语

看完 Pinia 这一章,我感觉应该都回不去 Vuex 了,真的方便了太多!!!新项目建议直接用 Pinia ,老项目如果有计划迁移,可以和 Vuex 同时使用一段时间,然后再逐步替换。