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 时重点检查:

  1. 构建、测试和 Node 脚本是否都能消费 ESM;
  2. 是否安装了兼容版本的 @vue/devtools-api
  3. Pinia 插件和持久化插件是否支持 4.x;
  4. CI 是否覆盖生产构建、Store 测试和 SSR(如有);
  5. 旧依赖是否偷偷通过 CommonJS require('pinia') 加载。

小结

Pinia Store 应围绕跨组件的业务状态建模。storeToRefs() 用于解构 state 和 getter,Action 承担可复用业务修改,$patch() 适合批量变化。Pinia 4 的业务 API 延续性很强,但 ESM-only 和 DevTools peer dependency 是升级时必须验证的边界。

官方资料

系列导航:目录 · 上一篇 · 下一篇