Vue 基础体系 · 第 11/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。
Pinia 状态管理:Store、Getter、Action、持久化和 SSR 边界
Pinia 是 Vue 应用中的集中式状态管理库。它把一组相关的状态、派生数据和操作封装为一个 Store,组件、路由守卫和其他业务模块可以通过同一个 Store 读取或修改数据。
但 Pinia 并不是“把所有变量放到全局”的工具。要正确使用它,必须区分:
- Store 的身份和生命周期;
- State、Getter、Action 各自负责什么;
- 响应式引用在解构时为什么会丢失;
- 异步 Action 如何处理竞态和错误;
- 持久化究竟保存什么、何时恢复;
- SSR 中哪些状态可以跨请求共享,哪些绝对不能共享。
本文示例基于 Vue 3、Composition API、TypeScript 和现代 Vite 工具链。
一、先建立状态管理模型
1. 什么是状态
**状态(state)**是会随用户操作、网络请求或业务流程发生变化,并且会影响界面的数据。
例如购物车:
type CartItem = {
productId: string
name: string
price: number
quantity: number
}
以下数据属于状态:
const items: CartItem[] = []
const couponCode = ''
const isSubmitting = false
const errorMessage: string | null = null
以下数据通常不需要单独存储为状态:
const total = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
)
因为 total 可以由 items 推导出来。把它同时存成状态会产生两个来源:
items -> total
items, total -> 界面
一旦某处只更新了 items,却忘记更新 total,两个值就会不一致。因此,能稳定地由已有状态计算出来的数据,通常应该建模为 Getter,而不是重复存储。
可以把应用状态简单表示为:
其中:
- 是时刻 的状态;
- 是用户操作、服务器响应等输入;
- 是改变状态的 Action;
- 是操作后的新状态。
Getter 则是状态的函数:
它不应该成为另一个独立事实源,而应该随着依赖的状态变化自动重新计算。
2. 什么是 Store
Store是具有稳定身份的一组状态、Getter 和 Action。它通常按业务边界划分,而不是按组件划分。
例如:
auth:当前用户、令牌、登录和退出;cart:购物车商品、总价、提交订单;products:商品列表、筛选条件、加载状态;settings:主题、语言和用户偏好。
Store 不是 Vue 组件。它可以被:
- 多个组件共享;
- 路由守卫使用;
- 其他 Store 使用;
- 测试代码直接调用;
- 在没有组件实例的模块中使用。
Pinia 使用 defineStore 定义 Store,使用一个唯一的 Store ID 标识它:
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
}),
getters: {
doubleCount: (state) => state.count * 2,
},
actions: {
increment() {
this.count++
},
},
})
'counter' 是 Store 的 ID。它不仅用于开发工具标识,也会影响插件、持久化键名和调试信息,因此同一个 Pinia 实例中不应重复使用同一个 ID。
组件中使用:
<script setup lang="ts">
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
</script>
<template>
<button @click="counter.increment">
{{ counter.count }} / {{ counter.doubleCount }}
</button>
</template>
useCounterStore() 并不是每次都创建一个独立的 Store。对于同一个 Pinia 实例和同一个 Store ID,Pinia 会返回对应的 Store 实例。
二、Options Store 与 Setup Store
Pinia 支持两种主要写法。
1. Options Store
Options Store 的结构类似 Vue Options API:
import { defineStore } from 'pinia'
export type User = {
id: string
name: string
}
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null as User | null,
accessToken: null as string | null,
isLoading: false,
errorMessage: null as string | null,
}),
getters: {
isLoggedIn: (state) => state.user !== null,
displayName: (state) => state.user?.name ?? '未登录',
},
actions: {
setSession(user: User, accessToken: string) {
this.user = user
this.accessToken = accessToken
},
clearSession() {
this.user = null
this.accessToken = null
},
},
})
Options Store 的特点是:
- 初始状态集中写在
state; - Getter 由
getters定义; - Action 中通过
this访问状态、Getter 和其他 Action; $reset()可以将状态恢复为初始值。
const auth = useAuthStore()
auth.$reset()
$reset() 会重新调用 state(),因此适合清理表单、退出登录或测试之间的状态。它只会重置当前 Store,不会自动重置其他 Store。
2. Setup Store
Setup Store 使用 Composition API:
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const accessToken = ref<string | null>(null)
const isLoggedIn = computed(() => user.value !== null)
const displayName = computed(() => user.value?.name ?? '未登录')
function setSession(nextUser: User, token: string) {
user.value = nextUser
accessToken.value = token
}
function clearSession() {
user.value = null
accessToken.value = null
}
return {
user,
accessToken,
isLoggedIn,
displayName,
setSession,
clearSession,
}
})
Setup Store 中:
ref返回状态;computed返回派生状态;- 普通函数作为 Action;
return出去的内容才是 Store 的公开接口。
组件使用时,Pinia 会把 Store 中的 ref 自动解包:
const auth = useAuthStore()
auth.user // 不需要 .value
auth.isLoggedIn // 不需要 .value
但是在 Store 定义内部仍然必须使用 .value:
user.value = nextUser
Setup Store 的灵活性更高,可以直接使用 watch、inject 和组合式函数,但 SSR 要求也更严格:所有需要被 Pinia 管理、序列化或水合的响应式状态都应该明确返回。不要把一个影响渲染的 ref 留在闭包中,否则它既不会成为 Store 状态,也不能被正常水合。
Options Store 的 $reset() 是 Pinia 提供的能力。Setup Store 没有自动生成的 $reset(),需要自行实现:
export const useFormStore = defineStore('form', () => {
const name = ref('')
const email = ref('')
function reset() {
name.value = ''
email.value = ''
}
return { name, email, reset }
})
三、State:单一来源与可变边界
1. 状态初始化必须是可追踪的
Options Store 中,状态应通过函数返回:
state: () => ({
count: 0,
})
使用函数的原因是每次创建 Store 状态时都能获得一个新的对象。不能把可变对象放在模块顶层作为所有请求共享的状态:
// 错误:所有请求和所有用户共享同一个可变对象
const sharedState = {
user: null,
}
export const useAuthStore = defineStore('auth', {
state: () => sharedState,
})
在普通 SPA 中,这种问题可能不明显,因为应用通常只有一个运行时实例;在 SSR 中,它会直接造成用户之间的数据串线。
2. 修改状态的三种方式
直接修改:
const counter = useCounterStore()
counter.count++
使用 $patch 修改多个字段:
counter.$patch({
count: 10,
})
或者使用函数批量修改:
counter.$patch((state) => {
state.count = 10
// state.otherField = ...
})
直接修改适合单个明确字段,$patch 适合把一个业务变更作为整体提交。Pinia 的开发工具和订阅机制可以观察这些变更,但 $patch 并不会自动提供事务回滚;如果中途抛出异常,已经执行的修改不会自动恢复。
如果需要“失败即恢复”,应显式保存旧值或设计状态机:
const previousItems = cart.items.map((item) => ({ ...item }))
try {
await submitOrder()
cart.items = []
} catch (error) {
cart.items = previousItems
throw error
}
3. storeToRefs 解决解构丢失响应式
下面的写法会破坏状态和 Getter 的响应式连接:
const counter = useCounterStore()
const { count, doubleCount } = counter
普通解构会读取当前值,而不会自动为每个属性建立响应式引用。后续 counter.count++ 时,解构出的 count 可能不会更新。
正确写法是使用 storeToRefs:
import { storeToRefs } from 'pinia'
const counter = useCounterStore()
const { count, doubleCount } = storeToRefs(counter)
const { increment } = counter
storeToRefs 只处理状态和 Getter;Action 是普通函数,可以直接解构。因为 Action 不依赖通过解构建立响应式引用:
increment()
在模板中通常直接使用 Store 对象最简单:
<script setup lang="ts">
const counter = useCounterStore()
</script>
<template>
<span>{{ counter.count }}</span>
</template>
四、Getter:派生状态,而不是第二份事实
1. Getter 的基本语义
Getter 类似组件中的 computed,用于根据 Store 状态计算派生值:
export const useCartStore = defineStore('cart', {
state: () => ({
items: [] as CartItem[],
}),
getters: {
itemCount: (state) =>
state.items.reduce((count, item) => count + item.quantity, 0),
subtotal: (state) =>
state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
),
isEmpty: (state) => state.items.length === 0,
},
})
其数据流是:
items 改变
↓
itemCount、subtotal、isEmpty 重新求值
↓
依赖它们的组件更新
如果 items 没有变化,且 Getter 的依赖也没有变化,Vue 的计算属性机制可以避免不必要的重复计算。
2. Getter 之间如何互相调用
当 Getter 只使用 state 时,可以写成箭头函数:
getters: {
subtotal: (state) => {
return state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
)
},
}
如果要使用同一个 Store 中的其他 Getter,可以通过 this:
getters: {
subtotal: (state) =>
state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
),
hasItems(): boolean {
return this.itemCount > 0
},
summary(): string {
return `${this.itemCount} 件商品,共 ${this.subtotal} 元`
},
}
这里使用普通方法而不是箭头函数,是因为 this 需要指向 Store。
TypeScript 在通过 this 访问其他 Getter 时有时不能完整推断返回类型,因此显式标注返回类型更可靠:
hasItems(): boolean {
return this.itemCount > 0
}
3. 带参数的 Getter
Getter 本身通常表示可缓存的派生值,但可以返回一个函数实现带参数查询:
getters: {
itemByProductId: (state) => {
return (productId: string) =>
state.items.find((item) => item.productId === productId)
},
}
使用:
const item = cart.itemByProductId('p-100')
需要注意,这个返回的函数每次调用时执行查询;它不是“每个 productId 都自动缓存一次”的独立计算属性。对于大型集合和高频查询,应考虑建立索引:
getters: {
itemMap: (state) => {
const map = new Map<string, CartItem>()
for (const item of state.items) {
map.set(item.productId, item)
}
return map
},
}
之后:
const item = cart.itemMap.get('p-100')
这仍然不是无条件更快的方案,因为建立 Map 本身需要遍历集合。是否建立索引取决于集合规模、读取频率和更新频率。
4. Getter 的反例:在 Getter 中修改状态
下面的 Getter 会产生副作用:
getters: {
// 错误
normalizedItems: (state) => {
state.items.sort((a, b) => a.price - b.price)
return state.items
},
}
读取 Getter 的动作本应是观察状态,但这里却修改了状态。结果可能包括:
- 读取一次 Getter 改变了列表顺序;
- 触发订阅和组件更新;
- 在计算过程中形成难以追踪的副作用;
- SSR 渲染前后顺序不一致。
应该在 Action 中修改,或者返回不改变原数组的新数组:
getters: {
sortedItems: (state) => {
return [...state.items].sort((a, b) => a.price - b.price)
},
}
如果排序规则属于业务行为而不是展示行为,也可以放在 Action 中明确执行:
actions: {
sortByPrice() {
this.items.sort((a, b) => a.price - b.price)
},
}
五、Action:把业务变更和异步流程集中起来
1. Action 的作用
Action是 Store 对外提供的业务操作。它可以:
- 修改多个状态字段;
- 调用其他 Action;
- 调用 HTTP API;
- 抛出或转换错误;
- 管理加载、成功和失败状态;
- 协调多个 Store。
例如登录:
import { defineStore } from 'pinia'
type LoginResponse = {
user: User
accessToken: string
}
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null as User | null,
accessToken: null as string | null,
status: 'idle' as 'idle' | 'loading' | 'success' | 'error',
errorMessage: null as string | null,
}),
getters: {
isLoggedIn: (state) => state.user !== null,
},
actions: {
async login(email: string, password: string) {
this.status = 'loading'
this.errorMessage = null
try {
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password }),
})
if (!response.ok) {
throw new Error(`登录失败:HTTP ${response.status}`)
}
const data = (await response.json()) as LoginResponse
this.user = data.user
this.accessToken = data.accessToken
this.status = 'success'
} catch (error) {
this.status = 'error'
this.errorMessage =
error instanceof Error ? error.message : '未知登录错误'
throw error
}
},
logout() {
this.user = null
this.accessToken = null
this.status = 'idle'
this.errorMessage = null
},
},
})
调用方可以根据返回的 Promise 决定是否跳转:
const auth = useAuthStore()
try {
await auth.login(email.value, password.value)
await router.push('/dashboard')
} catch {
// Store 已经记录错误;这里也可以显示 Toast 或阻止跳转
}
Action 中捕获错误后再次 throw 很重要。否则调用方看到的 Promise 会被视为成功,路由可能在登录失败后仍然跳转。
2. Action 的状态转换
一个异步请求至少要区分这些阶段:
idle
│
├── 请求开始 ──> loading
│ │
│ ├── 成功 ──> success
│ │
│ └── 失败 ──> error
│
└── 重置 ───────> idle
一个常见错误是只保存 data:
state: () => ({
products: [] as Product[],
})
这样无法区分:
- 从未请求;
- 正在请求;
- 请求成功但结果为空;
- 请求失败后保留旧数据;
- 请求成功后数据为空。
更完整的建模是:
type RequestStatus = 'idle' | 'loading' | 'success' | 'error'
state: () => ({
products: [] as Product[],
status: 'idle' as RequestStatus,
errorMessage: null as string | null,
})
“空数组”不等于“尚未加载”,状态字段承担的是数据本身无法表达的流程信息。
3. 竞态:旧请求不能覆盖新请求
假设用户快速修改搜索词:
t1: 请求 apple 发出
t2: 请求 app 发出
t3: app 返回
t4: apple 返回
如果 Action 每次返回后都直接写入 results,最后返回的旧请求可能覆盖新结果。按请求发出顺序看,正确结果应该来自 app,但按网络返回顺序看,apple 可能最后到达。
可以用请求序号保证“只有最新请求可以提交结果”:
export const useSearchStore = defineStore('search', {
state: () => ({
keyword: '',
results: [] as Product[],
status: 'idle' as RequestStatus,
errorMessage: null as string | null,
requestVersion: 0,
}),
actions: {
async search(keyword: string) {
this.keyword = keyword
const version = ++this.requestVersion
this.status = 'loading'
this.errorMessage = null
try {
const response = await fetch(
`/api/products?q=${encodeURIComponent(keyword)}`,
)
if (!response.ok) {
throw new Error(`搜索失败:HTTP ${response.status}`)
}
const results = (await response.json()) as Product[]
if (version !== this.requestVersion) {
return
}
this.results = results
this.status = 'success'
} catch (error) {
if (version !== this.requestVersion) {
return
}
this.status = 'error'
this.errorMessage =
error instanceof Error ? error.message : '搜索失败'
throw error
}
},
},
})
这里的逻辑是:
- 每次搜索先递增
requestVersion; - 当前请求保存发出时的版本号;
- 响应到达后检查版本号;
- 如果 Store 已经开始了更新的请求,则丢弃旧响应。
这种方案能防止旧响应提交,但不会取消网络请求。若需要节省网络和服务器资源,可以结合 AbortController:
let controller: AbortController | null = null
async function search(keyword: string) {
controller?.abort()
controller = new AbortController()
const response = await fetch(
`/api/products?q=${encodeURIComponent(keyword)}`,
{ signal: controller.signal },
)
// ...
}
被取消的请求通常会抛出 AbortError。这类错误不应被当成普通服务器失败显示给用户:
catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return
}
// 处理真实失败
}
取消解决资源问题,版本号解决提交顺序问题;两者不是同一个机制。
六、组件、Store、Router 和 API 的数据流
一个清晰的业务数据流通常是:
flowchart LR
UI[Vue 组件] -->|调用 Action| S[Pinia Store]
S -->|请求| API[后端 API]
API -->|响应或错误| S
S -->|State / Getter| UI
R[Vue Router 守卫] -->|读取或调用| S
例如路由守卫检查登录状态:
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { pinia } from '@/plugins/pinia'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/dashboard',
component: () => import('@/views/DashboardView.vue'),
meta: { requiresAuth: true },
},
],
})
router.beforeEach((to) => {
const auth = useAuthStore(pinia)
if (to.meta.requiresAuth && !auth.isLoggedIn) {
return {
name: 'login',
query: { redirect: to.fullPath },
}
}
})
export default router
关键在于:在组件内部,Pinia 通常可以从当前应用上下文找到;在路由守卫、普通工具模块或测试中,如果没有当前组件实例,应明确传入 Pinia 实例。
创建 Pinia:
// src/plugins/pinia.ts
import { createPinia } from 'pinia'
export const pinia = createPinia()
应用入口:
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { pinia } from './plugins/pinia'
const app = createApp(App)
app.use(pinia)
app.use(router)
app.mount('#app')
不要在 Store 模块顶层执行需要运行时上下文的 Store 获取:
// 容易出错,尤其是在模块加载阶段和 SSR 中
const auth = useAuthStore()
更安全的方式是在函数内部获取,或者显式传入 Pinia:
export function canAccessAdmin(piniaInstance: Pinia) {
const auth = useAuthStore(piniaInstance)
return auth.user?.role === 'admin'
}
七、Store 之间的依赖与循环依赖
Store 可以相互使用:
export const useOrderStore = defineStore('order', {
actions: {
async createOrder() {
const cart = useCartStore()
const auth = useAuthStore()
if (!auth.isLoggedIn) {
throw new Error('请先登录')
}
await api.createOrder({
userId: auth.user!.id,
items: cart.items,
})
},
},
})
但依赖关系需要是可理解的有向图:
auth ──> user
cart ──> product
order ──> auth、cart
如果 auth 的初始化 Action 调用 order,而 order 的初始化又调用 auth,就可能产生循环调用、初始化顺序不确定或无限递归。
解决方法不是把所有状态合并到一个 Store,而是重新划分职责:
- 基础身份 Store 只负责身份;
- 订单 Store 负责订单业务;
- 跨领域流程放在一个明确的业务 Action 或服务函数中;
- API 请求和纯数据转换可以放在独立模块中。
Store 是状态边界,不是所有业务逻辑的唯一容器。
八、持久化:Pinia 本身不会自动写入 localStorage
1. Pinia 内置能力与插件能力
Pinia 默认会在当前 JavaScript 运行时中保存响应式状态,但不会自动把状态写入:
localStorage;sessionStorage;- IndexedDB;
- Cookie;
- 服务端数据库。
因此,下面的代码不会自动持久化:
const settings = useSettingsStore()
settings.theme = 'dark'
// 刷新页面后,状态通常会从初始值重新开始
持久化是额外的存储策略,可以通过:
- Store Action 手动保存;
$subscribe监听状态变更;- Pinia 插件;
- 社区持久化插件。
“使用了 Pinia”不等于“刷新页面后状态还在”。
2. 用 $subscribe 实现最小持久化
import { watch } from 'vue'
import { defineStore } from 'pinia'
export const useSettingsStore = defineStore('settings', {
state: () => ({
theme: 'light' as 'light' | 'dark',
language: 'zh-CN',
}),
})
在应用启动后恢复并监听:
import { watch } from 'vue'
import { useSettingsStore } from '@/stores/settings'
import { pinia } from '@/plugins/pinia'
const settings = useSettingsStore(pinia)
if (typeof window !== 'undefined') {
const raw = localStorage.getItem('settings')
if (raw) {
try {
settings.$patch(JSON.parse(raw))
} catch {
localStorage.removeItem('settings')
}
}
settings.$subscribe(
(_mutation, state) => {
localStorage.setItem(
'settings',
JSON.stringify({
theme: state.theme,
language: state.language,
}),
)
},
{ detached: true },
)
}
这里有几个重要边界:
localStorage只存在于浏览器,SSR 中访问会抛出ReferenceError;- 必须捕获 JSON 解析错误,因为存储内容可能损坏或来自旧版本;
- 只保存需要的字段,避免把临时状态、错误信息和大对象全部写入;
detached: true让订阅不依赖某个组件的生命周期;- 这段代码应只初始化一次,否则会重复注册监听器。
如果希望把逻辑封装成 Pinia 插件:
import type { PiniaPluginContext } from 'pinia'
export function createLocalStoragePlugin(
ids: string[],
): (context: PiniaPluginContext) => void {
return ({ store }) => {
if (typeof window === 'undefined') {
return
}
if (!ids.includes(store.$id)) {
return
}
const key = `pinia:${store.$id}`
try {
const raw = localStorage.getItem(key)
if (raw) {
store.$patch(JSON.parse(raw))
}
} catch {
localStorage.removeItem(key)
}
store.$subscribe(
(_mutation, state) => {
try {
localStorage.setItem(key, JSON.stringify(state))
} catch (error) {
// 可能是配额不足或状态含有不可序列化数据
console.error(`无法持久化 Store:${store.$id}`, error)
}
},
{ detached: true },
)
}
}
注册:
import { createPinia } from 'pinia'
import { createLocalStoragePlugin } from './localStoragePlugin'
export const pinia = createPinia()
pinia.use(createLocalStoragePlugin(['settings', 'cart']))
pinia.use() 是 Pinia 的插件机制;具体的“持久化字段、存储介质、序列化方式和恢复策略”并不是 Pinia 核心统一规定的行为。
3. 不要把所有状态都持久化
持久化选择可以按风险分类:
| 状态 | 通常是否持久化 | 原因 |
|---|---|---|
| 主题、语言 | 是 | 用户偏好,敏感性低 |
| 购物车 | 通常是 | 需要跨刷新保留 |
| 分页临时加载状态 | 否 | 刷新后重新请求即可 |
isLoading |
否 | 它只描述当前运行时 |
| 错误文本 | 通常否 | 旧错误不应污染新会话 |
| Access Token | 谨慎 | XSS 风险和泄露影响较大 |
| 密码 | 绝不应持久化 | 极高敏感性 |
localStorage 中的数据可以被当前页面的 JavaScript 读取。如果站点存在 XSS,攻击脚本可能直接读取令牌。因此,令牌存储方案必须结合认证架构设计;不能因为“方便”就把所有认证信息塞进 Pinia 和 localStorage。
4. Schema 版本与迁移
持久化数据会跨越代码版本。今天保存的结构可能与下一次部署后的结构不同:
// 旧版本
{
"theme": "dark"
}
// 新版本
{
"version": 2,
"appearance": {
"theme": "dark"
}
}
恢复时应带版本号:
type PersistedSettings =
| {
version: 1
theme: 'light' | 'dark'
}
| {
version: 2
appearance: {
theme: 'light' | 'dark'
}
}
function migrateSettings(input: unknown) {
const data = input as Partial<PersistedSettings>
if (data.version === 2) {
return data
}
if (data.version === 1 && 'theme' in data) {
return {
version: 2 as const,
appearance: {
theme: data.theme ?? 'light',
},
}
}
return null
}
如果无法识别版本,宁可删除并使用默认值,也不要把不确定的旧结构直接 $patch 到当前 Store。
九、SSR:每个请求必须拥有独立 Pinia 实例
1. 为什么不能在服务端共享 Pinia
SSR 服务端进程通常会处理多个请求。如果把 Pinia 实例放在模块级单例中:
// SSR 中危险
export const pinia = createPinia()
那么请求 A 写入的状态可能在请求 B 渲染时仍然存在:
请求 A:登录用户 Alice
请求 B:未登录用户 Bob
服务端共享 Store:可能读到 Alice
这不仅是显示错误,也是严重的数据隔离问题。
SSR 的基本条件是:
其中 和 分别表示请求 A、B 的 Pinia 实例。每个请求必须创建自己的应用实例、路由实例和 Pinia 实例:
export function createApp() {
const app = createSSRApp(App)
const pinia = createPinia()
const router = createRouter(/* 当前请求对应的配置 */)
app.use(pinia)
app.use(router)
return { app, pinia, router }
}
客户端则需要使用同一个客户端 Pinia 实例完成水合:
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
Nuxt 等上层框架会封装部分 SSR 实例创建和水合流程,但“每请求隔离”的原则不会改变。
2. SSR 数据流:服务端填充,客户端水合
SSR 通常包含以下步骤:
sequenceDiagram
participant C as 浏览器
participant S as 服务端
participant P as 当前请求的 Pinia
C->>S: 请求页面
S->>P: 创建本请求专属 Pinia
S->>P: Action 获取页面数据
P-->>S: 生成 HTML 与状态快照
S-->>C: HTML + 初始 Pinia 状态
C->>P: 创建客户端 Pinia 并恢复快照
C->>C: Vue 水合 HTML
以商品详情为例,服务端需要先调用 Action:
const { app, pinia, router } = createApp()
await router.push(url)
await router.isReady()
const productStore = useProductStore(pinia)
await productStore.fetchProduct(route.params.id as string)
const html = await renderToString(app)
const state = pinia.state.value
随后把 state 安全地注入 HTML,客户端启动时恢复它。具体注入方式取决于 SSR 工具链,不能简单把未经处理的 JSON 拼接到 <script> 中。
3. 状态快照必须安全序列化
以下写法存在 XSS 风险:
html += `<script>window.__PINIA__ = ${JSON.stringify(state)}</script>`
如果状态中出现包含 </script>、特殊 Unicode 分隔符或其他危险内容,直接拼接可能改变脚本边界。
生产环境应使用经过验证的安全序列化方案,例如 devalue 一类专门用于 SSR 状态序列化的工具,或者使用框架提供的安全注入机制:
import devalue from 'devalue'
const serializedState = devalue(pinia.state.value)
客户端恢复时,也必须把状态作为数据解析,而不是执行任意脚本。原则是:
- 只序列化真正需要水合的状态;
- 不把密码、服务端密钥或内部权限信息放入公开 HTML;
- 不信任客户端回传的状态;
- 服务端仍然必须依据 Cookie、Session 或令牌重新验证权限。
SSR 注入的状态是“渲染初始值”,不是认证凭证的可信来源。
4. SSR 中不能把请求级对象放进 Store 状态
以下对象通常不适合放入可序列化状态:
{
request: Request,
response: Response,
socket: WebSocket,
controller: AbortController,
timer: setTimeout(...),
router: routerInstance,
}
原因有三类:
- 它们通常不能被安全序列化;
- 它们属于当前运行时,而不是业务数据;
- 它们跨服务端和客户端没有相同语义。
可以把请求状态和请求数据分开:
state: () => ({
product: null as Product | null,
status: 'idle' as RequestStatus,
errorMessage: null as string | null,
})
而把 AbortController 保留在 Action 闭包或模块的运行时变量中,并确保它不会成为要水合的 Store 状态。
5. SSR 中使用 window、document 和 localStorage
服务端没有浏览器 API,因此以下代码在 SSR 中会失败:
const theme = localStorage.getItem('theme')
应延迟到客户端:
if (typeof window !== 'undefined') {
const theme = localStorage.getItem('theme')
}
在 Vue 组件中,也可以在 onMounted 后读取:
import { onMounted } from 'vue'
onMounted(() => {
const raw = localStorage.getItem('settings')
// ...
})
但这会产生一个选择:
- 服务端先渲染默认主题,客户端挂载后再切换;
- 在服务端通过 Cookie 得到主题并预先写入 Store;
- 使用能在服务端和客户端都工作的外部状态来源。
如果服务端输出的 HTML 是浅色主题,客户端第一次渲染却根据 localStorage 立即使用深色主题,可能发生水合不一致或视觉闪烁。持久化策略必须和 SSR 初始数据策略一起设计。
十、持久化和 SSR 的冲突
假设服务端根据 Cookie 得到:
user = Alice
而浏览器 localStorage 中保存着旧的匿名状态:
user = null
客户端启动时,如果持久化插件无条件执行:
store.$patch(JSON.parse(localStorage.getItem(key)!))
就可能把服务端已经确定的 Alice 覆盖掉。
因此恢复优先级需要明确。例如:
服务端认证状态 > 当前请求数据 > 客户端持久化缓存 > 默认值
对于购物车,客户端缓存可能比服务端初始值更有价值;对于认证身份,服务端验证结果通常不能被任意客户端缓存覆盖。两者不能使用同一套无条件恢复策略。
可采用以下方法之一:
- 仅持久化非认证 Store;
- 在客户端比较时间戳后再合并;
- 让服务端把权威版本号注入状态;
- 对不同 Store 配置不同的恢复优先级;
- 登录和退出时显式清理相关持久化数据。
持久化不是简单的“保存和恢复”,它本质上是两个状态源之间的冲突解决问题。
十一、Action、缓存和请求失效
当 Store 保存服务端数据时,通常还需要保存缓存元数据:
type ProductCache = {
data: Product[]
fetchedAt: number
}
Action 可以依据时间判断是否重用缓存:
export const useProductStore = defineStore('products', {
state: () => ({
items: [] as Product[],
fetchedAt: 0,
status: 'idle' as RequestStatus,
errorMessage: null as string | null,
}),
getters: {
isFresh: (state) =>
state.fetchedAt > 0 &&
Date.now() - state.fetchedAt < 60_000,
},
actions: {
async fetchProducts(force = false) {
if (!force && this.isFresh) {
return this.items
}
this.status = 'loading'
this.errorMessage = null
try {
const response = await fetch('/api/products')
if (!response.ok) {
throw new Error(`加载商品失败:HTTP ${response.status}`)
}
this.items = (await response.json()) as Product[]
this.fetchedAt = Date.now()
this.status = 'success'
return this.items
} catch (error) {
this.status = 'error'
this.errorMessage =
error instanceof Error ? error.message : '加载商品失败'
throw error
}
},
},
})
这个实现表达了一个具体策略:
- 60 秒内认为缓存新鲜;
force = true时强制请求;- 请求成功后同时更新数据和时间;
- 请求失败时保留已有
items,但把状态置为error。
保留旧数据并显示错误,适合“刷新失败但页面仍可阅读”的场景;如果业务要求失败后清空数据,则应在失败分支显式清空,而不是依赖默认行为。
十二、订阅 Store 变化:$subscribe 与 $onAction
1. $subscribe
$subscribe 用于观察状态变化,适合:
- 持久化;
- 记录状态变更;
- 调试;
- 发送分析事件。
const store = useCartStore()
const unsubscribe = store.$subscribe((mutation, state) => {
console.log(mutation.type, mutation.storeId, state)
})
// 不再需要时取消
unsubscribe()
它观察的是 Store 状态变化,不是所有普通函数调用。持久化时应避免在回调中再次修改同一个 Store,否则可能造成递归或不必要的写入。
2. $onAction
$onAction 用于观察 Action 调用,可用于记录耗时和错误:
const unsubscribe = store.$onAction(({
name,
args,
after,
onError,
}) => {
const startedAt = performance.now()
after((result) => {
console.log(
`${name} 成功,耗时 ${performance.now() - startedAt}ms`,
args,
result,
)
})
onError((error) => {
console.error(`${name} 失败`, error)
})
})
两者的边界不同:
$subscribe关注“状态发生了什么变化”;$onAction关注“调用了哪个业务操作以及结果如何”。
如果一个 Action 内部修改状态三次,状态订阅和 Action 订阅观察到的粒度并不相同,不能混为一谈。
十三、常见失败表现与诊断方法
1. 解构后界面不更新
失败代码:
const store = useCounterStore()
const { count } = store
诊断方法:
- 检查是否直接解构 Store;
- 改为
storeToRefs(store); - 或暂时保留
store.count访问,确认问题是否消失。
正确代码:
const { count } = storeToRefs(useCounterStore())
2. Action 抛错,但页面仍然跳转
失败原因通常是 Action 捕获错误后没有重新抛出:
async login() {
try {
await api.login()
} catch {
this.errorMessage = '登录失败'
// 没有 throw
}
}
调用方的 await login() 会正常结束。应根据业务决定:
catch (error) {
this.errorMessage = '登录失败'
throw error
}
或者明确返回一个结果对象:
return { ok: false, error: '登录失败' }
两种方式都可以,但调用约定必须一致。
3. 刷新后状态丢失
可能原因包括:
- 没有配置持久化;
- 只监听了组件内的局部状态;
- 插件没有在 Store 创建前正确注册;
- 读取
localStorage时 JSON 解析失败; - SSR 水合时被服务端状态覆盖;
- 持久化键名发生变化;
- Store 状态包含未保存的字段。
诊断步骤:
- 查看浏览器存储中是否存在预期键;
- 检查写入回调是否被触发;
- 打印版本号和解析结果;
- 确认服务端与客户端恢复顺序;
- 确认是否在多个地方重复初始化持久化插件。
4. SSR 用户数据串线
典型表现是:
- 用户 A 偶尔看到用户 B 的名字;
- 服务端日志显示请求之间复用了状态;
- 本地开发正常,生产并发时出现;
- 关闭 SSR 后问题消失。
优先检查:
createPinia()是否在每个请求的应用工厂内执行;- Store 是否引用了模块级可变对象;
- 服务端是否缓存了包含用户状态的 HTML;
- 是否把请求数据写入了全局单例;
- 客户端水合快照是否与当前请求对应。
SSR 中的 Pinia 单例是结构性错误,不是通过清空 Store 就能可靠修复的问题。
5. Getter 不断触发或改变数据
检查 Getter 是否:
- 修改了数组、对象或 Store 字段;
- 在 Getter 中调用了会修改状态的 Action;
- 通过当前时间、随机数等非稳定输入生成渲染结果;
- 返回每次都新建的大对象,导致下游频繁更新。
例如:
getters: {
// 不适合作为稳定 Getter
now: () => Date.now(),
}
它不是由 Store 状态决定的派生值,且结果随时间变化。时间应由定时器更新一个明确的状态,或者在组件中按需求读取。
十四、Store 设计的边界
1. 什么数据值得进入 Store
适合放入 Store 的数据通常满足至少一个条件:
- 多个不相邻组件需要共享;
- 路由切换后仍应保留;
- 需要被路由守卫或其他业务模块读取;
- 有明确的业务操作和生命周期;
- 需要统一处理请求状态、缓存或错误。
不适合放入 Store 的数据包括:
- 单个输入框的临时值;
- 只在一个组件中使用的展开状态;
- 组件内部动画状态;
- 与 DOM 直接绑定的节点引用;
- 可以直接由 Props 或 URL 参数表达的数据。
例如当前页码如果由 URL 表达,就不一定要再复制到 Store:
URL ?page=3
Store currentPage = 3
两个来源可能不同步。应明确一个权威来源:
- 需要可分享、可返回、可复制的筛选条件,优先放 URL;
- 只属于当前页面生命周期的复杂交互状态,可以放组件;
- 需要跨页面共享的服务器数据,才考虑 Store 或专门的数据缓存层。
2. Store 不等于 API 层
把所有内容都写进 Action 会导致 Store 变成巨大模块:
actions: {
async fetchA() {},
async fetchB() {},
async transformC() {},
async uploadD() {},
async exportE() {},
}
可以把纯 API 调用放到服务模块:
// services/productApi.ts
export async function fetchProducts(): Promise<Product[]> {
const response = await fetch('/api/products')
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return response.json() as Promise<Product[]>
}
Store 负责业务状态:
import { fetchProducts } from '@/services/productApi'
actions: {
async load() {
this.status = 'loading'
try {
this.items = await fetchProducts()
this.status = 'success'
} catch (error) {
this.status = 'error'
throw error
}
},
}
这样 API 层可以独立测试,Store 仍然保留“请求如何影响应用状态”的职责。
十五、一个完整的购物车 Store
下面把 State、Getter、Action、错误处理和持久化边界放在一个例子中:
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
type CartItem = {
productId: string
name: string
price: number
quantity: number
}
type SubmitOrderResult = {
orderId: string
}
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const status = ref<'idle' | 'submitting' | 'success' | 'error'>('idle')
const errorMessage = ref<string | null>(null)
const itemCount = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0),
)
const subtotal = computed(() =>
items.value.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
),
)
const isEmpty = computed(() => items.value.length === 0)
function addItem(product: Omit<CartItem, 'quantity'>) {
const existing = items.value.find(
(item) => item.productId === product.productId,
)
if (existing) {
existing.quantity++
return
}
items.value.push({
...product,
quantity: 1,
})
}
function removeItem(productId: string) {
items.value = items.value.filter(
(item) => item.productId !== productId,
)
}
function setQuantity(productId: string, quantity: number) {
if (!Number.isInteger(quantity) || quantity < 1) {
throw new Error('商品数量必须是正整数')
}
const item = items.value.find(
(item) => item.productId === productId,
)
if (!item) {
throw new Error('商品不在购物车中')
}
item.quantity = quantity
}
function clear() {
items.value = []
status.value = 'idle'
errorMessage.value = null
}
async function submitOrder(): Promise<SubmitOrderResult> {
if (isEmpty.value) {
throw new Error('购物车为空')
}
status.value = 'submitting'
errorMessage.value = null
try {
const response = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
items: items.value.map((item) => ({
productId: item.productId,
quantity: item.quantity,
})),
}),
})
if (!response.ok) {
throw new Error(`提交订单失败:HTTP ${response.status}`)
}
const result = (await response.json()) as SubmitOrderResult
clear()
status.value = 'success'
return result
} catch (error) {
status.value = 'error'
errorMessage.value =
error instanceof Error ? error.message : '提交订单失败'
throw error
}
}
return {
items,
status,
errorMessage,
itemCount,
subtotal,
isEmpty,
addItem,
removeItem,
setQuantity,
clear,
submitOrder,
}
})
这个 Store 的几个关键不变量是:
- 每个
productId最多对应一个购物车项; quantity必须是正整数;itemCount和subtotal始终由items计算;- 提交失败时保留购物车,用户可以重试;
- 提交成功后清空购物车;
- 网络错误通过 Promise 传给调用方,而不是静默吞掉。
如果订单接口成功但客户端在收到响应前断网,客户端无法仅凭一次请求判断订单是否已经创建。此时不能简单重复提交,否则可能造成重复订单。应由后端提供幂等键,客户端在 Action 中发送同一个业务请求 ID。
十六、规范保证、实现细节与经验选择
Pinia 的核心保证
- Store 通过唯一 ID 注册和复用;
- State、Getter 和 Action 可以被 Vue 响应式系统观察;
- Store 可以通过插件扩展;
$patch、$reset、$subscribe等 API 提供明确的状态操作和观察能力;- 在 SSR 中,应用应使用请求级 Pinia 实例以避免状态泄露。
依赖环境或插件的能力
localStorage持久化不是 Pinia 核心默认行为;- IndexedDB、Cookie 和服务端缓存需要额外实现;
- SSR 状态注入和安全序列化由 SSR 工具链共同决定;
- 某些持久化插件的配置项、迁移能力和水合行为属于插件自身 API,不能当成 Pinia 通用规范。
需要根据业务选择的策略
- 认证令牌放 Cookie 还是 Web Storage;
- 缓存保存多久;
- 失败时保留旧数据还是清空;
- 旧请求通过取消、序号还是两者共同处理;
- URL、组件状态和 Store 谁作为某个字段的权威来源;
- 多个 Store 是否应该合并或拆分。
Pinia 解决的是响应式状态组织问题,不会自动解决认证安全、请求幂等、缓存失效、跨标签页同步或 SSR 数据隔离。越靠近这些边界,越需要明确数据的所有者、生命周期、序列化方式和失败路径。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue Router 完整指南:路由匹配、守卫、懒加载和滚动行为
- 下一篇:Vue 异步数据与请求状态:取消、竞态、缓存、分页和错误恢复
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论