八、vue3:Pinia
文章目录
一、对 Pinia 的深入理解
让我们从一个真实的开发场景来理解为什么要使用 Pinia。假设你正在开发一个在线学习平台(类似慕课网或B站课堂),这个应用包含以下功能模块:
- 视频播放页:用户可以观看课程视频,记录播放进度
- 课程目录页:展示课程章节列表,显示每个章节的学习状态(未开始/学习中/已完成)
- 笔记面板:用户在观看视频时可以记录笔记,笔记需要关联到具体的时间点
- 底部迷你播放器:当用户从视频页切换到其他页面时,视频以小窗形式继续播放
- 个人中心:展示学习时长统计、最近学习记录、收藏的课程
在传统的组件开发模式中,每个页面都维护自己的数据。比如视频播放页管理着当前播放进度和笔记数据,课程目录页管理着章节列表,个人中心管理着学习统计。现在问题来了:
当用户在视频播放页看到第15分钟时,点击"记笔记"按钮写了一条笔记,然后切换到课程目录页——目录页需要知道用户已经学完了前3章,第4章显示"学习中"。接着用户点击个人中心,学习时长统计应该包含刚才那15分钟。最后用户点击返回,底部迷你播放器应该继续从第15分钟播放,而不是从头开始。
如果每个页面都维护自己的数据副本,你就需要在这四个地方同时更新"当前学习进度",在三个地方同步"笔记数据",在两个地方协调"播放状态"。数据同步的链条越长,出现不一致的概率就越高——用户明明看完了第三章,目录页显示已完成,但个人中心的学习进度却没有更新,这种体验是灾难性的。
这就是分布式状态管理的困境:状态像散落的珍珠一样分散在各个组件中,当多个不相关的模块需要同一份数据时,你不得不通过 props 层层透传,或者借助事件总线来回广播,代码很快变成一团乱麻。
Pinia 提供的是一种集中式状态管理方案。想象你有一个中央数据中心,所有的核心状态都统一存放在这里:当前播放的视频ID、播放进度、笔记列表、学习记录。任何模块需要数据时,都从这个数据中心读取;任何模块修改数据时,都向这个数据中心提交变更。所有订阅了这份数据的模块会自动收到通知并更新。就像银行的中央系统,无论你是在ATM机、手机App还是柜台办理业务,账户余额始终是一致的。
那么什么情况下需要使用 Pinia 呢?当状态需要跨越组件边界、被多个不相关的模块共享时,就应该考虑使用 Pinia。比如当前播放状态(视频页和迷你播放器共享)、用户登录信息(所有页面共享)、全局主题设置(所有组件共享)。但是,组件的纯UI状态不应该放进 Pinia。比如视频播放页里"控制栏是否显示"这个状态,只有视频组件自己关心,其他模块完全不关心,这种状态留在组件内部即可。判断标准是:这个状态是否被多个独立的模块需要?是否需要在页面切换后依然保持? 如果答案是肯定的,交给 Pinia;否则,留在组件里。
官方链接:
二、准备一个效果
在正式开始之前,我们先准备一个"学习进度计数器"组件作为演示基础。这个组件目前使用本地状态,后续我们将逐步迁移到 Pinia。
<!-- src/components/StudyProgress.vue -->
<template>
<div class="study-container">
<h2>学习进度追踪</h2>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: progress + '%' }"></div>
</div>
<p>当前学习进度: {{ progress }}%</p>
<p>已学习时长: {{ studyMinutes }} 分钟</p>
<div class="btn-group">
<button @click="study(10)">学习 10 分钟</button>
<button @click="study(30)">学习 30 分钟</button>
<button @click="reset">重置进度</button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const studyMinutes = ref(0)
const totalMinutes = 120 // 假设课程总时长120分钟
const progress = computed(() => {
return Math.min(Math.round((studyMinutes.value / totalMinutes) * 100), 100)
})
const study = (minutes) => {
studyMinutes.value += minutes
}
const reset = () => {
studyMinutes.value = 0
}
</script>
<style scoped>
.study-container {
padding: 24px;
border-radius: 12px;
background: #f8f9fa;
max-width: 480px;
margin: 0 auto;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.progress-bar {
width: 100%;
height: 12px;
background: #e9ecef;
border-radius: 6px;
overflow: hidden;
margin: 16px 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #4facfe 0%, #00f2fe 100%);
transition: width 0.3s ease;
}
p {
color: #495057;
margin: 8px 0;
}
.btn-group {
display: flex;
gap: 10px;
margin-top: 16px;
}
button {
flex: 1;
padding: 10px 16px;
border: none;
border-radius: 8px;
background: #339af0;
color: white;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}
button:hover {
background: #228be6;
}
button:last-child {
background: #868e96;
}
button:last-child:hover {
background: #495057;
}
</style>
这是一个学习进度追踪组件,有进度条、已学习时长显示,以及三个操作按钮。目前所有状态都是组件本地的。接下来我们将把这个状态迁移到 Pinia,并模拟"多个组件共享同一份学习数据"的场景。
三、搭建一个 Pinia 的环境
1. 安装 Pinia
npm install pinia
Pinia 是 Vue 官方推荐的状态管理库,与 Vue 3 深度集成。它非常轻量,压缩后仅约 1KB,不会给项目带来额外的打包负担。
2. 在 main.ts 中配置 Pinia
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.mount('#app')
配置只有三步,但每一步都有明确的职责:
createPinia()创建一个 Pinia 根实例,内部初始化了一个响应式的全局状态树app.use(pinia)将 Pinia 挂载到 Vue 应用上,通过 Vue 的 provide/inject 机制让所有子组件都能访问到 Pinia 实例- 挂载完成后,任何组件都可以通过
useXxxStore()获取 store 实例
四、读取和存储数据
1. 创建 store 文件夹和文件
在 src 目录下创建 store 文件夹,然后创建 study.ts:
src/
├── store/
│ ├── study.ts # 学习进度 store
│ └── index.ts # 统一导出(可选)
2. 编写第一个 store
// src/store/study.ts
import { defineStore } from 'pinia'
export const useStudyStore = defineStore('study', {
state: () => {
return {
studyMinutes: 0, // 已学习分钟数
totalMinutes: 120, // 课程总时长
lastStudyTime: null as string | null, // 上次学习时间
notes: [] as string[] // 学习笔记
}
}
})
关于 defineStore 的第一个参数 'study':
这个字符串是 store 的全局唯一标识符。Pinia 内部用一个 Map 缓存所有 store 实例,ID 就是 Map 的 key。如果两个 store 使用了相同的 ID,后定义的会静默覆盖先定义的,而且不会报错。 这是生产环境中很容易踩的坑。建议用常量文件统一管理:
// src/store/const.ts
export const STORE_ID = {
STUDY: 'study',
USER: 'user',
SETTINGS: 'settings'
} as const
// src/store/study.ts
import { STORE_ID } from './const'
defineStore(STORE_ID.STUDY, { ... })
为什么 state 必须是一个函数?
和 Vue 组件的 data 选项一样,函数返回对象可以确保每个实例都有独立的状态副本。虽然在客户端 Pinia 的 store 通常是单例,但在服务端渲染(SSR)场景下,每个请求都需要独立的状态,函数形式是必不可少的。
3. 在组件中使用 store
<!-- src/components/StudyProgress.vue -->
<template>
<div class="study-container">
<h2>学习进度追踪</h2>
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: (studyStore.studyMinutes / studyStore.totalMinutes * 100) + '%' }"
></div>
</div>
<p>已学习: {{ studyStore.studyMinutes }} / {{ studyStore.totalMinutes }} 分钟</p>
<p v-if="studyStore.lastStudyTime">上次学习: {{ studyStore.lastStudyTime }}</p>
</div>
</template>
<script setup>
import { useStudyStore } from '@/store/study'
const studyStore = useStudyStore()
console.log('store 实例:', studyStore)
</script>
useStudyStore() 的执行逻辑:
第一次调用时,Pinia 会创建 store 实例,将其状态挂载到全局状态树中,并缓存这个实例。后续无论在多少个组件中调用 useStudyStore(),返回的都是同一个实例。这是状态能够在多个组件间共享的根本原因。
在模板中访问 studyStore.studyMinutes 时,不需要加 .value。Pinia 在内部对 state 做了处理,将 ref 自动解包到 store 实例上,所以你可以像访问普通对象属性一样访问响应式数据。
五、修改数据的三种方法
1. 第一种方式:直接修改
<template>
<div class="study-container">
<p>已学习: {{ studyStore.studyMinutes }} 分钟</p>
<button @click="studyStore.studyMinutes += 10">直接学习 10 分钟</button>
</div>
</template>
<script setup>
import { useStudyStore } from '@/store/study'
const studyStore = useStudyStore()
</script>
直接修改是最直观的方式。在 Vuex 中这是被禁止的,但 Pinia 允许这样做,因为 Vue 3 的 Proxy 已经能够完美追踪所有变化,DevTools 同样可以捕获并记录这些修改。
直接修改的底层流程:
- 访问
studyStore.studyMinutes,触发 Proxy 的 get trap - 赋值操作触发 Proxy 的 set trap
- Pinia 记录 mutation:
{ type: 'direct', storeId: 'study', events: [...] } - Vue 响应式系统通知所有依赖该状态的组件更新
- Vue DevTools 时间线中新增一条状态变更记录
2. 第二种方式:批量修改($patch)
当需要同时修改多个字段时,用 $patch 可以将多次更新合并为一次:
// 先扩展 store,增加更多状态
state: () => ({
studyMinutes: 0,
totalMinutes: 120,
lastStudyTime: null as string | null,
notes: [] as string[],
currentChapter: 1,
completedChapters: [] as number[]
})
<script setup>
const batchStudy = () => {
// 直接修改会触发多次更新
studyStore.studyMinutes += 30
studyStore.currentChapter = 2
studyStore.completedChapters.push(1)
studyStore.lastStudyTime = new Date().toLocaleString()
// 用 $patch 合并为一次更新
studyStore.$patch({
studyMinutes: studyStore.studyMinutes + 30,
currentChapter: 2,
completedChapters: [...studyStore.completedChapters, 1],
lastStudyTime: new Date().toLocaleString()
})
}
</script>
$patch 的两种形式:
// 形式一:传入对象(适合确定性的批量赋值)
studyStore.$patch({
studyMinutes: 60,
currentChapter: 3
})
// 形式二:传入函数(适合基于当前状态的计算)
studyStore.$patch((state) => {
state.studyMinutes += 10 // 可以直接操作原数组
state.notes.push('新笔记') // 数组 push 也有效
if (state.studyMinutes > 60) {
state.currentChapter = 2
}
})
数组陷阱:对象形式的
$patch中,如果给数组赋同一个引用,Vue 会认为没有变化。要么用展开运算符创建新数组,要么直接用函数形式的$patch。
$patch 的优势:
- 性能更好:只触发一次组件重新渲染
- DevTools 更清晰:多条修改被记录为一次操作
- 事务性:要么全部成功,要么全部回滚(在调试时更容易定位问题)
3. 第三种方式:使用 actions
actions 将业务逻辑封装在 store 内部,组件只负责触发:
// src/store/study.ts
export const useStudyStore = defineStore('study', {
state: () => ({
studyMinutes: 0,
totalMinutes: 120,
lastStudyTime: null as string | null,
notes: [] as string[],
currentChapter: 1,
completedChapters: [] as number[]
}),
actions: {
// 记录学习时长
recordStudy(minutes: number) {
this.studyMinutes += minutes
this.lastStudyTime = new Date().toLocaleString()
// 自动判断章节完成
const chapterThreshold = 30 // 每30分钟算完成一章
const completed = Math.floor(this.studyMinutes / chapterThreshold)
for (let i = 1; i <= completed; i++) {
if (!this.completedChapters.includes(i)) {
this.completedChapters.push(i)
}
}
},
// 添加笔记
addNote(note: string) {
const timestamp = new Date().toLocaleTimeString()
this.notes.push(`[${timestamp}] ${note}`)
},
// 切换章节
switchChapter(chapter: number) {
this.currentChapter = chapter
// 可以调用其他 action
this.recordStudy(0) // 更新学习时间戳
},
// 异步 action:从后端恢复学习进度
async restoreProgress(userId: string) {
const res = await fetch(`/api/progress/${userId}`)
const data = await res.json()
this.$patch(data) // 用 $patch 批量恢复
},
// 重置
reset() {
this.$patch({
studyMinutes: 0,
lastStudyTime: null,
notes: [],
currentChapter: 1,
completedChapters: []
})
}
}
})
<template>
<div class="study-container">
<h2>学习进度追踪</h2>
<p>第 {{ studyStore.currentChapter }} 章 | 已学 {{ studyStore.studyMinutes }} 分钟</p>
<p>已完成章节: {{ studyStore.completedChapters.join(', ') || '无' }}</p>
<div class="btn-group">
<button @click="studyStore.recordStudy(10)">学习 10 分钟</button>
<button @click="studyStore.recordStudy(30)">学习 30 分钟</button>
<button @click="studyStore.switchChapter(2)">切换到第2章</button>
</div>
<div class="note-section">
<input v-model="newNote" placeholder="记录一条笔记..." />
<button @click="addNote">添加笔记</button>
<ul>
<li v-for="(note, i) in studyStore.notes" :key="i">{{ note }}</li>
</ul>
</div>
<button class="reset-btn" @click="studyStore.reset">重置所有进度</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useStudyStore } from '@/store/study'
const studyStore = useStudyStore()
const newNote = ref('')
const addNote = () => {
if (newNote.value.trim()) {
studyStore.addNote(newNote.value)
newNote.value = ''
}
}
</script>
actions 中的 this 指向:
在 Options Store 的 actions 中,this 指向 store 实例本身,可以访问所有 state、getters 和其他 actions。但要注意:不能用箭头函数定义 action,因为箭头函数没有自己的 this,会指向外层作用域:
actions: {
// 普通函数,this 正确指向 store
recordStudy(minutes: number) {
this.studyMinutes += minutes
},
// 箭头函数,this 不是 store!
badRecord: (minutes) => {
this.studyMinutes += minutes // TypeError!
}
}
六、storeToRefs
从 store 中解构状态时需要特别小心:
<script setup>
const studyStore = useStudyStore()
// 错误:直接解构会失去响应性
const { studyMinutes, currentChapter } = studyStore
// studyMinutes 变成了普通的 number,修改不会触发更新
// 正确:使用 storeToRefs 保持响应性
import { storeToRefs } from 'pinia'
const { studyMinutes, currentChapter, completedChapters } = storeToRefs(studyStore)
// 这些都是 Ref 对象,保持响应性
// 方法可以直接解构,不需要 storeToRefs
const { recordStudy, addNote, reset } = studyStore
</script>
为什么 storeToRefs 可以而普通解构不行?
storeToRefs 内部遍历 store 的所有属性,只对 isRef() 或 isReactive() 为 true 的属性调用 toRef() 进行转换,普通函数会被跳过。而 toRefs 会把所有属性(包括方法)都转成 ref,导致方法调用变成 increment.value(),而且丢失 this 绑定。
最佳实践:
const store = useStudyStore()
// 数据:用 storeToRefs 解构,模板中直接使用
const { studyMinutes, notes, currentChapter } = storeToRefs(store)
// 方法:直接解构或从 store 上调用
const { recordStudy, addNote } = store
// 模板中使用
// <p>{{ studyMinutes }}</p> // 自动解包
// <button @click="recordStudy(10)">学习</button>
七、getters
Getters 用于从 state 派生计算值,和 Vue 的 computed 一样具有缓存特性:
export const useStudyStore = defineStore('study', {
state: () => ({
studyMinutes: 0,
totalMinutes: 120,
completedChapters: [] as number[],
notes: [] as string[]
}),
getters: {
// 使用 state 参数(推荐,类型推导更友好)
progressPercent: (state) => {
return Math.min(Math.round((state.studyMinutes / state.totalMinutes) * 100), 100)
},
// 使用 this(可以访问其他 getters,需显式声明返回类型)
studyStatus(): string {
if (this.progressPercent === 0) return '未开始'
if (this.progressPercent === 100) return '已完成'
return `学习中 (${this.progressPercent}%)`
},
// 计算学习等级
studyLevel: (state) => {
if (state.studyMinutes < 30) return '初学者'
if (state.studyMinutes < 60) return '进阶者'
if (state.studyMinutes < 100) return '熟练者'
return '专家'
},
// 带参数的 getter(返回函数,无缓存)
getNoteByIndex: (state) => {
return (index: number) => state.notes[index]
},
// 笔记数量
noteCount: (state) => state.notes.length
}
})
<template>
<div>
<p>学习状态: {{ studyStore.studyStatus }}</p>
<p>学习等级: {{ studyStore.studyLevel }}</p>
<p>笔记数量: {{ studyStore.noteCount }}</p>
<p>第一条笔记: {{ studyStore.getNoteByIndex(0) || '暂无笔记' }}</p>
</div>
</template>
Getters 的缓存机制详解:
progressPercent只有在studyMinutes或totalMinutes变化时才会重新计算- 如果 10 个组件都使用了
progressPercent,它只计算一次,所有组件共享结果 getNoteByIndex返回的是函数,不会被缓存,每次调用都会重新执行
如果需要缓存带参数的 getter,在组件层用 computed:
// 组件中
const targetNote = computed(() => studyStore.getNoteByIndex(props.index))
// 这样只有 props.index 变化时才会重新获取
八、$subscribe
$subscribe 用于监听 store 状态变化,适合执行副作用:
<script setup>
import { useStudyStore } from '@/store/study'
import { onMounted, onUnmounted } from 'vue'
const studyStore = useStudyStore()
let unsubscribe = () => {}
onMounted(() => {
// 订阅学习进度变化,自动保存到本地存储
unsubscribe = studyStore.$subscribe((mutation, state) => {
console.log('变化类型:', mutation.type) // 'direct' | 'patch object' | 'patch function'
console.log('store ID:', mutation.storeId) // 'study'
console.log('新状态:', state)
// 持久化到 localStorage
localStorage.setItem('study-progress', JSON.stringify({
studyMinutes: state.studyMinutes,
currentChapter: state.currentChapter,
completedChapters: state.completedChapters,
lastStudyTime: state.lastStudyTime
}))
})
})
onUnmounted(() => {
unsubscribe() // 组件卸载时取消订阅,防止内存泄漏
})
</script>
让订阅在组件卸载后依然生效(全局持久化):
// 在 App.vue 或 main.ts 中
studyStore.$subscribe((mutation, state) => {
localStorage.setItem('study-progress', JSON.stringify(state))
}, { detached: true }) // detached: true 表示脱离组件生命周期
初始化时从本地存储恢复:
// store/study.ts 的 action 中
actions: {
restoreFromStorage() {
const saved = localStorage.getItem('study-progress')
if (saved) {
this.$patch(JSON.parse(saved))
}
}
}
// main.ts 中
const studyStore = useStudyStore()
studyStore.restoreFromStorage()
九、store 组合式写法
Setup Store 是 Pinia 推荐的写法,与 Vue 3 的 <script setup> 风格完全一致:
// src/store/study-composition.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useStudyStore = defineStore('study', () => {
// ===== State =====
const studyMinutes = ref(0)
const totalMinutes = ref(120)
const lastStudyTime = ref<string | null>(null)
const notes = ref<string[]>([])
const currentChapter = ref(1)
const completedChapters = ref<number[]>([])
// ===== Getters =====
const progressPercent = computed(() =>
Math.min(Math.round((studyMinutes.value / totalMinutes.value) * 100), 100)
)
const studyStatus = computed(() => {
if (progressPercent.value === 0) return '未开始'
if (progressPercent.value === 100) return '已完成'
return `学习中 (${progressPercent.value}%)`
})
const studyLevel = computed(() => {
if (studyMinutes.value < 30) return '初学者'
if (studyMinutes.value < 60) return '进阶者'
if (studyMinutes.value < 100) return '熟练者'
return '专家'
})
// ===== Actions =====
function recordStudy(minutes: number) {
studyMinutes.value += minutes
lastStudyTime.value = new Date().toLocaleString()
const threshold = 30
const completed = Math.floor(studyMinutes.value / threshold)
for (let i = 1; i <= completed; i++) {
if (!completedChapters.value.includes(i)) {
completedChapters.value.push(i)
}
}
}
function addNote(note: string) {
const time = new Date().toLocaleTimeString()
notes.value.push(`[${time}] ${note}`)
}
function switchChapter(chapter: number) {
currentChapter.value = chapter
}
async function restoreProgress(userId: string) {
const res = await fetch(`/api/progress/${userId}`)
const data = await res.json()
studyMinutes.value = data.studyMinutes
currentChapter.value = data.currentChapter
completedChapters.value = data.completedChapters
}
function reset() {
studyMinutes.value = 0
lastStudyTime.value = null
notes.value = []
currentChapter.value = 1
completedChapters.value = []
}
// 必须 return 所有需要暴露的内容
return {
studyMinutes, totalMinutes, lastStudyTime, notes, currentChapter, completedChapters,
progressPercent, studyStatus, studyLevel,
recordStudy, addNote, switchChapter, restoreProgress, reset
}
})
组合式写法的优势:
- 没有
this烦恼:不存在 Options Store 中this指向的问题 - TypeScript 体验更好:类型推导自然流畅,不需要显式声明 getter 返回类型
- 逻辑复用:可以将通用逻辑抽离到 composable 中,在多个 store 间复用
- 与组件写法一致:如果团队使用
<script setup>,store 的写法也完全一致,降低心智负担
十、总结
通过"在线学习平台"这个场景,我们从零开始搭建了一个完整的 Pinia 状态管理流程。回顾整个过程:
| 环节 | 核心要点 |
|---|---|
| 场景判断 | 跨组件共享的状态用 Pinia,纯 UI 状态留在组件内 |
| Store 定义 | ID 全局唯一,state 用函数返回,优先使用 Setup Store |
| 读取状态 | 模板中直接访问,解构用 storeToRefs |
| 修改状态 | 简单直接改,批量用 $patch,复杂逻辑用 actions |
| 派生数据 | getters 自动缓存,传参 getter 在组件层用 computed 缓存 |
| 副作用 | $subscribe 监听变化,注意取消订阅防止内存泄漏 |
| 持久化 | 结合 $subscribe + localStorage,在应用入口统一恢复 |
Pinia 的设计哲学是让状态管理(共有数据)回归简单。没有繁琐的 mutation,没有嵌套的模块命名空间,只有直观的 state、getters 和 actions。配合 Vue 3 的 Composition API,它让代码更加扁平、类型更加安全、逻辑更加清晰。
更多推荐

所有评论(0)