摘要:在上一篇中,我们借助 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 数据管理,核心代码精简如下:
// 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项目迭代,原有目录结构不变,仅重构状态管理层:
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.json2.2 安装依赖(核心库+持久化插件)
执行 pnpm 安装命令:
# 安装Pinia核心库 pnpm add pinia # 安装持久化插件,自动把状态存入localStorage pnpm add pinia-plugin-persistedstate2.3 在入口 main.ts 全局注册 Pinia
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 代码
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:所有修改状态的逻辑统一封装在这里,方便统一日志、校验、拦截。
无需手动处理localStoragepersist: true开启后,插件自动监听 State 变化存入本地存储,页面刷新自动恢复数据,原有手写的读取、解析、异常捕获代码全部删除。
四、批量修改页面组件,接入 Pinia Store
原有组件调用方式改动极小,仅修改导入路径 + 删除多余的.state层级,业务模板代码无需改动。
4.1 修改列表页 TodoListPage.vue
<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
<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
<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是最简写法,支持对象形式精细化配置存储规则:
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
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 仓库内调用用户仓库
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 之间可以自由相互导入调用,模块化极其自然。
如果这篇文章帮你解决了实操上的困惑,别忘记点击点赞、分享,也可以留言告诉我你遇到的其它问题,我会尽快回复。动手练习是掌握编程最快的方法,请务必亲手敲一遍本文的所有示例代码,并截图保存你的成果。你的关注是我坚持原创和细节共享的力量来源,谢谢大家。