状态管理

什么是状态管理

在前端开发中,状态(State) 指的是应用中可变化的数据,比如用户信息、购物车列表、页面切换的标签页、表单输入值、全局主题配置等。

状态管理 则是对这些状态进行统一的创建、读取、修改、监听和共享 的一套规则和工具,核心目标是解决状态分散、流转混乱、难以维护的问题。

Vue 项目中,组件间共享数据的原生方式有哪些?

  • 父子组件:props 向下传、emit 向上触发事件;
  • 跨层级 / 兄弟组件:依赖 EventBus 或父组件中转;
  • 全局数据:挂载到 Vue.prototypewindow 上。

这些在项目规模扩大后会暴露致命问题

  • 数据在多个组件间层层传递(“prop”),溯源和调试困难状态流转混乱:
  • 状态修改不可控:任何组件都能随意修改全局数据,出现 Bug 后无法定位修改源头
  • 状态同步问题:多个组件依赖同一状态时,修改后无法自动同步更新
  • 逻辑分散:与状态相关的业务逻辑(如登录、购物车计算)散落在各个组件中,代码复用性差

所以说,状态管理在相对大型的前端项目中是必须的

状态管理在前端的必要性

https://blog.csdn.net/2301_80216352/article/details/155459782

从一个具体的场景来理解为什么要使用状态管理的库。 假设我们正在开发一个电商网站,这个网站有很多不同的页面和组件:首页展示商品列表,商品详情页面显示具体信息,购物车页面显示用户选择的商品,还有顶部的导航栏显示购物车中商品的数量。

在传统的组件开发模式中,每个组件管理自己的数据。比如购物车组件有自己的商品列表数据,商品详情页面有自己的商品数据,导航栏组件需要显示购物车商品数量。问题来了:当用户在商品详情页面点击”加入购物车”按钮时,购物车页面和导航栏需要同时更新。如果每个组件都维护自己的数据副本,我们就需要在多个地方重复更新数据,这很容易导致数据不一致。

状态分散在各个组件中,当多个组件需要相同的数据时,数据同步变得复杂且容易出错。特别是当组件层级很深,或者组件之间没有直接的父子关系时,数据传递变得非常困难。

Pinia 提供的是一种集中式状态管理方案。想象一下,我们有一个中央仓库(store),所有的数据都集中存放在这里。任何组件需要数据时,都从这个中央仓库获取。当数据发生变化时,所有使用这个数据的组件都会自动更新。就像是一家公司的所有部门都从同一个数据中心获取信息,确保每个人看到的数据都是一致的。

那么什么情况下需要使用 Pinia 呢?当两个或两个以上的组件需要共享相同的数据时,就应该考虑使用 Pinia。比如用户登录信息、购物车数据、全局的主题设置等。但是,并不是所有的状态都需要放到 Pinia 中。比如一个弹窗组件是否显示,这种只影响单个组件 UI 的状态,完全可以在组件内部管理。区分的关键在于:这个状态是否被多个不相关的组件使用?是否需要跨页面持久化?如果是,那么应该使用 Pinia;如果只在一个组件内部使用,那么使用组件的本地状态就足够了。

认识Vue的状态管理库Pinia

Vue 官方曾主推 Vuex 作为状态管理库(Vue2 时代),但 Vue3 发布后,官方更推荐 Pinia(已成为 Vue 官方状态管理库),Pinia 解决了 Vuex 的诸多痛点,且更贴合 Vue3 的 Composition API。

Pinia(发音 /ˈpiːnjə/,西班牙语 “菠萝”)是 Vue 官方推荐的状态管理库,适用于 Vue2 和 Vue3,核心特点是:

  • 抛弃 Vuex 的 Mutation 概念,简化状态修改逻辑;
  • 天然支持 TypeScript,类型推断完善;
  • 支持 Vue3 的 Composition API 和 Vue2 的 Options API;
  • 无嵌套模块限制,以 “Store” 为核心,结构更扁平;
  • 体积极小(约 1KB),性能优异;
  • 完全兼容 Vue Devtools(支持时间旅行、状态快照)。

安装 Pinia

1
2
npm install pinia
# 或 yarn add pinia

创建 Pinia 实例并挂载到 Vue 应用

1
2
3
4
5
6
7
8
9
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
const pinia = createPinia() // 创建 Pinia 实例
app.use(pinia) // 挂载到 Vue 应用
app.mount('#app')

Pinia 本身不支持持久化,需配合插件 pinia-plugin-persistedstate

安装:npm install pinia-plugin-persistedstate

配置

1
2
3
4
5
6
// src/main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate) // 注册插件

在 Store 中启用持久化

1
2
3
4
5
6
7
8
9
10
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
persist: true, // 全局持久化(默认存储到 localStorage)
// 自定义配置
// persist: {
// key: 'my-counter', // 存储的 key
// storage: sessionStorage, // 存储介质(sessionStorage)
// paths: ['count'] // 只持久化 count 字段
// }
})

一般来说,状态管理的相关文件都会放到名为 store 的文件夹,这个文件夹将存放所有的 store 文件。

创建 src/stores/counter.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { defineStore } from 'pinia'

// 定义并导出 Store,参数1:Store 唯一标识(必须唯一),参数2:配置对象
export const useCounterStore = defineStore('counter', {
// 1. 状态:返回初始值的函数(避免共享引用)
state: () => ({
count: 0,
title: 'Pinia 计数器'
}),

// 2. 计算属性:基于 state 派生,自动缓存
getters: {
doubleCount: (state) => state.count * 2, // 基础用法
// 访问其他 getter 或 state(用 this,需指定返回值类型)
doubleCountPlusOne(): number {
return this.doubleCount + 1
}
},

// 3. 方法:支持同步/异步修改状态
actions: {
increment() {
this.count++ // 直接修改 state
},
decrement(num: number) {
this.count -= num
},
// 异步示例:请求数据后修改状态
async fetchData() {
const res = await fetch('https://api.example.com/count')
const data = await res.json()
this.count = data.count
}
}
})

在组件中使用 Store

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<!-- src/components/Counter.vue -->
<template>
<div>
<h2>{{ counterStore.title }}</h2>
<p>当前计数:{{ counterStore.count }}</p>
<p>双倍计数:{{ counterStore.doubleCount }}</p>
<p>双倍计数+1:{{ counterStore.doubleCountPlusOne }}</p>
<button @click="counterStore.increment()">+1</button>
<button @click="counterStore.decrement(2)">-2</button>
<button @click="handleReset">重置状态</button>
</div>
</template>

<script setup lang="ts">
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia' // 辅助函数:解构 state/getter 并保持响应式

// 1. 获取 Store 实例(注意:直接解构会丢失响应式!)
const counterStore = useCounterStore()

// 直接访问 store.xxx 是响应式的,但解构会丢失响应式,需用 storeToRefs 辅助函数,因为 Pinia 的 state 基于 Vue 的 reactive 实现
// 2. 解构 state/getter 并保留响应式(推荐)
const { count, doubleCount } = storeToRefs(counterStore)

// 3. 重置 Store 状态(Pinia 内置方法)
const handleReset = () => {
counterStore.$reset()
}

// 4. 批量修改状态($patch 方法,性能更优)
// counterStore.$patch({ count: 10, title: '新标题' })
// 或函数式修改(适合复杂逻辑)
// counterStore.$patch((state) => {
// state.count += 5
// state.title = '批量修改'
// })
</script>

使用Pinia

定义和使用Store

Store是什么

Pinia 的核心是 Store(仓库),每个 Store 是一个独立的状态容器,可理解为 “全局组件”,包含:

概念 作用 类比 Vue 组件
state 存储核心状态(唯一数据源) data
getters 基于 state 计算派生的值(缓存) computed
actions 修改状态的方法(支持同步 / 异步) methods

往详细了说,,Store(仓库) 是状态管理的核心载体,用于集中存储和管理应用中需要跨组件共享的数据、派生状态以及修改状态的方法。它可以理解为一个 “全局的响应式容器”,独立于组件之外,却能被任意组件访问和修改,从而实现组件间的状态共享和协同。

Store 的核心特性如下

  1. 独立性:每个 Store 通过唯一的 id 标识(如 wordbookcounter),彼此隔离,避免状态污染。
  2. 响应式:Store 中的状态基于 Vue 的响应式 API(refreactive)实现,当状态变化时,依赖该状态的组件会自动更新。
  3. 可访问性:任何组件都可以引入并使用 Store,无需通过 “props 层层传递” 或 “事件冒泡” 等方式共享数据。
  4. 完整性:包含状态(state)、计算属性(getters)和方法(actions),覆盖状态管理的完整需求。

其中

  • 状态(State)

    • 定义:使用 Vue 的响应式 API(refreactive)声明,是 Store 的 “数据源”,存储原始数据。

    • 作用:保存应用中需要共享的核心数据(如单词列表、计数器数值)。

    • 示例:

      1
      2
      3
      4
      5
      // counter.ts 中的状态
      const count = ref(0); // 计数器数值

      // wordbook.ts 中的状态
      const words = ref<Word[]>([...]); // 单词列表
      • 这里的 countwords 都是 Store 的 state,它们是响应式的,修改后会触发依赖更新。
  • Getters(计算属性)

    • 定义:使用 computed() 声明,基于 state 派生的状态。

    • 作用:对 state 进行加工处理(如过滤、计算),并缓存结果(只有依赖的 state 变化时才会重新计算)。

    • 示例:

      1
      2
      3
      4
      5
      6
      // counter.ts 中的 getter
      const doubleCount = computed(() => count.value * 2); // 基于 count 计算双倍值

      // wordbook.ts 中的 getter
      const easyWords = computed(() => words.value.filter(w => w.difficulty === 'easy')); // 过滤简单单词
      const totalWords = computed(() => words.value.length); // 计算单词总数
      • 这里的 doubleCounteasyWords 等都是 getters,用于简化组件中对派生状态的访问。
  • Actions(方法)

    • 定义:直接声明函数,用于修改 state 或处理业务逻辑(支持同步和异步操作)。

    • 作用:集中管理状态的修改逻辑,避免在组件中直接操作 state 导致的混乱,便于维护和调试。

    • 示例

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      // counter.ts 中的 action
      function increment() {
      count.value++; // 修改 count 状态
      }

      // wordbook.ts 中的 action
      function addWord(word: Omit<Word, 'id' | 'createdAt'>) {
      const newWord: Word = { ...word, id: Date.now(), createdAt: new Date() };
      words.value.push(newWord); // 修改 words 状态
      }

      function removeWord(id: number) {
      const index = words.value.findIndex(w => w.id === id);
      if (index > -1) words.value.splice(index, 1); // 删除指定单词
      }

      这些方法都是 actions,组件通过调用它们来间接修改 state,确保状态变化可追踪

Pinia 的 Store 是一个 “自包含” 的状态管理单元,通过 state 存储数据、getters 处理派生状态、actions 管理状态修改,实现了状态的集中化、响应式和可维护性。

相比传统的 Vuex,Pinia 的 Store 更简洁(无需区分 mutationsactions)、类型支持更好,且与 Vue3 的组合式 API 无缝衔接

定义Store

Pinia 使用 defineStore() 函数创建 store,它的第一个参数要求是一个独一无二的名字,基本结构如下:

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

// 第一个参数是store的唯一标识(id)
export const useStoreName = defineStore('storeId', () => {
// 响应式状态(类似Vuex的state)
// 计算属性(类似Vuex的getters)
// 方法(类似Vuex的actions)

return {
// 暴露出去的状态、计算属性和方法
}
})

这个名字 ,也被用作 id ,是必须传入的, Pinia 将用它来连接 store 和 devtools。为了养成习惯性的用法,将返回的函数命名为 use… 是一个符合组合式函数风格的约定。

使用Vue组合式api定义Store,类似setup函数,我们可以传入一个函数,该函数定义了一些响应式属性和方法,并且返回一个带有我们想暴露出去的属性和方法的对象,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', () => {
// 响应式状态(state)
// state 函数返回一个对象,这个对象就是真正存储数据的地方
const count = ref(0)

// 计算属性(getters)
const doubleCount = computed(() => count.value * 2)

// 方法(actions)
function increment() {
count.value++
}

return { count, doubleCount, increment }
})

Setup Store 中:

  • ref() 就是 state 属性
  • computed() 就是 getters
  • function() 就是 actions

注意,要让 pinia 正确识别 state,你必须在 setup store 中返回 state 的所有属性。这意味着,你不能在 store 中使用私有属性。不完整返回会影响 SSR ,开发工具和其他插件的正常运行。

这是什么意思

必须完整返回所有响应式状态(state),不能有未暴露的 “私有” 状态。在 Setup Store 中,用 ref()/reactive() 定义的响应式变量就是 Store 的 state。这些状态需要被 Pinia 内部机制识别和管理。

“私有属性” 指的是在 Store 内部定义但没有通过 return 暴露的变量。例如:

1
2
3
4
5
6
7
8
9
10
11
12
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const secret = ref('私有属性') // 这是“私有属性”,未返回

const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}

// return { count, doubleCount, increment } // 遗漏了 secret
return { count, secret, doubleCount, increment } // 完整暴露所有 state 才是对的
})

使用Store

虽然我们前面定义了一个 store,但在我们使用 <script setup> 调用 useStore()(或者使用 setup() 函数,像所有的组件那样) 之前,store 实例是不会被创建的

也就是说,Store 的实例化需要调用 useStore()

1
2
3
4
5
<script setup>
import { useCounterStore } from '@/stores/counter'
// 调用后才创建 Store 实例,组件内可任意访问
const store = useCounterStore()
</script>

因为本质上, 我们前面定义的useCounterStore 本质是一个 “创建 Store 的函数”,不是 Store 本身;只有按下启动键(调用 useCounterStore()),才会做出菠萝(Store 实例);未调用前,Store 实例不存在,也就无法访问里面的 count/increment 等属性。但是一旦 store 被实例化,你可以直接访问在 store 的 stategettersactions 中定义的任何属性。

而且

  • 每个组件调用 useCounterStore(),拿到的是同一个实例(单例),这也是 “全局状态” 的核心 —— 所有组件共享同一个 Store;
  • 官方文档会提示 “不同文件定义不同 Store”,这是很有必要的:比如把用户 Store 放 user.ts、购物车放 cart.ts,好处是代码可拆分、TS 类型推断更准、打包时能按需加载。

这也就是为什么,我们定义 Store 的函数名,基本都会用 use 打头

而且注意,Store 是 reactive 包装的对象:直接解构会丢响应式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script setup>
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()

// ❌ 错误:解构后失去响应式
const { count, doubleCount } = store
// 即使后续 store.count 变了,这里的 count 还是初始值
setTimeout(() => {
store.increment() // store.count 从 0 → 1
console.log(count) // 仍然输出 0,因为解构的是“快照”
}, 1000)

// ✅ 正确:用 computed 保持响应式
const doubleValue = computed(() => store.doubleCount)
// 当 store.count 变化时,doubleValue 会自动更新
</script>

前面我们说过,Vue 的 reactive 有个限制,对 reactive 对象直接解构,会把属性从 “响应式对象” 中剥离出来,变成普通值(非响应式)。而Pinia 的 Store 底层就是用 reactive 包装的

正确解构我们通常使用 storeToRefs() 保留响应式

1
2
3
4
5
6
7
8
9
10
<script setup>
import { storeToRefs } from 'pinia'
const store = useCounterStore()

// ✅ 用 storeToRefs 解构,count/doubleCount 是 ref 类型(保留响应式)
const { count, doubleCount } = storeToRefs(store)

// ✅ action 可直接解构(因为 action 是函数,不需要响应式)
const { increment } = store
</script>

storeToRefs() 是 Pinia 专门解决 “解构丢响应式” 的工具,它的作用是:

  • 遍历 Store 中的属性,把响应式状态(state/getters) 转换成 ref 类型(比如 store.countref(0));
    • 只有需要把状态赋值给变量时,才需要 storeToRefs,不要处处用
  • 跳过 action(因为 action 是函数,本身不需要响应式);
  • 这样解构出来的 countref 对象,修改 store.count 时,count.value 会同步更新。
    • 解构后别忘记 .valuestoreToRefs 解构的是 ref 类型,在 <script> 中使用需要加 .value,模板中不用

为什么 action 可直接解构?

action 是 Store 上的 “方法”(比如 increment),方法的指向是固定的,不管怎么解构,调用 increment() 本质还是调用 store.increment(),所以不需要特殊处理。

而且你解构 action 会报错,const { increment } = storeToRefs(store) → increment 是函数,不是 ref/reactive

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<script setup>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'

const store = useCounterStore()

// 错误示范
const { count: badCount } = store
// 正确示范
const { count: goodCount, doubleCount } = storeToRefs(store)
// action 直接解构
const { increment } = store

// 1秒后修改 store.count
setTimeout(() => {
increment() // 等价于 store.increment()
console.log(badCount) // 0(非响应式,还是初始值)
console.log(goodCount.value) // 1(响应式,同步更新)
console.log(doubleCount.value) // 2(getter 也同步更新)
}, 1000)
</script>

总结,使用 Store 需要注意这些内容

  1. Store 是 “懒实例化” 的:调用 useXxxStore() 才会创建实例,不是定义了就存在;
  2. Store 是 reactive 包装的对象:直接解构会丢失响应式;
  3. 解构响应式状态用 storeToRefs(),action 可直接解构。

那么,完整的使用 store 的例子如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- src/components/CounterDemo.vue -->
<template>
<div class="demo-container">
<h2>Pinia 状态管理演示</h2>
<p>当前计数: {{ counterStore.sum }}</p>
</div>
</template>

<script setup>
// 导入 store
import { useCounterStore } from '@/store/counter'

// 使用 store
const counterStore = useCounterStore()

// 打印 store 对象,观察其结构
console.log('counterStore:', counterStore)
</script>

State

理解 State,及其适配TypeScript

前面说了,State 是 Store 的唯一数据源,承载了 Store 的基础响应式数据

Pinia 的 State 本质是:用 Vue 响应式 API(ref/reactive)定义的、存储 Store 基础数据的容器,是 Store 中所有数据的 “源头”(Getters 基于 State 派生,Actions 用于修改 State)

别忘了 Pinia 底层复用了 Vue 的响应式系统

  • Setup Store(组合式 API 写法)中,ref() 定义的是单个响应式状态,reactive() 定义的是对象型响应式状态;
  • Options Store(选项式写法)中,state 函数返回的对象会被 Pinia 自动包装成 reactive 对象;
  • 因此 State 的响应式行为和 Vue 组件内的 data/ref/reactive 完全一致,遵循 Vue 响应式规则(如对象属性新增需用 reactiveref、数组修改需用原生方法等)。

如果希望适配 TypeScript,需要开TS 的 strict 模式(严格模式),因为它包含了 noImplicitThis(禁止隐式的 this 类型)等规则,开启后 TS 能更精准地分析代码类型。

Pinia 依赖 TS 的类型推导能力,开启这些规则后,大部分简单场景下,Pinia 能自动识别 State 的类型,无需手动写类型注解

1
2
3
4
5
6
const useStore = defineStore('storeId', {
state: () => ({
count: 0, // 自动推断为 number 类型
name: '张三' // 自动推断为 string 类型
})
})

此时你在组件中访问 store.count,TS 会提示它是 number 类型,赋值非数字会报错,这就是 “自动推断” 的好处。

但是,在某些情况下,应该帮助它进行一些转换:

  • 初始化空列表userList: []

    如果直接写 userList: [],TS 会默认推断为 never[](空数组,不允许添加任何元素),这显然不符合我们的预期(我们希望它是 UserInfo[] 类型的数组)。

    所以需要手动标注类型:

    1
    userList: [] as UserInfo[], // 告诉 TS:这个空数组是 UserInfo 类型的数组
  • 尚未加载的数据(user: null

    如果直接写 user: null,TS 会推断为 null 类型(只能是 null,不能赋值为 UserInfo 对象),但我们的实际需求是:user 要么是 null(未加载),要么是 UserInfo 对象(加载完成)。

    所以需要手动标注联合类型:

    1
    user: null as UserInfo | null, // 告诉 TS:user 可以是 null 或 UserInfo 对象

更规范的写法就是用接口定义整个 State 类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 定义用户信息类型
interface UserInfo {
name: string
age: number
}

// 定义整个 State 的类型
interface State {
userList: UserInfo[]
user: UserInfo | null
}

// 定义 Store,指定 state 返回值类型为 State
const useUserStore = defineStore('user', {
state: (): State => {
return {
userList: [], // TS 知道这是 UserInfo[] 类型
user: null // TS 知道这是 UserInfo | null 类型
}
}
})

// 组件中使用
const store = useUserStore()

// ✅ 正确:符合类型定义
store.userList.push({ name: '张三', age: 20 })
store.user = { name: '李四', age: 25 }

// ❌ 错误:TS 会报错(类型不匹配)
store.userList.push({ name: '王五' }) // 缺少 age 字段
store.user = 123 // 不能把数字赋值给 UserInfo | null 类型

什么意思?说这么多是在说?

  1. Pinia 对 TS 友好:开启严格模式后,简单场景能自动推断 State 类型;
  2. 别忘了采用TS的规范写法:用接口定义整个 State 结构,让类型更清晰、易维护;

编写和访问 state

Pinia 支持两种 Store 写法,对应两种 State 定义方式,基本都是 “返回初始值 + 保证响应式”。

  • Setup Store(Vue3 组合式 API 风格)

    最常用的写法,直接用 Vue 的 ref/reactive 定义 State,无需包裹在 state 函数中

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    import { defineStore } from 'pinia'
    import { ref, reactive } from 'vue'

    export const useUserStore = defineStore('user', () => {
    // 单个值状态(ref 推荐,解构更友好)
    const userId = ref<string>('1001')
    const userName = ref<string>('张三')

    // 对象/数组状态(reactive 或 ref 都可)
    // 推荐:简单对象用 reactive,复杂对象/需解构用 ref
    const userInfo = reactive({
    age: 20,
    phone: '13800138000'
    })
    const roles = ref<string[]>(['admin', 'user'])

    // 必须返回所有 State(否则 Pinia 无法追踪,DevTools/SSR 异常)
    return { userId, userName, userInfo, roles }
    })
  • Options Store(Vue2 选项式风格,兼容 Vue2/Vue3)

    模仿 Vuex 的写法,通过 state 函数返回初始值(必须是函数,避免多个 Store 实例共享同一引用):

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

    export const useUserStore = defineStore('user', {
    // 必须是函数:每次实例化 Store 都会执行,返回新的状态对象
    state: () => ({
    userId: '1001',
    userName: '张三',
    userInfo: {
    age: 20,
    phone: '13800138000'
    },
    roles: ['admin', 'user']
    }),
    getters: { /* ... */ },
    actions: { /* ... */ }
    })

    只提到一下,不会细说

在写 State 部分的时候,注意

  • 必须用函数返回初始值(Setup Store 中 ref/reactive 本身是函数式创建,Options Store 中必须写 state: () => ({})):避免多个组件共享同一个状态对象的引用,导致状态污染;
  • 类型标注:推荐用 TypeScript 显式标注 State 类型(如 ref<string>('1001')),Pinia 会自动推导,但显式标注更清晰;这个下面会细说
  • 不要定义私有 State:所有 State 必须通过 return 暴露(Setup Store),否则 Pinia 无法追踪,DevTools 看不到、SSR 会丢失数据。

访问 state 相对来说没有那么多事

默认情况下,你可以通过 store 实例访问 state,const store = useStore(),直接对其进行读写。

1
2
const store = useStore()
store.count++

注意,新的属性如果没有在 state() 中被定义,则不能被添加,它必须包含初始状态。

例如:如果 secondCount 没有在 state() 中定义,我们无法执行 store.secondCount = 2

重置 state

有时候我们修改了state数据,想要将它还原,这个时候该怎么做呢?就比如用户填写了一部分表单,突然想重置为最初始的状态。

你可以通过调用 store 的 $reset() 方法将 state 重置为初始值。将当前 Store 的 state 覆盖为初始化时的原始状态副本

1
2
const store = useStore()
store.$reset()

Pinia 在创建 Store 时,会先缓存一份初始 state(比如定义 state() 函数返回的对象),调用 $reset() 时,会把这份缓存的初始状态深拷贝(或浅拷贝,取决于数据类型)覆盖到当前 state 上,从而实现 重置。

用户填写表单时,可能需要 “清空表单 / 恢复默认值”,比如新增表单的 “取消” 按钮:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<template>
<form>
<input v-model="store.form.name" placeholder="姓名" />
<input v-model="store.form.age" type="number" placeholder="年龄" />
<button @click="handleCancel">取消</button>
</form>
</template>

<script setup>
import { useStore } from '@/stores/form'
const store = useStore()

// 点击取消,重置表单状态
const handleCancel = () => {
store.$reset()
}
</script>

<!-- stores/form.js -->
import { defineStore } from 'pinia'

export const useStore = defineStore('form', {
state: () => ({
form: {
name: '', // 初始空值
age: 0 // 初始默认值
},
submitStatus: 'idle' // 初始状态
})
})

如果 Store 中有多个关联状态,需要一次性还原时,$reset() 比手动逐个赋值更高效:

1
2
3
4
5
6
7
8
// 手动重置(繁琐,易遗漏)
store.keyword = ''
store.page = 1
store.size = 10
store.status = 'all'

// $reset() 一键重置(简洁)
store.$reset()

$reset() 仅重置 state,不会改变 actionsgetters 的定义,也不会触发 actions(仅修改状态)。

如果使用 Setup Store(而非选项式),$reset() 不会自动生成,需要手动实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// stores/setupStore.js
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useSetupStore = defineStore('setup', () => {
// 1. 定义初始状态
const initialState = {
count: 0,
text: 'hello'
}

// 2. 基于初始状态创建响应式数据
const count = ref(initialState.count)
const text = ref(initialState.text)

// 3. 手动实现 $reset 方法
const $reset = () => {
count.value = initialState.count
text.value = initialState.text
}

// 4. 暴露方法
return { count, text, $reset }
})

注意,$reset()核心是 “还原到初始状态”,适用于全量重置场景,如果不需要重置全部 state,仅需重置部分状态,用手动赋值

变更 state

state 本质上是个响应式对象,基于 Vue 的 reactive/ref 实现,因此变更 state 的核心原则是保持响应式的前提下修改状态

官方提供了三类变更 state 的方式

  1. 直接修改(简单场景);

    直接通过 Store 实例访问并修改 state 属性,Pinia 会基于 Vue 的响应式系统自动追踪变更。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // 定义 Store(选项式)
    import { defineStore } from 'pinia'
    const useCounterStore = defineStore('counter', {
    state: () => ({
    count: 0,
    name: 'Vue',
    items: [{ id: 1, text: 'test' }]
    })
    })

    // 组件中使用
    const counterStore = useCounterStore()

    // 直接修改单个属性
    counterStore.count++ // 数字自增
    counterStore.name = 'Pinia' // 替换字符串

    // 直接修改嵌套属性(响应式仍生效)
    counterStore.items[0].text = 'updated'
  2. $patch 补丁对象(多属性批量修改);

    $patch 传入一个补丁对象(键为 state 属性名,值为新值),Pinia 会一次性合并所有变更,并将整个操作归为 devtools 中的一条记录

    1
    2
    3
    4
    5
    6
    7
    // 批量修改多个属性
    counterStore.$patch({
    count: counterStore.count + 2, // 基于旧值修改
    name: 'Vue + Pinia',
    // 注意:数组/对象直接替换才会生效(响应式要求)
    items: [...counterStore.items, { id: 2, text: 'new item' }]
    })

    但是,$patch集合(数组 / 对象)的限制:仅支持 “替换式修改”,无法直接修改集合的内部元素

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // ❌ 无效:补丁对象中直接修改数组元素,Pinia 无法追踪
    counterStore.$patch({
    items[0].text: 'error' // 语法错误,且响应式失效
    })

    // ✅ 正确:先创建新数组,再替换原数组
    counterStore.$patch({
    items: counterStore.items.map(item =>
    item.id === 1 ? { ...item, text: 'updated' } : item
    )
    })

    即使修改多个属性,$patch 也只会触发一次响应式更新(相比多次直接修改更高效),且 devtools 中仅显示一条 “patch” 记录

  3. $patch 函数(复杂操作,如数组 / 对象嵌套修改)。

    $patch 传入一个修改函数,函数接收 state 作为参数(直接操作原始 state),Pinia 会将函数内的所有操作归为 devtools 中的一条记录

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    // 1. 数组复杂操作
    counterStore.$patch((state) => {
    state.items.push({ id: 3, text: 'push item' }) // 新增元素
    state.items.splice(0, 1) // 删除第一个元素
    state.items = state.items.filter(item => item.id > 1) // 过滤数组
    })

    // 2. 嵌套对象修改
    counterStore.$patch((state) => {
    state.user = state.user || {} // 兜底判断
    state.user.info = state.user.info || {}
    state.user.info.age = 20 // 深层修改
    })

    // 3. 带逻辑的批量修改
    counterStore.$patch((state) => {
    if (state.count > 10) {
    state.count = 0
    state.name = 'reset'
    } else {
    state.count += 5
    }
    })

如果使用 Setup Store(组合式写法),变更逻辑完全一致,仅需注意 state 是通过 ref/reactive 定义的响应式数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Setup Store 定义
const useCartStore = defineStore('cart', () => {
// 响应式 state
const count = ref(0)
const items = ref([{ id: 1, num: 1 }])

return { count, items }
})

// 组件中修改
const cartStore = useCartStore()

// 1. 直接修改
cartStore.count++

// 2. $patch 补丁对象
cartStore.$patch({
count: cartStore.count + 2,
items: [...cartStore.items, { id: 2, num: 3 }]
})

// 3. $patch 函数
cartStore.$patch((state) => {
state.items.push({ id: 3, num: 2 })
state.items.forEach(item => item.num++)
})

注意,避免频繁修改 state,可将高频修改逻辑封装到 actions 中(便于复用和测试),而非直接在组件中修改;

替换 state

不能完全替换掉 store 的 state,因为那样会破坏其响应性。但是,你可以 patch 它。

1
2
3
4
// 这实际上并没有替换`$state`
store.$state = { count: 24 }
// 在它内部调用 `$patch()`:
store.$patch({ count: 24 })

你也可以通过变更 pinia 实例的 state 来设置整个应用的初始 state。

1
pinia.state.value = {}

订阅 state

Pinia 的 $subscribe() 是专门用于侦听 Store 中 state 变更的 API,其设计目标是替代普通的 watch() 实现更高效、更贴合状态管理的侦听逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const store = useStore() // 获取 Store 实例

// 订阅 state 变更
const unsubscribe = store.$subscribe(
(mutation, state) => {
// 回调函数:state 变更时执行
console.log('state 发生变更', mutation, state)
},
// 可选配置项(与 Vue 的 watch 配置一致)
{ flush: 'sync', detached: false }
)

// 手动取消订阅(如需)
// unsubscribe()

回调函数接收两个核心参数:mutation(变更元信息)和 state(变更后的最新 state)。

参数 类型 说明
mutation Object 变更的元数据,包含 3 个核心属性:- type:变更类型(direct/patch object/patch function)- storeId:当前 Store 的唯一 ID(如 cart)- payload:仅 patch object 类型时可用,即传入 $patch() 的补丁对象
state Object 变更后的完整 state(响应式对象)

例如:监听购物车 state 并且持久化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// stores/cart.js
export const useCartStore = defineStore('cart', {
state: () => ({
items: [],
total: 0
})
})

// 组件中订阅
const cartStore = useCartStore()
cartStore.$subscribe((mutation, state) => {
// 打印变更类型:比如直接修改是 'direct',$patch 对象是 'patch object'
console.log('变更类型:', mutation.type)
// 打印 Store ID:'cart'
console.log('Store ID:', mutation.storeId)
// 仅 patch object 时有值:比如 { total: 100 }
console.log('补丁内容:', mutation.payload)

// 持久化到本地存储
localStorage.setItem('cartState', JSON.stringify(state))
})

// 触发不同类型的变更,观察 mutation 变化
cartStore.total = 100 // mutation.type = 'direct',payload 无值
cartStore.$patch({ total: 200 }) // mutation.type = 'patch object',payload = { total: 200 }
cartStore.$patch((state) => { // mutation.type = 'patch function',payload 无值
state.items.push({ id: 1, price: 50 })
state.total += 50
})

$subscribe() 的第二个参数是配置对象,继承自 Vue 的 watch 配置(flushdeepimmediate),但是新增了 detached 专属配置:

detached:是否与组件分离

  • false(默认):订阅器与当前组件绑定,组件卸载时自动取消订阅(避免内存泄漏);
  • true:订阅器与组件分离,组件卸载后仍保留(适合全局监听,如全局状态持久化)。

如果需要监听所有 Store 的 state 变更(而非单个 Store),可直接监听 Pinia 实例的 state 属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { createPinia } from 'pinia'
const pinia = createPinia()

// 全局监听所有 Store 的 state 变更
watch(
pinia.state, // Pinia 实例的 state(包含所有 Store 的 state)
(state) => {
// state 是一个对象,key 为 Store ID,value 为对应 Store 的 state
console.log('全局 state 变更:', state)
// 持久化所有 Store 的 state 到本地存储
localStorage.setItem('piniaGlobalState', JSON.stringify(state))
},
{ deep: true, flush: 'post' } // 必须开启 deep: true(嵌套对象监听)
)

也可以取消订阅,手动取消使用返回值,$subscribe() 会返回一个取消订阅的函数,调用即可停止监听:

1
2
3
4
5
6
7
8
const unsubscribe = cartStore.$subscribe((mutation, state) => {
// 业务逻辑
})

// 手动取消(如按钮点击、组件卸载前)
const handleUnsubscribe = () => {
unsubscribe()
}

如果在组件的 setup() 中调用 $subscribe() 且未设置 detached: true,组件卸载时会自动取消订阅,无需手动处理:

1
2
3
4
5
6
7
8
9
<script setup>
import { useCartStore } from '@/stores/cart'

const cartStore = useCartStore()
// 组件卸载时自动取消
cartStore.$subscribe(() => {
// ...
})
</script>

Setup Store 中使用 $subscribe() 与选项式 Store 完全一致,无需额外配置

Getter

理解 Getter

Getter 完全等同于 store 的 state 的计算值。可以通过 defineStore() 中的 getters 属性来定义它们。推荐使用箭头函数,并且它将接收 state 作为第一个参数

这段话是官网上对 Getter 的准确描述,所以它是什么意思

可以把Getter和 Vue 组件里的computed(计算属性) 做直接类比

  • Vue 组件的computed是基于组件的data/props进行派生计算,有缓存特性,依赖变化时才重新计算。
  • Pinia 的Getter是基于 store 的state进行派生计算,同样有缓存特性,依赖的 state 变化时才重新计算

所以官网说 “Getter 完全等同于 store 的 state 的计算值”,本质就是:Getter 是针对 store 的 state 的 “计算属性”,作用和组件的 computed 一致,只是作用域在 store 中。而且大多数时候,getter 仅依赖 state。

而且 Getter 本质是 “计算属性”,不是函数,所以不能直接传参。

使用Getter

首先,在defineStore中通过getters属性定义,一般用箭头函数(因为箭头函数不绑定 this,能更清晰地接收 state 参数),并且它将接收 state 作为第一个参数:

1
2
3
4
5
6
7
8
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
}),
getters: {
doubleCount: (state) => state.count * 2,
},
})

那么,Getter 还支持很多种的定义形式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<script setup>
import { defineStore } from 'pinia'
import { useStore } from './store'

// 定义store
const useCounterStore = defineStore('counter', {
// 1. 定义state:存储原始数据
state: () => ({
count: 0,
list: [1, 2, 3, 4, 5]
}),
// 2. 定义getters:基于state的计算值
getters: {
// 推荐:箭头函数,接收state作为第一个参数
doubleCount: (state) => {
// 计算:count的两倍
return state.count * 2
},
// 复杂计算:过滤列表中大于3的数
filterList: (state) => {
return state.list.filter(item => item > 3)
},
// 访问其他getter:此时需要用函数形式,接收store实例为参数(或用this,不推荐)
doubleCountPlusOne: (state) => {
// 基于已有的doubleCount getter再计算
return state.doubleCount + 1
}
}
})

这是 Getter 的使用方式:定义好的 Getter 会挂载到 store 实例上,像访问普通属性一样直接使用(不需要加括号调用,因为它是计算属性,不是函数)。

1
2
3
4
5
6
7
8
9
10
// 在组件中使用
const counterStore = useCounterStore()
</script>

<template>
<div>原始count:{{ counterStore.count }}</div>
<div>count的两倍:{{ counterStore.doubleCount }}</div>
<div>两倍count加一:{{ counterStore.doubleCountPlusOne }}</div>
<div>过滤后的列表:{{ counterStore.filterList }}</div>
</template>

这里要注意:Getter 是计算属性,不是方法,所以直接写store.doubleCount,而不是store.doubleCount()

而且,想要使用另一个 store 的 getter 的话,那就直接在 getter 内使用就好:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { useOtherStore } from './other-store'

export const useStore = defineStore('main', {
state: () => ({
// ...
}),
getters: {
otherGetter(state) {
const otherStore = useOtherStore()
return state.localData + otherStore.data
},
},
})

Getter 如何访问其他 Getter

与计算属性一样,你也可以组合多个 getter。

除了通过state参数,还能通过this(常规函数)访问整个 store 实例,包括其他 Getter、state 甚至 actions。

“大多数时候,getter 仅依赖 state。不过,有时它们也可能会使用其他 getter。”

也就是,Getter 不仅能基于state计算,还能基于其他已定义的 Getter做二次计算。

比如:

  • 先定义doubleCount(基于 state.count 计算)
  • 再定义doublePlusOne(基于doubleCount再 + 1)

这时候就需要一种方式让doublePlusOne访问doubleCount,有两种实现方式:

  • state参数访问其他 Getter,这是很好的方式,且不会引发类型问题,而且无需关心 TypeScript 类型问题,返回类型会自动推断。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    getters: {
    doubleCount(state) {
    return state.count * 2;
    },
    // 用state参数访问其他Getter(state会包含所有getter)
    doublePlusOne(state) {
    return state.doubleCount + 1;
    }
    }
  • this访问整个 store 实例,如果用常规函数定义 Getter,this会指向整个 store 实例,因此可以通过this.doubleCount访问其他 Getter,也能通过this.count访问 state:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    getters: {
    doubleCount(state) {
    return state.count * 2;
    },
    // 常规函数,this指向store实例
    doublePlusOne() {
    // this可以访问:this.count(state)、this.doubleCount(其他getter)、甚至this.xxxAction(actions)
    return this.doubleCount + 1;
    }
    }

    但这种方式在TypeScript 中会有问题,这就引出了下一个规则。

TypeScript 下的类型标注问题

使用this时必须显式定义返回类型,这是为了规避 TypeScript 的类型推断缺陷;而用state参数(箭头函数 / 常规函数)则不需要。

当 Getter 通过this访问其他 Getter 时,TypeScript 会陷入循环类型推断(比如doublePlusOne依赖doubleCountdoubleCount又属于 store 的一部分,store 又包含doublePlusOne),导致无法自动识别返回类型,甚至出现类型报错。

在 TypeScript 中,只要 Getter 里用了this,就必须手动写返回类型(比如: number),而不用this则不需要。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { defineStore } from 'pinia';

export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
}),
getters: {
// 情况1:仅用state,自动推断返回类型为number(无需手动写)
doubleCount(state) {
return state.count * 2; // TypeScript自动识别:返回number
},

// 情况2:用this访问其他Getter,必须显式定义返回类型: number
doublePlusOne(): number { // 关键:手动标注返回类型
return this.doubleCount + 1; // this能自动补全store的属性(类型提示✨)
},

// 情况3:箭头函数+state,同样自动推断类型(推荐)
tripleCount: (state) => {
return state.count * 3;
},
},
});

“不过这不影响用箭头函数定义的 getter,也不会影响不使用 this 的 getter。”

因为箭头函数没有自己的thisthis会指向外层作用域,而非 store 实例),所以只能用state参数,自然不会涉及this的类型问题,TypeScript 能正常推断返回类型。

即使是常规函数,只要只用到state参数、不用this,TypeScript 也能自动推断返回类型。

向 getter 传递参数

Getter 只是幕后的计算属性,所以不可以向它们传递任何参数。不过,你可以从 getter 返回一个函数,该函数可以接受任意参数:

1
2
3
4
5
6
7
export const useUserListStore = defineStore('userList', {
getters: {
getUserById: (state) => {
return (userId) => state.users.find((user) => user.id === userId)
},
},
})

并在组件中使用:

1
2
3
4
5
6
7
8
9
10
11
<script setup>
import { useUserListStore } from './store'
const userList = useUserListStore()
const { getUserById } = storeToRefs(userList)
// 请注意,你需要使用 `getUserById.value` 来访问
// <script setup> 中的函数
</script>

<template>
<p>User 2: {{ getUserById(2) }}</p>
</template>

请注意,当你这样做时,getter 将不再被缓存。它们只是一个被你调用的函数。

不过,可以在 getter 本身中缓存一些结果,虽然这种做法并不常见,但有证明表明它的性能会更好

1
2
3
4
5
6
7
8
export const useUserListStore = defineStore('userList', {
getters: {
getActiveUserById(state) {
const activeUsers = state.users.filter((user) => user.active)
return (userId) => activeUsers.find((user) => user.id === userId)
},
},
})

Action

Action 相当于组件中的 method

  • 组件的method是用于定义组件的业务逻辑(比如点击事件处理、数据请求、复杂操作),可以是同步 / 异步的。
  • 那么 Pinia 的actions就是用于定义store 的业务逻辑(比如修改 state、异步请求数据、调用其他 actions),同样支持同步 / 异步,是处理 store 中动态逻辑的核心。

state是 store 的 “数据”,getters是 store 的 “计算属性”,actions是 store 的 “方法”。

使用action

基本定义:通过actions属性定义,用this访问 store 实例

  • getters类似,actions中可以通过this访问整个 store 实例,包括:

    • this.xxx:访问state中的数据(如this.count)。

    • this.xxxGetter:访问getters中的计算属性(如this.doubleCount)。

    • this.xxxAction():调用其他actions方法。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    import { defineStore } from 'pinia'

    export const useCounterStore = defineStore('main', {
    state: () => ({
    count: 0, // 原始数据
    }),
    getters: {
    doubleCount: (state) => state.count * 2, // 计算属性
    },
    actions: {
    // 同步action:修改state
    increment() {
    this.count++ // this指向store实例,直接修改state
    },
    // 带参数的action:更灵活地修改state
    incrementBy(num) {
    this.count += num
    },
    // 调用其他action + 访问getters
    incrementAndDouble() {
    this.increment() // 调用当前store的其他action
    console.log('当前doubleCount:', this.doubleCount) // 访问getters
    },
    randomizeCounter() {
    this.count = Math.round(100 * Math.random())
    },
    },
    })
    • Actions不推荐用箭头函数定义:因为箭头函数没有自己的this,无法指向 store 实例,会导致this.count等操作报错。
    • Actions 修改 state 是直接赋值

Action 可以是异步的

那么使用很简单,Actions 的调用方式像调用普通方法一样

定义好的 Actions 会挂载到 store 实例上,调用方式和普通函数 / 方法完全一致,支持:

  • 组件的<script setup>中调用。
  • 模板中直接调用(比如绑定点击事件)。
  • 其他 store 的 Actions 中调用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<script setup>
import { useCounterStore } from './stores/counter'
import { useUsersStore } from './stores/users'

// 获取store实例
const counterStore = useCounterStore()
const usersStore = useUsersStore()

// 1. 调用同步action(无参)
counterStore.increment()

// 2. 调用同步action(带参)
counterStore.incrementBy(5)

// 3. 调用异步action(带参,处理返回值)
const handleRegister = async () => {
const result = await usersStore.registerUser('test', '123456')
if (result.success) {
alert('注册成功!')
} else {
alert(`注册失败:${result.error}`)
}
}
</script>

<template>
<div>
<p>Count: {{ counterStore.count }}</p>
<!-- 模板中调用action(无参) -->
<button @click="counterStore.randomizeCounter()">随机数</button>
<!-- 模板中调用action(带参) -->
<button @click="counterStore.incrementBy(3)">+3</button>
<!-- 模板中调用异步action -->
<button @click="handleRegister">注册用户</button>
<!-- 显示加载/错误状态 -->
<p v-if="usersStore.loading">加载中...</p>
<p v-if="usersStore.error" style="color: red">{{ usersStore.error }}</p>
</div>
</template>

访问其他 store 的 action 也很简单,想要使用另一个 store 的话,那你直接在 action 中调用就好了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { useAuthStore } from './auth-store'

export const useSettingsStore = defineStore('settings', {
state: () => ({
preferences: null,
// ...
}),
actions: {
async fetchUserPreferences() {
const auth = useAuthStore()
if (auth.isAuthenticated) {
this.preferences = await fetchPreferences()
} else {
throw new Error('User must be authenticated')
}
},
},
})

订阅 action

Pinia 的订阅 Action(subscribeAction) 是一种监听机制,允许你在Action 被调用前、调用后、抛出错误时,捕获到 Action 的执行信息(比如 Action 名称、参数、返回值、错误信息等),并执行自定义逻辑。

给 store 的所有 Action 装了一个 “监听器”,无论哪个 Action 被触发,这个监听器都能收到通知,并做相应处理。

这样就无需在每个 Action 内部重复写相同的逻辑

通过 store.$onAction() 来监听 action 和它们的结果。传递给它的回调函数会在 action 本身之前执行。,并且可以通过内置的afteronError钩子,分别监听 Action执行成功后执行出错后的状态

$onAction接收两个参数:

  • 第一个参数:回调函数,参数是一个包含 Action 上下文和钩子的对象(name/store/args/after/onError)。
  • 第二个参数:布尔值(可选),默认false(订阅器绑定当前组件,组件卸载时自动删除);设为true时,订阅器与组件解耦,组件卸载后仍保留。

$onAction执行后会返回一个取消订阅的函数,可手动调用删除监听器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 调用store的$onAction方法,传入回调函数,得到取消订阅的函数
const unsubscribe = someStore.$onAction(
({
name, // 触发的Action名称(如:increment、incrementAsync)
store, // 当前store实例(等同于someStore)
args, // 传递给Action的参数数组(如调用increment(5),则args = [5])
after, // 钩子函数:Action执行成功(resolve)后触发
onError, // 钩子函数:Action执行失败(reject/抛出错误)后触发
}) => {
// 1. 这部分代码会在 Action执行前 立即执行(核心:先于Action逻辑)
const startTime = Date.now() // 记录Action开始执行的时间
console.log(`Start "${name}" with params [${args.join(', ')}].`) // 打印Action名称和参数

// 2. after钩子:Action成功执行后(包括异步Action的Promise resolve后)触发
// 回调函数的参数result是Action的返回值
after((result) => {
console.log(
`Finished "${name}" after ${Date.now() - startTime}ms.\nResult: ${result}.`
)
})

// 3. onError钩子:Action执行出错(抛出错误/Promise reject)时触发
// 回调函数的参数error是错误对象
onError((error) => {
console.warn(
`Failed "${name}" after ${Date.now() - startTime}ms.\nError: ${error}.`
)
})
}
)

// 手动调用取消订阅函数,删除监听器(可选)
// unsubscribe()

来说一下文档中提到的$onAction组件绑定特性

  1. 默认行为(第二个参数为false
    • 如果在组件的setup()中调用store.$onAction,订阅器会绑定到当前组件
    • 当组件被卸载时,订阅器会自动删除,无需手动调用unsubscribe,避免内存泄漏。
  2. 解耦行为(第二个参数为true
    • 订阅器与当前组件解耦,组件卸载后,订阅器仍然生效。
    • 适用场景:全局级别的 Action 监听(如全局日志、埋点),需要在整个应用生命周期内保留。
    • 注意:这种情况需要手动调用unsubscribe 来删除订阅器,否则会导致内存泄漏。

Vue 与 Vite 的 SSR

官方文档首先强调:只要你在setup函数、getter、action 的顶部调用useStore(),Pinia 的 SSR 支持就是开箱即用的

  • setup内部:useStore()无需传参,Pinia 自动处理上下文。
  • setup外部:useStore(pinia)必须传 pinia 实例,否则会报错。

这是因为 Pinia 在 SSR 环境中会自动管理store 的实例作用域—— 它能识别当前运行的是服务端还是客户端,以及对应的应用实例,从而确保每个请求 / 组件都能获取到正确的 store 实例

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 组件中:setup函数顶部调用useStore,SSR正常工作 -->
<script setup>
// ✅ 推荐:setup顶部调用,Pinia自动处理SSR作用域
import { useMainStore } from '@/stores/main'
const mainStore = useMainStore() // 无需传参,开箱即用

// 可以正常使用store的state、getter、action
const isLoggedIn = mainStore.isLoggedIn
</script>

<template>
<div v-if="isLoggedIn">欢迎登录</div>
</template>

setup函数、getter、action 的顶部调用useStore()时,Pinia 能通过 Vue 的 SSR 上下文,自动关联到当前应用的 pinia 实例,不会出现 “找不到 pinia 实例” 或 “状态共享污染” 的问题。

但是,如果在setup外部使用 store,必须手动传 pinia 实例

例如路由守卫,serverPrefetch、工具函数等setup外部的场景使用 store,直接调用useStore()会报错(因为 Pinia 无法自动识别上下文),此时必须将应用的 pinia 实例作为参数传递给useStore()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// main.ts(服务端/客户端共用的入口文件)
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createRouter, createMemoryHistory, createWebHistory } from 'vue-router'
import App from './App.vue'

// 1. 创建pinia实例(全局唯一,SSR中每个请求会创建新的pinia实例)
const pinia = createPinia()

// 2. 创建路由实例(SSR需区分服务端/客户端的history)
const router = createRouter({
history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
routes: [
{ path: '/', component: () => import('./views/Home.vue') },
{ path: '/profile', component: () => import('./views/Profile.vue'), meta: { requiresAuth: true } },
],
})

// 3. 路由守卫:setup外部使用store,必须传pinia实例
router.beforeEach((to, from, next) => {
// 将pinia实例传给useStore
const mainStore = useMainStore(pinia)

// 鉴权逻辑:需要登录但未登录时跳转到登录页
if (to.meta.requiresAuth && !mainStore.isLoggedIn) {
next('/login')
} else {
next()
}
})

// 4. 挂载pinia和路由到应用
const app = createApp(App)
app.use(pinia)
app.use(router)

// 5. 导出app、router、pinia(SSR中需要暴露这些实例供服务端渲染使用)
export { app, router, pinia }

SSR 的核心流程是:服务端渲染页面时,先获取数据并更新 store 的 state → 将 state 序列化后嵌入到 HTML 中 → 客户端加载页面时,先恢复 store 的 state,再挂载应用(避免客户端重新请求数据,导致页面闪烁)

这个 “服务端序列化 state,客户端恢复 state” 的过程,Pinia 称之为State 激活(State Hydration)

State 激活的步骤

注意,代码只是示例理解 Pinia 在 SSR 中是如何工作的,绝大多数场景下,不需要手写这些代码,现代 Vue SSR 生态已经把这些底层逻辑封装好了

  • 服务端:渲染后获取并序列化 pinia 的 state

    服务端渲染页面时,完成所有数据预取后,pinia 的根状态会存储在pinia.state.value中。需要将其序列化并转义(防止 XSS 攻击),然后嵌入到 HTML 的全局变量中。

    官方推荐使用@nuxt/devalue

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    // 服务端渲染入口文件(如server/render.ts)
    import { createApp } from 'vue'
    import { createPinia } from 'pinia'
    import devalue from '@nuxt/devalue'
    import { renderToString } from 'vue/server-renderer'
    import App from './App.vue'
    import { router } from './main'

    export async function render(url: string) {
    // 1. 每个请求创建新的pinia和app实例(避免跨请求状态污染)
    const pinia = createPinia()
    const app = createApp(App)
    app.use(pinia)
    app.use(router)

    // 2. 路由跳转至当前请求的URL
    await router.push(url)
    await router.isReady()

    // 3. 渲染应用为字符串(服务端渲染核心)
    const appHtml = await renderToString(app)

    // 4. 获取pinia的根状态(所有store的state)
    const piniaState = pinia.state.value

    // 5. 序列化并转义state(关键:防止XSS,@nuxt/devalue比JSON.stringify更安全)
    const serializedState = devalue(piniaState)

    // 6. 将序列化后的state嵌入到HTML中(作为全局变量)
    const html = `
    <!DOCTYPE html>
    <html>
    <head>
    <title>Pinia SSR</title>
    </head>
    <body>
    <div id="app">${appHtml}</div>
    <!-- 挂载序列化后的state到window.__INITIAL_PINIA_STATE -->
    <script>
    window.__INITIAL_PINIA_STATE__ = ${serializedState}
    </script>
    <!-- 客户端入口脚本 -->
    <script type="module" src="/src/client.ts"></script>
    </body>
    </html>
    `

    return html
    }
  • 客户端:恢复 pinia 的 state(挂载应用前)

    客户端加载页面时,需要先从全局变量中读取序列化的 state,赋值给pinia.state.value,再挂载应用。

    必须在调用任何useStore()之前完成这一步,否则 store 会使用默认的空状态,导致数据不一致。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // 客户端入口文件(如src/client.ts)
    import { createApp } from 'vue'
    import { createPinia } from 'pinia'
    import { createRouter, createWebHistory } from 'vue-router'
    import App from './App.vue'
    import { router } from './main'

    // 1. 创建pinia实例
    const pinia = createPinia()

    // 2. 恢复pinia的state(关键:在挂载应用前完成)
    if (typeof window !== 'undefined') {
    // 从全局变量中读取序列化的state并赋值
    pinia.state.value = window.__INITIAL_PINIA_STATE__
    }

    // 3. 创建并挂载应用
    const app = createApp(App)
    app.use(pinia)
    app.use(router)
    app.mount('#app')

对了,如果你的项目是基于Nuxt(Vue 的 SSR 框架),不需要手动处理上述步骤,因为 Nuxt 与 Pinia 深度集成,会自动处理:

  • store 的实例作用域(每个请求一个 pinia 实例)。
  • state 的序列化与恢复。
  • useStore()在任意位置的调用(Nuxt 会自动传递 pinia 实例)。

只需按照 Nuxt 的文档配置 Pinia 即可,无需手动写 SSR 相关的代码。