Skip to content

L11 · Pinia:全局状态管理 ​

🎯 本节目标:用 Pinia 管理跨组件共享状态,迁移现有 Todo 业务逻辑
📦 本节产出:完整的 Pinia store 架构 + 持久化插件 + 多 store 组合
🔗 前置钩子:L10 的多路由架构(跨路由共享数据的需求)
🔗 后续钩子:L12 将在 store 基础上实现标签分类系统

1. 为什么需要 Pinia ​

1.1 Composable 的局限 ​

typescript
// 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 ​

bash
npm install pinia@3

课程使用 Vue 3.5.13+ 的 3.5.x、Pinia 3 和 TypeScript 5;Pinia 3 要求 Vue 至少 3.5.11。

2.1 注册 Pinia ​

typescript
// 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 语法) ​

typescript
// 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 ​

vue
<!-- 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 则改为:

vue
<!-- 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:为什么需要 ​

typescript
// ❌ 直接解构 → 丢失响应式(和 reactive 一样的问题)
const { todos, filter } = taskStore  // filter 是值快照;todos 仍是代理,但会错过 Store 对数组的整体替换

// ✅ storeToRefs → 保持响应式
const { todos, filter } = storeToRefs(taskStore)  // todos 仍然是 ref

// ✅ Actions 可以直接解构(函数不需要响应式)
const { addTodo, deleteTodo } = taskStore

4. 多 Store 组合 ​

typescript
// 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 }
})
typescript
// 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 实现。

typescript
// 演示片段:将依赖与校验加入现有 Store,不是完整替换文件
import { useUserStore } from './userStore'

export const useTaskStore = defineStore('tasks', () => {
  const userStore = useUserStore()

  function addTodo(text: string) {
    if (!userStore.isLoggedIn) {
      throw new Error('请先登录')
    }
    // ...
  }
})

5. 持久化插件 ​

bash
npm install pinia-plugin-persistedstate@4
typescript
// 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')
typescript
// 在 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 中恢复上一个状态。

🐞 防坑指南 ​

坑说明正确做法
直接解构 storeconst { count } = store 丢失响应式const { count } = storeToRefs(store)
组件外使用 store在 .ts 文件中顶层调用 useStore()必须在 setup() 内或 Pinia 已安装后调用
store 当全局变量用什么数据都塞进 store优先将共享或需集中管理的业务状态放 store,输入草稿可留在组件
将直接修改 state 当成禁用行为Pinia 允许 store.count++ 和 $patch业务校验与多步修改集中在 action,便于复用与追踪

📐 最佳实践 ​

  1. store 命名:useXxxStore 配合 id: 'xxx',保持一致
  2. getter 代替组件 computed:能在 store 的 getter 做的计算不要在组件里重复
  3. action 职责:action 负责业务逻辑(校验 + 修改),组件只调用 action
  4. 持久化选择性:只持久化需要的 key(persist: { pick: ['todos'] }),不要全量序列化

Git 提交 ​

bash
git add .
git commit -m "L11: Pinia 全局状态管理 + 持久化"

🔗 钩子连接 ​

→ 下一节:L12 · 任务分类与标签系统 ​

L12 将在 Pinia Store 基础上为任务添加分类、优先级筛选和标签管理。