L11 · Pinia:全局状态管理
🎯 本节目标:用 Pinia 管理跨组件共享状态,迁移现有 Todo 业务逻辑
📦 本节产出:完整的 Pinia store 架构 + 持久化插件 + 多 store 组合
🔗 前置钩子:L10 的多路由架构(跨路由共享数据的需求)
🔗 后续钩子:L12 将在 store 基础上实现标签分类系统1. 为什么需要 Pinia
1.1 Composable 的局限
// useTodos() 在不同组件中调用 → 创建不同的 ref 实例
// HomeView.vue
const { todos } = useTodos() // ref 实例 A
// StatsView.vue
const { todos } = useTodos() // ref 实例 B ← 不是同一个!L08 的 useTodos 在函数内创建状态,所以每次调用有独立副本。Composable 也可以通过模块级变量或 provide/inject 共享状态;Pinia 进一步提供统一的 Store 生命周期、插件和调试集成。
1.2 Pinia 解决什么
2. 安装和创建 Store
npm install pinia@3课程使用 Vue 3.5.13+ 的 3.5.x、Pinia 3 和 TypeScript 5;Pinia 3 要求 Vue 至少 3.5.11。
2.1 注册 Pinia
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './assets/main.css'
const app = createApp(App)
app.use(createPinia()) // 安装 Pinia
app.use(router)
app.mount('#app')2.2 创建 Task Store(Setup 语法)
// src/stores/taskStore.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { createTodoId, isTodoList, type Todo, type FilterType } from '@/types/todo'
// 首次迁移时保留 L08 的数据;后续由持久化插件恢复 Store 状态
function loadLegacyTodos(): Todo[] {
try {
const stored = localStorage.getItem('vue-todo-list')
if (stored !== null) {
const parsed: unknown = JSON.parse(stored)
if (isTodoList(parsed)) return parsed
}
} catch (error) {
console.error('读取旧版 Todo 数据失败', error)
}
return [
{ id: 1, text: '搭建项目脚手架', done: true, priority: 'low', createdAt: '2024-01-01' },
{ id: 2, text: '学习 Vue 3', done: false, priority: 'high', createdAt: '2024-01-02' },
]
}
// Setup 语法:像写 Composition API 一样写 Store
export const useTaskStore = defineStore('tasks', () => {
// State → ref()
const todos = ref<Todo[]>(loadLegacyTodos())
const filter = ref<FilterType>('all')
// Getters → computed()
const filteredTodos = computed(() => {
switch (filter.value) {
case 'active': return todos.value.filter(t => !t.done)
case 'done': return todos.value.filter(t => t.done)
default: return todos.value
}
})
const stats = computed(() => {
const total = todos.value.length
const doneCount = todos.value.filter(t => t.done).length
return {
total,
doneCount,
activeCount: total - doneCount,
donePercent: total > 0 ? Math.round((doneCount / total) * 100) : 0,
}
})
// Actions → 普通函数
function addTodo(text: string) {
const trimmed = text.trim()
if (!trimmed) return
todos.value.push({
id: createTodoId(todos.value), text: trimmed, done: false,
priority: 'medium',
createdAt: new Date().toISOString().slice(0, 10),
})
}
function toggleTodo(id: number) {
const todo = todos.value.find(t => t.id === id)
if (todo) todo.done = !todo.done
}
function deleteTodo(id: number) {
todos.value = todos.value.filter(t => t.id !== id)
}
function updateTodo(id: number, text: string) {
const todo = todos.value.find(t => t.id === id)
if (todo && text.trim()) todo.text = text.trim()
}
function clearDone() {
todos.value = todos.value.filter(t => !t.done)
}
return {
todos, filter, filteredTodos, stats,
addTodo, toggleTodo, deleteTodo, updateTodo, clearDone,
}
})Setup Store 沿用 Composition API 写法:返回的 ref 是 State,computed 是 Getter,函数是 Action。状态需要返回给 Pinia,Setup Store 的重置等行为仍需自行实现,不能把它完全当成普通 composable。
3. 在组件中使用 Store
<!-- src/views/HomeView.vue -->
<script setup lang="ts">
import { useTaskStore } from '@/stores/taskStore'
import TodoItem from '@/components/todo/TodoItem.vue'
import TodoInput from '@/components/todo/TodoInput.vue'
import TodoFilter from '@/components/todo/TodoFilter.vue'
import TodoStats from '@/components/todo/TodoStats.vue'
// 获取 Store 实例
const taskStore = useTaskStore()
// 直接解构会丢失响应式 → 用 storeToRefs
import { storeToRefs } from 'pinia'
const { filteredTodos, filter, stats } = storeToRefs(taskStore)
// Action 可以直接解构(函数不需要响应式)
const { addTodo, toggleTodo, deleteTodo, updateTodo, clearDone } = taskStore
</script>
<template>
<div>
<TodoInput @add="addTodo" />
<TodoStats v-bind="stats" />
<TodoFilter
v-model="filter"
:total-count="stats.total"
:active-count="stats.activeCount"
:done-count="stats.doneCount"
@clear-done="clearDone"
/>
<TransitionGroup name="list" tag="div" class="todo-list">
<TodoItem
v-for="todo in filteredTodos"
:key="todo.id"
v-bind="todo"
@toggle="toggleTodo"
@delete="deleteTodo"
@update="updateTodo"
/>
</TransitionGroup>
</div>
</template>保留 HomeView 中 L09 的列表动画样式。将 StatsView 的 useTodos 替换成 useTaskStore,再用 storeToRefs(taskStore) 取得 stats;TaskDetailView 则改为:
<!-- src/views/TaskDetailView.vue 的 script,保留原模板 -->
<script setup lang="ts">
import { computed } from 'vue'
import { useTaskStore } from '@/stores/taskStore'
const props = defineProps<{ id: string }>()
const taskStore = useTaskStore()
const todo = computed(() => taskStore.todos.find(item => item.id === Number(props.id)))
</script>迁移后,这些页面不再调用旧 useTodos,以免同时维护两份状态和存储监听器。
3.1 storeToRefs:为什么需要
// ❌ 直接解构 → 丢失响应式(和 reactive 一样的问题)
const { todos, filter } = taskStore // filter 是值快照;todos 仍是代理,但会错过 Store 对数组的整体替换
// ✅ storeToRefs → 保持响应式
const { todos, filter } = storeToRefs(taskStore) // todos 仍然是 ref
// ✅ Actions 可以直接解构(函数不需要响应式)
const { addTodo, deleteTodo } = taskStore4. 多 Store 组合
// src/stores/userStore.ts
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', () => {
const user = ref<{ name: string; email: string } | null>(null)
const isLoggedIn = computed(() => !!user.value)
function login(name: string, email: string) {
user.value = { name, email }
}
function logout() {
user.value = null
}
return { user, isLoggedIn, login, logout }
})// src/stores/uiStore.ts
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const useUIStore = defineStore('ui', () => {
const theme = ref<'light' | 'dark'>('light')
const sidebarOpen = ref(true)
function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
function toggleSidebar() {
sidebarOpen.value = !sidebarOpen.value
}
return { theme, sidebarOpen, toggleTheme, toggleSidebar }
})Store 间引用: 下面演示业务依赖;此 userStore 仅保存演示资料,并未与 L10 的登录标记接通。若练习此校验,需先调用它的 login,真实认证留待 L22 实现。
// 演示片段:将依赖与校验加入现有 Store,不是完整替换文件
import { useUserStore } from './userStore'
export const useTaskStore = defineStore('tasks', () => {
const userStore = useUserStore()
function addTodo(text: string) {
if (!userStore.isLoggedIn) {
throw new Error('请先登录')
}
// ...
}
})5. 持久化插件
npm install pinia-plugin-persistedstate@4// src/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 './assets/main.css'
const app = createApp(App)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
app.use(router)
app.mount('#app')// 在 Store 中启用持久化
export const useTaskStore = defineStore('tasks', () => {
// ...状态和逻辑...
return { todos, filter, /* ... */ }
}, {
persist: {
key: 'vue-task-store',
pick: ['todos', 'filter'],
debug: true, // 开发时输出存储异常,不能把失败当作保存成功
},
})持久化插件默认使用浏览器 localStorage,并在创建 Store 时恢复状态。vue-task-store 使用与 L08 不同的键,避免把旧数组当成 Store 对象;旧数据通过 loadLegacyTodos 作初次迁移。插件序列化不会自动做领域校验,导入旧数据或修改结构时仍需校验和迁移,L12 会处理新字段。pick 是本课插件 v4 的配置名。
参考 Pinia 核心概念 与 持久化插件配置。
6. Pinia DevTools
在 Vue DevTools 的 Pinia 标签页中可以:
- 查看所有 Store 的当前状态
- 查看状态变化时间线(具体调试能力取决于 DevTools 版本;不要把它当成应用的 undo 功能)
- 直接编辑 Store 数据
- 追踪 Action 调用记录
7. 本节总结
🔬 深度专题
📖 D08 · Pinia vs Vuex 4 设计决策 — 为什么 Pinia 成为官方推荐?
检查清单
- [ ] 能安装和配置 Pinia
- [ ] 能用 Setup 语法创建 Store
- [ ] 知道
storeToRefs的必要性 - [ ] 能在组件中使用 Store 的 State / Getter / Action
- [ ] 能实现多 Store 组合
- [ ] 能配置
pinia-plugin-persistedstate持久化
课后练习
练习 1:跟做(15 min) 完整实现 taskStore + 在 HomeView 中使用 storeToRefs 解构 + 渲染 filteredTodos。
练习 2:举一反三(20 min) 在 taskStore 中增加 sortBy state('date' | 'priority' | 'name'),增加对应的 sortedTodos computed,让 filteredTodos 在筛选之后再排序。
挑战题(30 min) 实现一个简单的 undo 功能:在 taskStore 中维护一个 history: Todo[][] 数组,每次修改前把当前 todos 快照 push 进 history。实现 undo() action,从 history 中恢复上一个状态。
🐞 防坑指南
| 坑 | 说明 | 正确做法 |
|---|---|---|
| 直接解构 store | const { count } = store 丢失响应式 | const { count } = storeToRefs(store) |
| 组件外使用 store | 在 .ts 文件中顶层调用 useStore() | 必须在 setup() 内或 Pinia 已安装后调用 |
| store 当全局变量用 | 什么数据都塞进 store | 优先将共享或需集中管理的业务状态放 store,输入草稿可留在组件 |
| 将直接修改 state 当成禁用行为 | Pinia 允许 store.count++ 和 $patch | 业务校验与多步修改集中在 action,便于复用与追踪 |
📐 最佳实践
- store 命名:
useXxxStore配合id: 'xxx',保持一致 - getter 代替组件 computed:能在 store 的 getter 做的计算不要在组件里重复
- action 职责:action 负责业务逻辑(校验 + 修改),组件只调用 action
- 持久化选择性:只持久化需要的 key(
persist: { pick: ['todos'] }),不要全量序列化
Git 提交
git add .
git commit -m "L11: Pinia 全局状态管理 + 持久化"🔗 钩子连接
→ 下一节:L12 · 任务分类与标签系统
L12 将在 Pinia Store 基础上为任务添加分类、优先级筛选和标签管理。