第7章 7.2 Pinia 状态管理:数据管理原来这么简单

「上一章我们把 uniapp 项目改造了 TypeScript,代码质量提升了一个档次。但问题来了——项目里到处都是 getApp().globalData 这种写法,数据流乱得像一团麻绳,你根本不知道谁改了它、什么时候改的。」

别慌,这一章教你用 Pinia 把数据管得明明白白。学会了它,你的数据就像放进了一个有管理员的保险箱——谁取谁存、怎么存取,全都有记录。


🎯 为什么要学 Pinia?

想象一个场景:你写了一个电商 App,购物车数据存在了一个全局变量里。突然产品经理说「加个会员价功能」,你发现:

  • 购物车数据在 5 个页面里传来传去
  • 你不知道哪个页面改了它、什么时候改的
  • 测试说「有 bug,买 3 件显示只扣了 2 件的钱」但你根本找不到哪改的

这就是没有状态管理的痛苦。

Pinia 就是来解决这个问题的。它是 Vue 3 官方推荐的状态管理库(uniapp 支持),简单说就是:一个地方存数据,所有页面都能用,改了谁动过一目了然。\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\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
  • 提示:在 increment action 里加个判断

练习 2(3 分钟):待办清单加个「清空已完成」

  • 输入:已有 3 个任务,2 个已完成
  • 预期输出:点击后只剩 1 个未完成的
  • 提示:用 store.completed getter 配合删除 action

练习 3(5 分钟):天气看板加个「收藏城市」

  • 输入:查询北京天气后点收藏
  • 预期输出:下次打开 App 不用重新查
  • 提示:新增一个 favorites 数组 state,用 persist 自动保存

练习 4(5 分钟):把项目 2 和 3 串起来

  • 输入:天气为「下雨」时,自动往待办清单加一条「带伞」
  • 预期输出:查询下雨天气后,待办清单自动多了一条「带伞」
  • 提示:在 fetchWeather action 里加个判断,调用 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 飞起来。」

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。