第7章 7.2 Pinia 状态管理:数据管理原来这么简单
「上一章我们把 uniapp 项目改造了 TypeScript,代码质量提升了一个档次。但问题来了——项目里到处都是 getApp().globalData 这种写法,数据流乱得像一团麻绳,你根本不知道谁改了它、什么时候改的。」
别慌,这一章教你用 Pinia 把数据管得明明白白。学会了它,你的数据就像放进了一个有管理员的保险箱——谁取谁存、怎么存取,全都有记录。
🎯 为什么要学 Pinia?
想象一个场景:你写了一个电商 App,购物车数据存在了一个全局变量里。突然产品经理说「加个会员价功能」,你发现:
- 购物车数据在 5 个页面里传来传去
- 你不知道哪个页面改了它、什么时候改的
- 测试说「有 bug,买 3 件显示只扣了 2 件的钱」但你根本找不到哪改的
这就是没有状态管理的痛苦。
Pinia 就是来解决这个问题的。它是 Vue 3 官方推荐的状态管理库(uniapp 支持),简单说就是:一个地方存数据,所有页面都能用,改了谁动过一目了然。\n\n
\n\n
\n\n
学完这章,你能:
- 用 Pinia 存取数据,像用字典一样简单
- 多个页面共享同一份数据,不再传来传去
- 追踪数据的每一次变化,出问题能快速定位
🧱 基础 25 分钟:核心概念
1. 什么是 Store?
生活类比:Store 就像 便利店仓库。仓库里有很多商品(数据),店员(页面)需要什么就去仓库拿,不用自己带个小仓库在身上。
在代码里,一个 Store 就是:
// stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0
}),
actions: {
increment() {
this.count++
}
}
})
解释:defineStore 创建了一个叫 counter 的便利店,state 是仓库里的商品(初始 count = 0),actions 是操作方法(增加数量)。
2. 三巨头:state、getters、actions
一个完整的 Store 有三个组成部分:
// stores/shopping.js
import { defineStore } from 'pinia'
export const useShoppingStore = defineStore('shopping', {
// ★ state:存放数据,就像便利店货架上的商品
state: () => ({
cart: [], // 购物车里的商品
userName: '小明', // 当前用户名
discount: 0.1 // 会员折扣 10%
}),
// ★ getters:计算属性,就像根据商品算总价
getters: {
// 购物车里有几件商品?
totalItems: (state) => state.cart.length,
// 打折后总价是多少?(原始总价 × 折扣)
finalPrice: (state) => {
const raw = state.cart.reduce((sum, item) => sum + item.price, 0)
return raw * (1 - state.discount)
}
},
// ★ actions:修改数据的方法,就像店员操作货架
actions: {
addToCart(product) {
this.cart.push(product)
},
clearCart() {
this.cart = []
},
setDiscount(value) {
this.discount = value
}
}
})
敲黑板:
- state = 放着不变的数据
- getters = 根据 state 算出来的值(带缓存,改 state 才重新算)
- actions = 改 state 的唯一入口
3. 在页面里用 Store
学会了创建,现在看看怎么用:
// pages/cart/cart.vue
<script setup>
import { useShoppingStore } from '@/stores/shopping'
// ★ 一行代码拿到 Store,就像扫码进入便利店
const store = useShoppingStore()
// ★ 读取 state 的两种方式
console.log(store.cart) // 直接访问(推荐)
console.log(store.$state.cart) // 官方写法(也可以)
// ★ 读取 getters
console.log(store.totalItems) // 自动计算后的值
console.log(store.finalPrice) // 打折后的价格
// ★ 调用 actions 改数据
store.addToCart({ name: 'iPhone', price: 6999 })
store.setDiscount(0.2) // 改成 8 折
</script>
4. 在 onLoad 里初始化数据
uniapp 页面加载时从服务器拿数据:
// pages/index/index.vue
<script setup>
import { onLoad } from '@dcloudio/uni-app'
import { useShoppingStore } from '@/stores/shopping'
const store = useShoppingStore()
onLoad(() => {
// 模拟:从服务器获取用户信息
uni.request({
url: 'https://api.example.com/user',
success: (res) => {
// ★ 改 state 的正确方式:通过 actions
store.setUserInfo(res.data)
}
})
})
// 也可以直接改(不推荐,但 Pinia 允许)
store.userName = '新名字' // ⚠️ 这样也能改,但调试时看不到谁改的
</script>
5. 持久化:刷新页面不丢数据
痛点:用户填了一堆表单,刷新小程序数据全没了。
解决方案:用 pinia-plugin-persistedstate 插件把数据存到本地:
// main.js
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
然后在 Store 里加一行配置:
export const useShoppingStore = defineStore('shopping', {
state: () => ({
cart: [],
userName: '小明'
}),
// ★ 就在这里加一句,自动帮你存到本地
persist: true
})
现在用户刷新页面,购物车数据还在!
6. 多 Store 拆分
生活类比:便利店太大了怎么办?分成 生鲜区、零食区、文具区,每个区有自己的管理员。
// stores/user.js - 用户相关
export const useUserStore = defineStore('user', {
state: () => ({
name: '',
vipLevel: 0
})
})
// stores/cart.js - 购物车相关
export const useCartStore = defineStore('cart', {
state: () => ({
items: []
})
})
// stores/order.js - 订单相关
export const useOrderStore = defineStore('order', {
state: () => ({
list: []
})
})
用的时候按需引入:
import { useUserStore } from '@/stores/user'
import { useCartStore } from '@/stores/cart'
const userStore = useUserStore()
const cartStore = useCartStore()
🔥 实战 35 分钟:3 个递进项目
项目 1:5 分钟 - 计数器(理解核心 API)
目标:点按钮数字 +1、-1,显示当前值。
// stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0
}),
actions: {
increment() {
this.count++
},
decrement() {
this.count--
},
reset() {
this.count = 0
}
}
})
<!-- pages/index/index.vue -->
<script setup>
import { useCounterStore } from '@/stores/counter'
const store = useCounterStore()
</script>
<template>
<view class="container">
<text class="count">{{ store.count }}</text>
<button @tap="store.increment">+1</button>
<button @tap="store.decrement">-1</button>
<button @tap="store.reset">重置</button>
</view>
</template>
<style>
.container { padding: 40px; align-items: center; }
.count { font-size: 60px; margin-bottom: 20px; }
</style>
预期输出:页面上显示 0,点 +1 变成 1,点 -1 变回 0。
项目 2:15 分钟 - 待办清单(真实场景)
需求:用户输入任务,点添加按钮存到 Store,页面刷新不丢数据。
// stores/todo.js
import { defineStore } from 'pinia'
export const useTodoStore = defineStore('todo', {
state: () => ({
todos: [],
nextId: 1
}),
getters: {
// 还有多少件没完成?
remaining: (state) => state.todos.filter(t => !t.done).length,
// 已完成的
completed: (state) => state.todos.filter(t => t.done)
},
actions: {
addTodo(text) {
this.todos.push({
id: this.nextId++,
text,
done: false,
createdAt: Date.now()
})
},
toggleTodo(id) {
const todo = this.todos.find(t => t.id === id)
if (todo) todo.done = !todo.done
},
deleteTodo(id) {
const index = this.todos.findIndex(t => t.id === id)
if (index > -1) this.todos.splice(index, 1)
}
},
// ★ 持久化:刷新不丢数据
persist: true
})
<!-- pages/todo/todo.vue -->
<script setup>
import { useTodoStore } from '@/stores/todo'
const store = useTodoStore()
let inputValue = ''
const addTodo = () => {
if (!inputValue.trim()) return
store.addTodo(inputValue)
inputValue = ''
}
</script>
<template>
<view class="container">
<!-- 输入区 -->
<view class="input-row">
<input v-model="inputValue" placeholder="输入任务..." @confirm="addTodo" />
<button size="mini" @tap="addTodo">添加</button>
</view>
<!-- 列表 -->
<view v-for="todo in store.todos" :key="todo.id" class="todo-item">
<checkbox :checked="todo.done" @tap="store.toggleTodo(todo.id)" />
<text :class="{ done: todo.done }">{{ todo.text }}</text>
<text class="delete" @tap="store.deleteTodo(todo.id)">删除</text>
</view>
<!-- 统计 -->
<text class="stats">还剩 {{ store.remaining }} 件未完成</text>
</view>
</template>
<style>
.input-row { display: flex; padding: 10px; }
.done { text-decoration: line-through; color: #999; }
.delete { color: red; margin-left: auto; }
.stats { padding: 10px; color: #666; }
</style>
预期输出:输入「买牛奶」点添加,列表显示「买牛奶」前面有复选框。点复选框打勾,字变灰划线。刷新页面数据还在。
项目 3:15 分钟 - 天气看板(组合实战)
需求:输入城市名,调用天气 API,把结果存到 Store 并展示。
// stores/weather.js
import { defineStore } from 'pinia'
export const useWeatherStore = defineStore('weather', {
state: () => ({
city: '',
data: null,
loading: false,
error: ''
}),
getters: {
// 温度显示
temperature: (state) => state.data ? `${state.data.temp}°C` : '--',
// 天气描述
description: (state) => state.data?.weather || '未知'
},
actions: {
async fetchWeather(city) {
if (!city) return
this.loading = true
this.error = ''
this.city = city
try {
// ★ 实际项目用真实 API,这里用模拟数据演示
const res = await new Promise(resolve => {
setTimeout(() => {
resolve({
temp: Math.floor(Math.random() * 30) + 5,
weather: ['晴', '多云', '小雨', '阴'][Math.floor(Math.random() * 4)],
humidity: Math.floor(Math.random() * 50) + 30
})
}, 500)
})
this.data = res
} catch (e) {
this.error = '获取天气失败'
} finally {
this.loading = false
}
}
},
persist: true
})
<!-- pages/weather/weather.vue -->
<script setup>
import { useWeatherStore } from '@/stores/weather'
const store = useWeatherStore()
let cityInput = ''
const search = () => {
store.fetchWeather(cityInput)
}
</script>
<template>
<view class="container">
<text class="title">天气看板</text>
<!-- 搜索区 -->
<view class="search-row">
<input v-model="cityInput" placeholder="输入城市名" @confirm="search" />
<button size="mini" @tap="search">查询</button>
</view>
<!-- 加载中 -->
<text v-if="store.loading">加载中...</text>
<!-- 错误 -->
<text v-else-if="store.error" class="error">{{ store.error }}</text>
<!-- 结果 -->
<view v-else-if="store.data" class="result">
<text class="city">{{ store.city }}</text>
<text class="temp">{{ store.temperature }}</text>
<text class="desc">{{ store.description }}</text>
<text class="humidity">湿度: {{ store.data.humidity }}%</text>
</view>
<!-- 空状态 -->
<text v-else class="placeholder">输入城市查询天气</text>
</view>
</template>
<style>
.container { padding: 20px; }
.search-row { display: flex; gap: 10px; margin-bottom: 20px; }
.result { background: #f0f8ff; padding: 20px; border-radius: 10px; }
.city { font-size: 20px; font-weight: bold; }
.temp { font-size: 48px; color: #1890ff; }
.desc { font-size: 16px; color: #666; }
.error { color: red; }
.placeholder { color: #999; }
</style>
预期输出:输入「北京」点查询,显示「北京 23°C 多云 湿度 45%」。刷新页面数据保留。
💪 进阶 20 分钟:常见坑 + 性能小贴士
❌ 坑 1:直接修改 state 而不是通过 actions
// ❌ 错误:直接改,调试时看不到谁改的
store.userName = '新名字'
// ✅ 正确:写个 action 来改
actions: {
setUserName(name) {
this.userName = name
}
}
store.setUserName('新名字')
原因:actions 会被 Pinia 记录,方便调试;直接改绕过了这个机制。
❌ 坑 2:在 gettters 里修改 state
// ❌ 错误:getters 应该是纯函数,只读不写
getters: {
doubleCount(state) {
state.count *= 2 // 不要这样!
return state.count
}
}
// ✅ 正确:只读取和计算
getters: {
doubleCount(state) {
return state.count * 2
}
}
❌ 坑 3:Store 里用箭头函数访问 this
// ❌ 错误:箭头函数没有 this
actions: {
increment: () => this.count++ // this 是 undefined
}
// ✅ 正确:用普通函数
actions: {
increment() {
this.count++
}
}
❌ 坑 4:忘了初始化 state 的类型
// ❌ 错误:没有定义类型,ts 会推断为 any
state: () => ({
count: 0,
name: ''
})
// ✅ 正确:用 TypeScript 定义清楚(承接上一章!)
interface UserState {
count: number
name: string
items: string[]
}
state: (): UserState => ({
count: 0,
name: '',
items: []
})
❌ 坑 5:在 onLoad 里直接用 store 而没有先引入
// ❌ 错误:store 可能还没初始化
onLoad(() => {
const store = useCounterStore() // 太早了!
store.increment()
})
// ✅ 正确:在 setup 里获取
const store = useCounterStore()
onLoad(() => {
store.increment() // 这时候已经初始化好了
})
💡 性能小贴士:按需引入 Store
// ❌ 大项目全引入会增加包体积
import { useUserStore } from '@/stores/user'
import { useCartStore } from '@/stores/cart'
import { useOrderStore } from '@/stores/order'
import { useProductStore } from '@/stores/product'
// ✅ 按页面引入,用到哪个引哪个
// cart.vue 只需要购物车
import { useCartStore } from '@/stores/cart'
🔧 调试技巧:查看所有 state 变化
// 在 main.js 里开启调试
const pinia = createPinia()
pinia.use(({ store }) => {
store.$subscribe((mutation, state) => {
console.log('数据变了!', mutation.type, state)
})
})
app.use(pinia)
现在每次 state 变化都会打印日志,方便你追踪数据流向。
✏️ 练习题
练习 1(2 分钟):计数器加个上限
- 输入:连续点
+1按钮 15 次 - 预期输出:数字最大只到 10
- 提示:在
incrementaction 里加个判断
练习 2(3 分钟):待办清单加个「清空已完成」
- 输入:已有 3 个任务,2 个已完成
- 预期输出:点击后只剩 1 个未完成的
- 提示:用
store.completedgetter 配合删除 action
练习 3(5 分钟):天气看板加个「收藏城市」
- 输入:查询北京天气后点收藏
- 预期输出:下次打开 App 不用重新查
- 提示:新增一个
favorites数组 state,用 persist 自动保存
练习 4(5 分钟):把项目 2 和 3 串起来
- 输入:天气为「下雨」时,自动往待办清单加一条「带伞」
- 预期输出:查询下雨天气后,待办清单自动多了一条「带伞」
- 提示:在
fetchWeatheraction 里加个判断,调用useTodoStore().addTodo()
练习 5(10 分钟):分析这个报错
- 输入:代码运行时报错
Cannot read properties of undefined (reading 'push') - 预期输出:找到问题并修复
- 提示:检查是不是在箭头函数的 action 里用了 this
📝 作业:做一个「学习进度追踪器」
需求:记录你每个章节的学习情况,数据不丢。
功能点:
1. 能添加章节(标题 + 学习时长 + 是否完成)
2. 列表展示所有章节,未完成的在前
3. 点完成按钮标记为已完成,显示完成时间
4. 持久化保存,刷新不丢数据
加分项:
- 统计总学习时长
- 导出学习记录为 JSON
验收标准:
- 能跑起来
- 添加 3 个章节后刷新,数据还在
- 点完成按钮后,显示完成时间
📚 总结 + 资源
本文学了 3 个核心点:
- Store 是存放共享数据的「便利店仓库」
- state 存数据、getters 算数据、actions 改数据
- 用 persist 可以让数据持久化,刷新不丢
延伸资源:
- Pinia 官方文档(中文版)
- uniapp + Pinia 实战视频(搜「uniapp pinia」)
互动钩子:你在做项目时有没有遇到「不知道数据被谁改了」的坑?评论区聊聊,老粉优先回复!
「学会了 Pinia 状态管理,你的 uniapp 项目数据流就清晰多了。但项目做大了,加载慢怎么办?下一章我们聊聊 性能优化:分包 + 懒加载 + 图片压缩,让你的 App 飞起来。」

评论(0)