摘要: 在上一篇中,我们借助 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 之间可以自由相互导入调用,模块化极其自然。
如果这篇文章帮你解决了实操上的困惑,别忘记点击点赞、分享 ,也可以留言告诉我你遇到的其它问题,我会尽快回复。动手练习是掌握编程最快的方法,请务必亲手敲一遍本文的所有示例代码,并截图保存你的成果。你的关注是我坚持原创和细节共享的力量来源,谢谢大家。