Pinia 4:状态、Getter、Action、订阅与持久化
Pinia 4:状态、Getter、Action、订阅与持久化
系列导航:Vue 3 现代开发指南
上一篇:Vue Router 5
下一篇:Vue 3 进阶响应式与内置组件
Pinia 是 Vue 官方推荐的状态管理库。一个 Store 包含状态、派生状态和业务动作,并通过 Vue DevTools 提供可追踪的更新记录。它适合当前用户、购物车、跨路由草稿等应用级状态,不应取代组件内部的 ref 或普通父子通信。
本文基于 Pinia 4。它的日常 Store API 与 Pinia 3 基本一致,主要升级点是只发布 ESM,并把 @vue/devtools-api 升级为需要一同安装的 peer dependency。
一、安装和注册
使用当前 create-vue 并选择 Pinia 时,脚手架会处理兼容依赖。手工安装 Pinia 4:
npm install pinia @vue/devtools-api
在 src/main.ts 注册:
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')
Pinia 4 是 ESM-only。如果旧构建脚本仍通过 require() 加载 Pinia,应先把构建和配置迁到 ESM。
二、什么状态应该进入 Store
适合进入 Pinia:
- 登录用户和权限;
- 购物车、跨页面筛选条件;
- 多个远距离组件共同读写的业务状态;
- 需要 DevTools、持久化或 SSR 水合的状态。
不必进入 Pinia:
- 只属于一个组件的弹窗开关;
- 输入框即时值;
- 可由 Props 或现有状态直接计算出的值;
- DOM 节点、第三方库实例等不可序列化对象。
Store 越全局,修改它的影响范围越大。不要把所有 ref 搬进 Pinia 来追求“统一”。
三、Setup Store
Setup Store 与组合式函数写法一致:ref 对应 state,computed 对应 getter,函数对应 action。
// stores/todos.ts
import { computed, ref } from 'vue'
import { acceptHMRUpdate, defineStore } from 'pinia'
export interface Todo {
id: string
title: string
done: boolean
}
export const useTodoStore = defineStore('todos', () => {
const items = ref<Todo[]>([])
const loading = ref(false)
const completed = computed(
() => items.value.filter((item) => item.done),
)
const pendingCount = computed(
() => items.value.length - completed.value.length,
)
function add(title: string) {
const normalized = title.trim()
if (!normalized) return
items.value.push({
id: crypto.randomUUID(),
title: normalized,
done: false,
})
}
function toggle(id: string) {
const todo = items.value.find((item) => item.id === id)
if (todo) todo.done = !todo.done
}
function $reset() {
items.value = []
loading.value = false
}
return {
items,
loading,
completed,
pendingCount,
add,
toggle,
$reset,
}
})
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useTodoStore, import.meta.hot))
}
Setup Store 必须返回需要被 Pinia 管理的状态,否则 DevTools、SSR 和插件无法看到它。Options Store 自带 $reset(),Setup Store 则像上面一样自行实现。
四、在组件中使用 Store
<script setup lang="ts">
import { ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useTodoStore } from '@/stores/todos'
const draft = ref('')
const todoStore = useTodoStore()
const { items, pendingCount } = storeToRefs(todoStore)
const { add, toggle } = todoStore
function submit() {
add(draft.value)
draft.value = ''
}
</script>
<template>
<form @submit.prevent="submit">
<input v-model="draft" placeholder="新增任务" />
<button>添加</button>
</form>
<p>待完成:{{ pendingCount }}</p>
<ul>
<li v-for="item in items" :key="item.id">
<label>
<input
:checked="item.done"
type="checkbox"
@change="toggle(item.id)"
/>
{{ item.title }}
</label>
</li>
</ul>
</template>
直接解构 Store 的 state 或 getter 会失去响应式连接,所以使用 storeToRefs()。Action 已经绑定到 Store,可以直接解构函数。Vue 的 toRefs() 会把 Store 方法等也纳入处理,不适合替代 storeToRefs()。
五、Options Store
喜欢 state / getters / actions 结构时,可以使用 Options Store:
import { defineStore } from 'pinia'
interface CounterState {
count: number
step: number
}
export const useCounterStore = defineStore('counter', {
state: (): CounterState => ({
count: 0,
step: 1,
}),
getters: {
doubled: (state) => state.count * 2,
summary(): string {
return `${this.count} × 2 = ${this.doubled}`
},
},
actions: {
increment() {
this.count += this.step
},
},
})
两种 Store 形式能力相近。团队可以按逻辑复杂度和代码风格选择,不需要把 Options Store 描述成“旧写法”。
六、修改状态的方式
Pinia 允许直接修改:
counterStore.count++
批量修改使用 $patch():
counterStore.$patch({
count: 10,
step: 2,
})
集合修改可以使用函数形式,多个变化会归为一次 DevTools 记录:
todoStore.$patch((state) => {
state.items.push(firstTodo, secondTodo)
})
涉及校验、异步流程或会被多处调用的修改,放进 Action。即使直接赋值合法,也不意味着所有业务规则都应散落在组件里。
七、异步 Action
Action 可以直接使用 async/await,并集中维护加载和错误状态:
const error = ref<string | null>(null)
async function fetchTodos() {
loading.value = true
error.value = null
try {
const response = await fetch('/api/todos')
if (!response.ok) throw new Error(`HTTP ${response.status}`)
items.value = await response.json()
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '加载失败'
throw cause
} finally {
loading.value = false
}
}
是否在 Store 中捕获错误取决于职责:Store 可以保存可共享的错误状态,但页面仍应决定如何呈现通知、重试按钮或错误边界。
八、订阅与持久化
$subscribe() 可以监听状态变更:
const stop = todoStore.$subscribe((mutation, state) => {
console.log(mutation.type)
localStorage.setItem('todos', JSON.stringify(state.items))
})
不再需要时调用 stop()。组件 setup() 中创建的订阅默认会跟随组件作用域;应用级持久化通常应在初始化模块或 Pinia 插件中安装一次,避免多个组件重复写入。
一个最小的客户端恢复过程:
const saved = localStorage.getItem('todos')
if (saved) {
try {
todoStore.items = JSON.parse(saved)
} catch {
localStorage.removeItem('todos')
}
}
真实项目还要处理:
- 数据结构版本和迁移;
- 存储配额、JSON 解析失败;
- SSR 环境没有
window/localStorage; - 多标签页同步;
- 用户退出时清理数据;
- 敏感信息不能因为“方便”就持久化到浏览器。
需求复杂时,选择维护活跃且支持当前 Pinia 主版本的持久化插件,并在升级前验证其 ESM 和 SSR 兼容性。
九、在组件外使用 Store
useXxxStore() 依赖已激活的 Pinia。不要在路由、请求模块的顶层过早调用:
// 容易在 app.use(pinia) 之前执行
// const userStore = useUserStore()
在函数内部调用,确保应用已经安装 Pinia:
router.beforeEach(() => {
const userStore = useUserStore()
// ...
})
SSR 或多应用实例中,应显式传递 Pinia 实例,避免请求之间共享状态。
十、Pinia 4 迁移检查
从 Pinia 3 升级到 4 时重点检查:
- 构建、测试和 Node 脚本是否都能消费 ESM;
- 是否安装了兼容版本的
@vue/devtools-api; - Pinia 插件和持久化插件是否支持 4.x;
- CI 是否覆盖生产构建、Store 测试和 SSR(如有);
- 旧依赖是否偷偷通过 CommonJS
require('pinia')加载。
小结
Pinia Store 应围绕跨组件的业务状态建模。storeToRefs() 用于解构 state 和 getter,Action 承担可复用业务修改,$patch() 适合批量变化。Pinia 4 的业务 API 延续性很强,但 ESM-only 和 DevTools peer dependency 是升级时必须验证的边界。