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,而不是重复存储。

可以把应用状态简单表示为:

St+1=A(St,It)S_{t+1} = A(S_t, I_t)

其中:

  • StS_t 是时刻 tt 的状态;
  • ItI_t 是用户操作、服务器响应等输入;
  • AA 是改变状态的 Action;
  • St+1S_{t+1} 是操作后的新状态。

Getter 则是状态的函数:

G=f(S)G = f(S)

它不应该成为另一个独立事实源,而应该随着依赖的状态变化自动重新计算。


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 的灵活性更高,可以直接使用 watchinject 和组合式函数,但 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
      }
    },
  },
})

这里的逻辑是:

  1. 每次搜索先递增 requestVersion
  2. 当前请求保存发出时的版本号;
  3. 响应到达后检查版本号;
  4. 如果 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 },
  )
}

这里有几个重要边界:

  1. localStorage 只存在于浏览器,SSR 中访问会抛出 ReferenceError
  2. 必须捕获 JSON 解析错误,因为存储内容可能损坏或来自旧版本;
  3. 只保存需要的字段,避免把临时状态、错误信息和大对象全部写入;
  4. detached: true 让订阅不依赖某个组件的生命周期;
  5. 这段代码应只初始化一次,否则会重复注册监听器。

如果希望把逻辑封装成 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 的基本条件是:

PAPBP_A \ne P_B

其中 PAP_APBP_B 分别表示请求 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,
}

原因有三类:

  1. 它们通常不能被安全序列化;
  2. 它们属于当前运行时,而不是业务数据;
  3. 它们跨服务端和客户端没有相同语义。

可以把请求状态和请求数据分开:

state: () => ({
  product: null as Product | null,
  status: 'idle' as RequestStatus,
  errorMessage: null as string | null,
})

而把 AbortController 保留在 Action 闭包或模块的运行时变量中,并确保它不会成为要水合的 Store 状态。


5. SSR 中使用 windowdocumentlocalStorage

服务端没有浏览器 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 状态包含未保存的字段。

诊断步骤:

  1. 查看浏览器存储中是否存在预期键;
  2. 检查写入回调是否被触发;
  3. 打印版本号和解析结果;
  4. 确认服务端与客户端恢复顺序;
  5. 确认是否在多个地方重复初始化持久化插件。

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 必须是正整数;
  • itemCountsubtotal 始终由 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、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。