第 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,仓库初始化时自动读回来。
两种落地方式:
- 手写 :
watch+localStorage(原理清晰,本节讲这个)。 - 官方插件
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-persistedstate(persist: true) - 什么时候用 Pinia:数据要跨页面/跨组件共享才进仓库,组件私有数据留在组件里
企业踩坑实录
- 组件里直接解构 state :
const { token } = userStore拿到的是普通值,丢响应式。state 和 getters 必须用storeToRefs(userStore)包一层再解构;actions 是普通函数,直接解构没有问题。 - 持久化时机写得到处都是 :东一处西一处手动 localStorage.setItem,漏一处就是 bug。统一方案是
$subscribe监听整个仓库的变化统一落盘(见本章 9.4)。 - 多个 store 互相引用报错 :在 action 内部
const other = useOtherStore()按需获取即可;不要在 store 模块顶层交叉调用------那时 pinia 可能尚未安装。 - 敏感信息无脑进 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 的简版方案是最好的回答加分项。