第 9 章 Pinia 状态管理

第 9 章 Pinia 状态管理

本章导读

  • 你将学会:defineStore 定义仓库、state / getters / actions 三件套、storeToRefs 正确解构、模块拆分与持久化思路------全局状态的完整方案
  • 前置要求:第 8 章
  • 预计用时:1 天。storeToRefs 的坑与第 3 章一脉相承,注意串联

9.1 定义 store:state / getters / actions

【知识点讲解】

组件自己 ref 的数据只属于自己。多个页面都要用、还要改的数据(登录用户信息、购物车、全局设置)需要一个"仓库"集中管理------这就是 Pinia。

一个 store 三层结构,对应组件里的三个概念但作用域是全局:

Pinia 类比组件 职责
state ref 数据 存数据
getters computed 派生计算
actions methods 改数据/异步逻辑

定义用 defineStore('仓库名', {...}),仓库名全局唯一。使用时在组件里调用 useUserStore() 拿到仓库实例。注意 :调用要写在 <script setup> 顶层或函数体内(setup 生命周期内),不要在模块顶层直接调用。

【示例代码】

javascript 复制代码
// 文件:src/store/counter.js(第一个仓库:计数器)
import { defineStore } from 'pinia'
​
export const useCounterStore = defineStore('counter', {
  // 1. state:存数据(写成函数返回对象,固定写法)
  state: () => ({
    count: 0,
    history: []
  }),
​
  // 2. getters:计算属性(第一个参数拿到 state,可互相引用)
  getters: {
    double: (state) => state.count * 2,
    // 引用其他 getter:用 this(普通函数写法)
    doublePlusOne() {
      return this.double + 1
    }
  },
​
  // 3. actions:方法(同步异步都放这里,用 this 改 state)
  actions: {
    increment() {
      this.count++
      this.history.push(new Date().toLocaleTimeString())
    },
    async incrementAsync() {
      // 异步逻辑写在 action 里,组件只管调用
      await new Promise((resolve) => setTimeout(resolve, 1000))
      this.increment()
    },
    reset() {
      this.count = 0
      this.history = []
    }
  }
})
xml 复制代码
​
<script setup>
import { useCounterStore } from '@/store/counter'
​
const counter = useCounterStore()
</script>
​
<template>
  <div class="page">
    <h2>Pinia 计数器</h2>
    <p>count:{{ counter.count }} / double:{{ counter.double }} / doublePlusOne:{{ counter.doublePlusOne }}</p>
    <button @click="counter.increment()">+1</button>
    <button @click="counter.incrementAsync()">异步 +1(1 秒后)</button>
    <button @click="counter.reset()">重置</button>
​
    <h3>操作历史</h3>
    <ul>
      <li v-for="(h, i) in counter.history" :key="i">{{ h }}</li>
    </ul>
  </div>
</template>
css 复制代码
// 文件:src/router/index.js(routes 追加)
{ path: '/pinia', name: 'pinia', component: () => import('@/views/PiniaView.vue') }

验证全局性:把 <p>{{ counter.count }}</p> 复制到任意其他页面(如首页),一边加另一个页面也同步变------这就是"仓库"的意义。

【练习Demo】

需求 :创建 useShopStore:库存 stock(初始 100)、销量 sales(初始 0),getter stockRate(剩余库存百分比),action sell(n)(库存够才卖,销量+n)、restock(n)(补货)。页面展示并操作。

参考实现:

javascript 复制代码
// 文件:src/store/shop.js
import { defineStore } from 'pinia'
​
export const useShopStore = defineStore('shop', {
  state: () => ({
    stock: 100,
    sales: 0
  }),
  getters: {
    stockRate: (state) => Math.round((state.stock / 100) * 100) + '%'
  },
  actions: {
    sell(n) {
      if (this.stock >= n) {
        this.stock -= n
        this.sales += n
        return true
      }
      alert('库存不足')
      return false
    },
    restock(n) {
      this.stock += n
    }
  }
})
xml 复制代码
​
<script setup>
import { useShopStore } from '@/store/shop'
​
const shop = useShopStore()
</script>
​
<template>
  <div class="page">
    <h2>商品库存</h2>
    <p>库存:{{ shop.stock }}(剩余 {{ shop.stockRate }})/ 销量:{{ shop.sales }}</p>
    <button @click="shop.sell(1)">卖 1 件</button>
    <button @click="shop.sell(10)">卖 10 件</button>
    <button @click="shop.restock(50)">补货 50</button>
  </div>
</template>

9.2 组件中使用 store:storeToRefs 解构

【知识点讲解】

组件里用 store 有个高频坑:直接解构 state/getters 会丢响应(和 reactive 一样的原理):

arduino 复制代码
const { count } = counter  // ❌ 普通变量,变了页面不动

正确姿势:用 Pinia 提供的 storeToRefs

scss 复制代码
const { count, double } = storeToRefs(counter) // ✅ 保持响应

分工记忆:数据(state/getters)解构要 storeToRefs;方法(actions)直接解构(函数本来就不需要响应)。

另外也可以 $patch 批量改、$reset 一键重置------这些是 store 实例自带的。

【示例代码】

xml 复制代码
​
<script setup>
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/store/counter'
​
const counter = useCounterStore()
​
// ✅ 数据用 storeToRefs 解构,保持响应式
const { count, double, history } = storeToRefs(counter)
​
// ✅ 方法直接解构(函数不需要响应式)
const { increment, reset } = counter
​
// $patch:批量修改(比多次赋值触发更少的更新)
function patchAll() {
  counter.$patch({ count: 100 })
}
​
// $reset:一键恢复初始 state(仅选项式 store 可用)
function resetAll() {
  counter.$reset()
}
</script>
​
<template>
  <div class="page">
    <h2>storeToRefs 解构</h2>
    <p>count:{{ count }} / double:{{ double }}</p>
    <button @click="increment">+1</button>
    <button @click="patchAll">改成 100</button>
    <button @click="resetAll">重置仓库</button>
  </div>
</template>

【练习Demo】

需求 :把 9.1 练习的 ShopView 改造成"解构写法",模板中不得出现 shop. 前缀。

参考实现:

xml 复制代码
<script setup>
import { storeToRefs } from 'pinia'
import { useShopStore } from '@/store/shop'

const shop = useShopStore()

// 数据:storeToRefs
const { stock, sales, stockRate } = storeToRefs(shop)
// 方法:直接解构
const { sell, restock } = shop
</script>

<template>
  <div class="page">
    <h2>商品库存</h2>
    <p>库存:{{ stock }}(剩余 {{ stockRate }})/ 销量:{{ sales }}</p>
    <button @click="sell(1)">卖 1 件</button>
    <button @click="sell(10)">卖 10 件</button>
    <button @click="restock(50)">补货 50</button>
  </div>
</template>

9.3 模块拆分

【知识点讲解】

真实项目从不把所有数据塞一个 store,而是按业务域拆文件store/user.js(用户)、store/cart.js(购物车)、store/app.js(全局设置,主题/侧边栏折叠)。每个文件一个 defineStore,各自独立、互不干扰------Pinia 天然模块化,没有 Vuex 时代的 modules 嵌套配置

文件即模块;组件里要用哪个就 import 哪个。store 之间也可以互相引用(在一个 store 的 action 里调用另一个 store)。

【示例代码】

javascript 复制代码
// 文件:src/store/user.js(用户仓库)
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    token: '',
    userInfo: null // { id, nickname, avatar }
  }),
  getters: {
    isLogin: (state) => !!state.token
  },
  actions: {
    login(username, password) {
      // 模拟请求:真实项目这里调接口
      return new Promise((resolve) => {
        setTimeout(() => {
          this.token = 'mock-token-' + Date.now()
          this.userInfo = { id: 1, nickname: username, avatar: '' }
          resolve(true)
        }, 500)
      })
    },
    logout() {
      this.token = ''
      this.userInfo = null
    }
  }
})
javascript 复制代码
// 文件:src/store/app.js(全局 UI 设置仓库)
import { defineStore } from 'pinia'

export const useAppStore = defineStore('app', {
  state: () => ({
    sidebarCollapsed: false,
    theme: 'light'
  }),
  actions: {
    toggleSidebar() {
      this.sidebarCollapsed = !this.sidebarCollapsed
    },
    setTheme(theme) {
      this.theme = theme
    }
  }
})
xml 复制代码
<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/store/user'
import { useAppStore } from '@/store/app'

const userStore = useUserStore()
const appStore = useAppStore()

const { isLogin, userInfo } = storeToRefs(userStore)
const { sidebarCollapsed, theme } = storeToRefs(appStore)
const { login, logout } = userStore
const { toggleSidebar, setTheme } = appStore
</script>

<template>
  <div class="page" :style="{ background: theme === 'dark' ? '#001529' : '#fff', color: theme === 'dark' ? '#fff' : '#333' }">
    <h2>多仓库模块</h2>

    
    <button @click="toggleSidebar">{{ sidebarCollapsed ? '展开' : '收起' }}侧边栏</button>
    <button @click="setTheme(theme === 'light' ? 'dark' : 'light')">切换主题</button>

    
    <template v-if="isLogin">
      <p>欢迎:{{ userInfo?.nickname }}</p>
      <button @click="logout">退出登录</button>
    </template>
    <button v-else @click="login('小明', '123456')">模拟登录</button>
  </div>
</template>

【练习Demo】

需求 :新建 store/cart.js 购物车仓库:items(数组,每项 {id, name, price, count})、getter totalAmount(总金额)与 totalCount(总件数)、action addItem(已存在则 count+1,否则新增)、removeItem(id)clear()。页面完成加购、删除、清空。

参考实现:

javascript 复制代码
// 文件:src/store/cart.js
import { defineStore } from 'pinia'

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: []
  }),
  getters: {
    totalAmount: (state) => state.items.reduce((sum, i) => sum + i.price * i.count, 0),
    totalCount: (state) => state.items.reduce((sum, i) => sum + i.count, 0)
  },
  actions: {
    addItem(goods) {
      const found = this.items.find((i) => i.id === goods.id)
      if (found) {
        found.count++
      } else {
        this.items.push({ ...goods, count: 1 })
      }
    },
    removeItem(id) {
      this.items = this.items.filter((i) => i.id !== id)
    },
    clear() {
      this.items = []
    }
  }
})
xml 复制代码
<script setup>
import { storeToRefs } from 'pinia'
import { useCartStore } from '@/store/cart'

const cart = useCartStore()
const { items, totalAmount, totalCount } = storeToRefs(cart)
const { addItem, removeItem, clear } = cart

const goodsList = [
  { id: 1, name: '键盘', price: 299 },
  { id: 2, name: '鼠标', price: 199 }
]
</script>

<template>
  <div class="page">
    <h2>购物车(Pinia)</h2>

    <h3>商品</h3>
    <button v-for="g in goodsList" :key="g.id" @click="addItem(g)">
      {{ g.name }} ¥{{ g.price }} +
    </button>

    <h3>购物车({{ totalCount }} 件,共 ¥{{ totalAmount }})</h3>
    <ul>
      <li v-for="i in items" :key="i.id">
        {{ i.name }} × {{ i.count }} = ¥{{ i.price * i.count }}
        <button @click="removeItem(i.id)">删除</button>
      </li>
    </ul>
    <button v-if="items.length" @click="clear">清空购物车</button>
  </div>
</template>

9.4 持久化思路

【知识点讲解】

Pinia 数据在内存里,刷新页面就没了 ------登录状态、购物车这种数据,用户一按 F5 就丢,体验很差。持久化 = state 变化时自动存 localStorage,仓库初始化时自动读回来

两种落地方式:

  1. 手写watch + localStorage(原理清晰,本节讲这个)。
  2. 官方插件 pinia-plugin-persistedstate:一行配置搞定(企业常用,知其所以然后再用)。

手写三步:初始化 state 时从 localStorage 读 → watch(deep) 监听变化写入 → 提供统一工具函数复用。

【示例代码】

javascript 复制代码
// 文件:src/utils/storage.js(本地存储工具:JSON 读写封一层,容错)
export function getItem(key, defaultValue = null) {
  try {
    const raw = localStorage.getItem(key)
    return raw !== null ? JSON.parse(raw) : defaultValue
  } catch {
    return defaultValue
  }
}

export function setItem(key, value) {
  localStorage.setItem(key, JSON.stringify(value))
}

export function removeItem(key) {
  localStorage.removeItem(key)
}
javascript 复制代码
// 文件:src/store/cart.js(改造:持久化)
import { defineStore } from 'pinia'
import { getItem, setItem } from '@/utils/storage.js'

const CART_KEY = 'vue3-learn-demo-cart'

export const useCartStore = defineStore('cart', {
  // 初始化时从 localStorage 恢复(持久化第一步)
  state: () => ({
    items: getItem(CART_KEY, [])
  }),
  getters: {
    totalAmount: (state) => state.items.reduce((sum, i) => sum + i.price * i.count, 0),
    totalCount: (state) => state.items.reduce((sum, i) => sum + i.count, 0)
  },
  actions: {
    addItem(goods) {
      const found = this.items.find((i) => i.id === goods.id)
      if (found) {
        found.count++
      } else {
        this.items.push({ ...goods, count: 1 })
      }
      // 持久化第二步:每次变更后保存
      setItem(CART_KEY, this.items)
    },
    removeItem(id) {
      this.items = this.items.filter((i) => i.id !== id)
      setItem(CART_KEY, this.items)
    },
    clear() {
      this.items = []
      setItem(CART_KEY, this.items)
    }
  }
})

插件方案(了解即可,原理就是上面手写内容的自动化):

复制代码
npm install pinia-plugin-persistedstate
javascript 复制代码
// 文件:src/main.js
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 pinia = createPinia()
pinia.use(piniaPluginPersistedstate) // 装插件

const app = createApp(App)
app.use(pinia)
app.use(router)
app.mount('#app')
arduino 复制代码
// store 定义里加一行即可自动持久化整个 state
// persist: true

【练习Demo】

需求 :给 store/app.js(主题、侧边栏状态)加上持久化:切换深色模式后刷新页面,主题保持深色。

参考实现:

javascript 复制代码
// 文件:src/store/app.js(改造)
import { defineStore } from 'pinia'
import { getItem, setItem } from '@/utils/storage.js'

const APP_KEY = 'vue3-learn-demo-app'

export const useAppStore = defineStore('app', {
  // 启动时恢复上次的状态
  state: () => getItem(APP_KEY, {
    sidebarCollapsed: false,
    theme: 'light'
  }),
  actions: {
    save() {
      setItem(APP_KEY, { sidebarCollapsed: this.sidebarCollapsed, theme: this.theme })
    },
    toggleSidebar() {
      this.sidebarCollapsed = !this.sidebarCollapsed
      this.save()
    },
    setTheme(theme) {
      this.theme = theme
      this.save()
    }
  }
})

验证:进入 StoreModuleView 页面切到深色主题 → F5 刷新 → 仍是深色。

第 9 章小结

  • Pinia 三层:state 存数据(函数返回对象)、getters 计算(可互相引用)、actions 改数据 + 异步逻辑
  • 组件取仓库:const store = useXxxStore()state/getters 解构必须 storeToRefs,actions 直接解构
  • 模块拆分 = 按业务域一文件一 store,天然模块化,无需嵌套配置
  • 持久化:初始化读 localStorage + 变更后写回;企业里直接用 pinia-plugin-persistedstatepersist: true
  • 什么时候用 Pinia:数据要跨页面/跨组件共享才进仓库,组件私有数据留在组件里


企业踩坑实录

  1. 组件里直接解构 stateconst { token } = userStore 拿到的是普通值,丢响应式。state 和 getters 必须用 storeToRefs(userStore) 包一层再解构;actions 是普通函数,直接解构没有问题。
  2. 持久化时机写得到处都是 :东一处西一处手动 localStorage.setItem,漏一处就是 bug。统一方案是 $subscribe 监听整个仓库的变化统一落盘(见本章 9.4)。
  3. 多个 store 互相引用报错 :在 action 内部 const other = useOtherStore() 按需获取即可;不要在 store 模块顶层交叉调用------那时 pinia 可能尚未安装。
  4. 敏感信息无脑进 localStorage:token 的存储策略(过期时间、刷新机制、清理时机)必须与后端约定好,不要把整个用户对象原样塞进去。

思考题(先自己答,再对照参考答案)

Q1:Pinia 相比 Vuex 好在哪?

参考答案:去掉了 mutations(同步异步都写在 actions);天然模块化(一个 defineStore 就是一个模块,无需嵌套注册);完整的 TypeScript 支持;API 数量更少、心智负担更小。

Q2:什么数据该进 store?

参考答案:判断标准只有一条------是否跨页面或跨组件共享。登录态、主题、全局字典进 store;表单临时输入、弹窗开关留在组件本地。store 不是垃圾场,放得越多越难维护。

Q3:getters 和组件里的 computed 有什么不同?

参考答案:功能等价,但 getters 定义在仓库层、所有组件共享,避免每个组件重复实现同一份派生逻辑(比如"未完成的待办数")。

Q4:如何把 localStorage 数据源换成真实后端接口?

参考答案:只改 actions------原来读写 localStorage 的地方换成 await 请求,组件侧的调用方式完全不变。这正是分层的价值:数据来源切换不波及视图层。

面试高频

问:Pinia 的持久化怎么实现?

答:官方插件 pinia-plugin-persistedstate 一行接入;原理是 $subscribe 订阅 state 变化、序列化写入 storage,并在 store 初始化时读回合并。能手写 9.4 的简版方案是最好的回答加分项。

相关推荐
雪芽蓝域zzs1 小时前
第 13 章:对接后端真实接口(Axios 请求,替换 Mock 数据,完整业务闭环)
vue.js·echars
Aaswk2 小时前
从 HTML/CSS/JS 到 Vue + Axios 的一篇实战笔记
前端·css·vue.js·html
+VX:Fegn08952 小时前
计算机毕业设计|基于springboot + vue图书借阅管理系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计
星光开发者2 小时前
基于Spring Boot的充电桩管理系统的设计与实现-计算机毕设【课程设计】57105
vue.js·spring boot·vscode·mysql·django·php·express
ℋᙚᵐⁱᒻᵉ鲸落3 小时前
ETypeError: nameRef.value?.focus is not a functio. 设置el-input自动聚焦时报错
前端·javascript·vue.js·elementui
+VX:Fegn089512 小时前
计算机毕业设计|基于springboot + vue蛋糕店管理系统(源码+数据库+文档)
前端·数据库·vue.js·spring boot·课程设计
老男孩游戏时光21 小时前
Vue3 Composition API 最佳实践,别再乱写 setup 了
vue.js
+VX:Fegn089521 小时前
计算机毕业设计|基于java+ vue共享单车信息系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计
Bs_MoneyMagnet21 小时前
基于springboot+vue的非遗物质文化遗产系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring·毕业设计·计算机毕业设计