Vue.js从零到精通系列(五):全局状态管理——Pinia 核心与实践

摘要: 在上一篇中,我们借助 Vue Router 把待办应用改造为多视图单页应用(SPA),拆分出列表、详情、设置三大页面,通过手写 useTodoStore 组合式函数完成全局数据共享与本地存储持久化。但随着业务迭代、项目体量变大,这种原生手写全局状态方案短板逐步暴露:无法在 Vue Devtools 可视化调试状态流转、无标准化插件扩展能力、TS 类型推导繁琐、多模块状态难以规范化管理。

本篇正式接入 Vue3 官方指定状态管理库 Pinia ,先拆解手写 Store 的局限性与 Pinia 的核心优势;完整迁移重构原有 Todo 项目,手把手掌握 defineStore 组合式写法、State/Getters/Actions 三大核心模块;搭配 pinia-plugin-persistedstate 一行配置实现自动持久化,彻底剔除手动操作 localStorage 的冗余代码。重构后完整保留原有全部业务功能,同时获得状态调试、模块化拆分、精准类型推断、插件扩展等工程化能力,熟练掌握多页面组件间安全、规范、可追溯的数据流转方案。


一、手写 Store 真的够用吗?为什么要引入 Pinia

1.1 回顾上一版原生全局状态实现

上一章我们用 Vue 原生 reactive + computed 封装组合式函数,实现全局 Todo 数据管理,核心代码精简如下:

TypeScript 复制代码
// src/store/todoStore.ts(原生手写版本)
import { reactive, computed, watch } from 'vue'
import type { Todo } from '../types'
​
export function useTodoStore() {
  const state = reactive({
    todos: [] as Todo[],
    nextId: 1
  })
​
  // 计算属性
  const total = computed(() => state.todos.length)
  const activeCount = computed(() => state.todos.filter(item => !item.done).length)
​
  // 增删改业务方法
  function addTodo(text: string) {
    const val = text.trim()
    if (!val) return
    state.todos.push({ id: state.nextId++, text: val, done: false })
  }
​
  // 手动监听数据变化,写入本地存储
  watch(() => state.todos, (val) => {
    localStorage.setItem('todo-data', JSON.stringify(val))
  }, { deep: true, flush: 'post' })
​
  // 页面初始化读取本地缓存
  const loadStorage = () => {/* 读取、解析localStorage */}
  loadStorage()
​
  return { state, total, activeCount, addTodo, /* 其余方法 */ }
}

组件内直接调用 const store = useTodoStore() 就能拿到状态和操作方法,小型 Demo 完全够用,但中大型项目会暴露出不可忽视的缺陷。

1.2 原生手写方案的四大硬伤

无 Devtools 调试能力 Vue 开发者工具无法识别自定义组合式 Store,不能查看状态快照、无法回溯每一次数据修改的调用来源、不能在线手动修改状态调试界面,排查 Bug 只能靠 console.log

模块化无统一规范 项目拆分用户、购物车、配置等多套全局状态时,只能依靠文件夹、文件名人工区分,无内置命名隔离机制,多人协作极易出现变量、方法重名冲突。

缺少插件扩展体系 持久化、全局请求拦截、操作日志打印、权限拦截等通用逻辑,只能在每一个 Store 里重复复制粘贴,无法全局一次性注册复用,代码冗余度极高。

TypeScript 类型体验差 响应式对象、返回值需要频繁手动类型断言,无法自动推导完整类型,编辑器智能提示残缺,长期开发类型隐患多。

1.3 Pinia 诞生背景与核心定位

Pinia 最初是 Vuex 5 的实验原型,由 Vue 核心团队成员 Eduardo 开发,2022 年正式成为 Vue3 官方默认状态管理库 。 它彻底舍弃了 Vuex 繁琐的 Mutations 强制同步更新规则,全面兼容组合式 API,本质就是标准化、带官方调试工具、支持插件扩展、TS 原生友好的升级版组合式 Store

对比表格直观区分两种方案:

特性 原生手写 Composable Store Pinia 标准 Store
Devtools 调试 不识别,无状态追踪 原生支持,时间线回溯、在线改值
持久化实现 手动 watch + localStorage 插件一行配置自动完成
模块隔离 靠人工文件划分,无隔离 defineStore 唯一 ID 天然隔离
TS 类型推导 频繁手动断言,提示不全 全自动推导,无需额外类型定义
插件机制 无,逻辑重复拷贝 全局注册插件,一次配置多处生效

二、Pinia 安装与项目初始化接入

2.1 项目前置说明

基于上一篇完整可运行的 vue-todo-spa 项目迭代,原有目录结构不变,仅重构状态管理层:

TypeScript 复制代码
vue-todo-spa/
├── src/
│   ├── main.ts                // 项目入口,注册Pinia+路由
│   ├── types.ts               // Todo TS类型定义
│   ├── router/index.ts        // Vue Router路由配置
│   ├── store/todoStore.ts     // 待替换的旧手写Store
│   ├── components/            // 所有Todo子UI组件
│   ├── views/                 // 列表/详情/设置页面组件
│   └── App.vue                // 根布局+全局导航
└── package.json

2.2 安装依赖(核心库+持久化插件)

执行 pnpm 安装命令:

TypeScript 复制代码
# 安装Pinia核心库
pnpm add pinia
​
# 安装持久化插件,自动把状态存入localStorage
pnpm add pinia-plugin-persistedstate

2.3 在入口 main.ts 全局注册 Pinia

TypeScript 复制代码
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import App from './App.vue'
import router from './router'
import './style.css'
​
const app = createApp(App)
​
// 1. 创建Pinia实例
const pinia = createPinia()
// 2. 挂载持久化插件
pinia.use(piniaPluginPersistedstate)
// 3. Pinia注入Vue全局应用
app.use(pinia)
​
// 注册路由
app.use(router)
app.mount('#app')

注册顺序无强制要求,Pinia 在 Router 前后注册均可;推荐前置注册,避免路由守卫内部无法调用 Store。

启动项目 pnpm dev,浏览器打开 Vue Devtools(如若没有,需要先获取扩展),会新增独立的 Pinia 标签页,此时暂无自定义仓库,接入完成。


三、重构 Todo 全局仓库:Pinia 组合式标准写法

社区约定多仓库统一放在 stores 文件夹(复数形式),新建 src/stores/todo.ts,替代旧的 store/todoStore.ts

3.1 完整 Pinia Todo Store 代码

TypeScript 复制代码
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import type { Todo } from '../types'
​
// defineStore(仓库唯一ID, 组合式逻辑函数, 配置项)
export const useTodoStore = defineStore('todo', () => {
  // ========== State:响应式状态,等同于组件ref/reactive ==========
  const todos = ref<Todo[]>([])
  const nextId = ref(1)
​
  // ========== Getters:计算属性,依赖State自动缓存 ==========
  // 总任务数量
  const total = computed(() => todos.value.length)
  // 未完成任务数
  const activeCount = computed(() => todos.value.filter(t => !t.done).length)
  // 是否全部任务勾选完成
  const allDone = computed(() => todos.value.length > 0 && activeCount.value === 0)
  // 根据ID查询单个任务(返回查询函数)
  const getTodoById = computed(() => (id: number) => todos.value.find(t => t.id === id))
​
  // ========== Actions:同步/异步业务方法,修改State唯一入口 ==========
  // 新增待办
  function addTodo(text: string) {
    const trimmed = text.trim()
    if (!trimmed) return
    todos.value.push({
      id: nextId.value++,
      text: trimmed,
      done: false
    })
  }
​
  // 切换任务完成状态
  function toggleTodo(id: number) {
    const target = todos.value.find(item => item.id === id)
    if (target) target.done = !target.done
  }
​
  // 删除单个任务
  function removeTodo(id: number) {
    todos.value = todos.value.filter(item => item.id !== id)
  }
​
  // 清空已完成任务
  function clearCompleted() {
    todos.value = todos.value.filter(item => !item.done)
  }
​
  // 一键全部勾选完成
  function checkAll() {
    todos.value.forEach(item => item.done = true)
  }
​
  // 一键取消全部勾选
  function unCheckAll() {
    todos.value.forEach(item => item.done = false)
  }
​
  // 向外导出所有状态、计算属性、方法
  return {
    // 原始状态
    todos,
    nextId,
    // 计算属性Getters
    total,
    activeCount,
    allDone,
    getTodoById,
    // 操作方法Actions
    addTodo,
    toggleTodo,
    removeTodo,
    clearCompleted,
    checkAll,
    unCheckAll
  }
}, {
  // 开启插件自动持久化
  persist: true
})

3.2 核心语法要点拆解

defineStore('todo', setupFn, options)

  • 第一个参数:仓库唯一ID,全局不可重复,Devtools 以此区分不同仓库;

  • 第二个参数:和 <script setup> 写法完全一致,直接使用 ref/computed

  • 第三个配置项:开启持久化、自定义序列化规则等。

三层结构分工清晰

  • State:原始响应式数据,不能在组件内随意直接批量改写;

  • Getters:封装派生数据(统计、筛选、查询),自带缓存,重复调用不会重复计算;

  • Actions:所有修改状态的逻辑统一封装在这里,方便统一日志、校验、拦截。

无需手动处理 localStorage persist: true 开启后,插件自动监听 State 变化存入本地存储,页面刷新自动恢复数据,原有手写的读取、解析、异常捕获代码全部删除。


四、批量修改页面组件,接入 Pinia Store

原有组件调用方式改动极小,仅修改导入路径 + 删除多余的 .state 层级,业务模板代码无需改动。

4.1 修改列表页 TodoListPage.vue

TypeScript 复制代码
<script setup lang="ts">
import { useRouter } from 'vue-router'
// 路径替换为新stores文件夹
import { useTodoStore } from '../stores/todo'
import TodoHeader from '../components/TodoHeader.vue'
import TodoInput from '../components/TodoInput.vue'
import TodoList from '../components/TodoList.vue'
import TodoItem from '../components/TodoItem.vue'
import TodoFooter from '../components/TodoFooter.vue'
​
const router = useRouter()
// 直接获取仓库实例,不再有.state嵌套层级
const store = useTodoStore()
</script>
​
<template>
  <div class="page-wrap">
    <div class="card">
      <TodoHeader
        :active-count="store.activeCount"
        :total="store.total"
      />
      <TodoInput @add="store.addTodo" />
​
      <!-- 直接访问store.todos,不再是store.state.todos -->
      <TodoList :todos="store.todos">
        <template #item="{ todo }">
          <TodoItem
            :todo="todo"
            :go-detail="() => router.push({name:'Detail', params:{id:todo.id}})"
            @toggle="store.toggleTodo"
            @remove="store.removeTodo"
          />
        </template>
      </TodoList>
​
      <TodoFooter
        v-if="store.todos.length"
        :is-all-checked="store.allDone"
        @check-all="store.checkAll"
        @un-check-all="store.unCheckAll"
        @clear-completed="store.clearCompleted"
      />
    </div>
  </div>
</template>
​
<style scoped>/* 样式完全不变,省略 */</style>

4.2 修改详情页 TodoDetailPage.vue

TypeScript 复制代码
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useTodoStore } from '../stores/todo'
​
const router = useRouter()
const route = useRoute()
const store = useTodoStore()
​
const tid = computed(() => Number(route.params.id))
// Getters返回查询函数,调用传参获取单条任务
const todo = computed(() => store.getTodoById(tid.value))
​
// 返回列表
const backList = () => router.push('/list')
// 删除当前任务
const delCurrent = () => {
  if (todo.value) store.removeTodo(tid.value)
  backList()
}
​
// 路由ID变更校验任务是否存在
watch(
  () => route.params.id,
  () => !todo.value && backList(),
  { immediate: true }
)
</script>
​
<template>/* 模板无改动,直接沿用 */</template>

4.3 修改设置页 SettingPage.vue

TypeScript 复制代码
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useTodoStore } from '../stores/todo'
​
const router = useRouter()
const store = useTodoStore()
​
// 清空全部任务
const clearAll = () => {
  if(confirm('确定清空所有任务?不可恢复!')) {
    // Pinia支持直接赋值修改ref类型state
    store.todos = []
    store.nextId = 1
  }
}
// 手动清空本地缓存(插件持久化数据)
const clearStorage = () => {
  localStorage.removeItem('todo-data')
  alert('本地存储已清空,刷新页面生效')
}
</script>
​
<template>/* 模板无改动 */</template>

4.4 清理旧代码

所有组件导入路径全部替换完成后,直接删除 src/store/todoStore.ts 旧文件,项目结构彻底规范化。 运行 pnpm dev,页面功能和重构前完全一致,无任何业务回归。


五、持久化插件高级配置(可选拓展)

上文 persist: true 是最简写法,支持对象形式精细化配置存储规则:

TypeScript 复制代码
persist: {
  key: 'vue-todo-pinia-data',    // 自定义localStorage存储key
  storage: sessionStorage,        // 切换存储引擎(关闭标签页数据自动销毁)
  paths: ['todos'],               // 仅持久化todos,nextId不存入本地
  beforeRestore: (ctx) => {       // 数据恢复前置钩子
    console.log('开始读取本地缓存', ctx)
  }
}

六、跨仓库互相调用(多Store协作实战)

真实项目会拆分用户、购物车、配置等多个独立仓库,Pinia 支持仓库间互相导入调用,无需复杂命名空间配置。

6.1 新建用户仓库 src/stores/user.ts

TypeScript 复制代码
import { ref } from 'vue'
import { defineStore } from 'pinia'
​
export const useUserStore = defineStore('user', () => {
  const username = ref('游客未登录')
​
  // 登录方法
  function login(name: string) {
    username.value = name
  }
​
  return { username, login }
})

6.2 在 Todo 仓库内调用用户仓库

TypeScript 复制代码
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
// 导入用户仓库
import { useUserStore } from './user'
import type { Todo } from '../types'
​
export const useTodoStore = defineStore('todo', () => {
  // 直接实例化另一个仓库
  const userStore = useUserStore()
​
  const todos = ref<Todo[]>([])
  const nextId = ref(1)
​
  // 新增任务增加登录校验
  function addTodo(text: string) {
    // 读取用户仓库状态做权限拦截
    if (userStore.username === '游客未登录') {
      alert('请登录后再添加待办任务!')
      return
    }
    const trimmed = text.trim()
    if (!trimmed) return
    todos.value.push({ id: nextId.value++, text: trimmed, done: false })
  }
​
  // 其余代码不变...
}, { persist: true })

跨仓库调用语法简洁直观,无 Vuex 嵌套模块、根根访问器等复杂语法,多人协作维护成本极低。


七、全量功能回归测试

重构后完整复测所有原有业务功能,保证无改动、无Bug:

列表页:新增、勾选、删除、批量全选/取消、清空已完成、空列表提示全部正常;

详情页:路由ID匹配任务、修改状态、删除跳转、无效ID自动回退列表页;

设置页:一键清空所有任务、手动清除本地存储、返回列表功能正常;

持久化校验:新增多条任务,刷新浏览器页面,数据完整保留,插件自动完成存储恢复。


八、总结

通过本篇,我们在上一篇的项目基础上,将手写的组合式 Store 升级为 Pinia 官方状态管理库。我们不仅完成了功能等价的重构,还额外获得了 Devtools 调试能力和插件化的数据持久化。Pinia 并没有颠覆你之前对状态管理的认知,而是用更规范、更强大的工具把你已经熟悉的组合式 API 模式包装了起来,让你在面对大型项目时能更加从容。

  • Pinia 是 Vue 3 的默认状态管理库,可以看作是"带插件的 Composable Store"。

  • 使用 defineStore(id, () => { ... }) 创建组合式 Store,State 用 ref,Getter 用 computed,Action 用普通函数。

  • 在组件中调用 useXxxStore() 获取 Store 实例,直接访问属性和方法,无需 .value(在模板中)。

  • 借助 pinia-plugin-persistedstate 插件,可以一行配置实现自动持久化,告别手写 watch + localStorage

  • Pinia 与 Vue Devtools 深度集成,提供状态检查和时间旅行调试。

  • Store 之间可以自由相互导入调用,模块化极其自然。


如果这篇文章帮你解决了实操上的困惑,别忘记点击点赞、分享 ,也可以留言告诉我你遇到的其它问题,我会尽快回复。动手练习是掌握编程最快的方法,请务必亲手敲一遍本文的所有示例代码,并截图保存你的成果。你的关注是我坚持原创和细节共享的力量来源,谢谢大家。

相关推荐
我不是外星人2 小时前
浅谈我对 AI 发展的看法
前端·ai编程·claude
kmblack12 小时前
javascript计算年龄
开发语言·javascript·ecmascript
老马聊技术2 小时前
AI对话功能之SpringBoot整合Vue3
vue.js·人工智能·spring boot·后端
甲维斯2 小时前
测一波Kimi K2.7,消耗一周配额!
前端·人工智能·游戏开发
Dick5072 小时前
ROS2 多机器人通用 Driver 层复盘:BaseRobotDriver 到多平台 Mock 切换实现
前端·javascript·机器人
英勇无比的消炎药3 小时前
一站式汇总TinyVue工具案例与真实落地经验
vue.js·前端框架
xiaofeichaichai3 小时前
前端安全 XSS 与 CSRF
前端·安全·xss
JS菌3 小时前
Skills 动态加载系统:让 AI Agent 按需获取领域知识
前端·人工智能·后端