Pinia状态管理
Vue框架本身就具备状态管理的能力。在我们开发Vue应用时,驱动视图渲染的数据正是通过状态来管理的。本章将重点讨论基于Vue的状态管理框架Pinia。Pinia是一个专为Vue设计的状态管理库,它集中存储并管理应用的所有组件状态,确保状态数据能够按照预期的方式进行变化。
然而,并非所有Vue应用的开发都需要使用Pinia来进行状态管理。对于规模较小、结构简单的Vue应用,使用Vue自身的状态管理功能通常已足够。但是,对于复杂度高、组件众多的Vue应用,组件间的交互可能会使得状态管理变得更加复杂,这时Pinia的帮助就显得尤为重要。
1、了解Pinia框架的精髓
Pinia采用了集中式管理方法,负责维护和控制所有组件的状态。这与Vue本身的"独立式"状态管理方式形成了鲜明对比,后者允许每个组件独立管理自己的状态。在Pinia出现之前,Vuex是Vue官方提供的状态管理库。然而,随着Vue 3.x的推出和组合式API的普及,Vuex的使用逐渐显得不那么方便。因此,Pinia应运而生。Pinia最初就是为了与组合式API配合使用而设计的Vue状态管理工具。与Vuex相比,Pinia对使用Vue 3.x的用户更为友好,同时它也兼容非组合式API,支持Vue 2.x版本。
在深入介绍Pinia之前,我们需要先理解状态管理的必要性。Vue作为一个响应式前端开发框架,页面上的元素内容可以通过数据驱动的方式进行更新。在Vue组件内实现元素的响应性,只需将要绑定到元素中的数据定义为响应式。一旦数据发生变化,模板内的相应元素就会自动刷新。
尽管Vue内置的状态管理在组件级别非常高效,但在跨组件状态同步方面却存在一些局限性。首先,Vue中的数据流是单向的,即从父组件传向子组件的参数是只读的,子组件无法直接修改这些参数并反馈给父组件。我们通常需要通过回调函数或全局状态管理来实现状态共享。此外,同级组件之间的状态共享也相对困难,如果多个组件都尝试修改同一状态,可能会导致难以追踪的异常。
1.1、理解状态管理
我们先从一个简单的示例组件来理解状态管理。使用Vite工具新建一个名为pinia-demo的项目工程。为了方便测试,我们将默认生成的部分代码先清理掉,修改App.vue文件如下:
html
<script setup>
import HelloWorld from './components/HelloWorld.vue'
</script>
<template>
<HelloWorld />
</template>
修改HelloWorld.vue文件如下:
html
<script setup>
import {ref} from 'vue'
//简单的计数器功能
const count = ref(0)
function increment() {
count.value ++
}
</script>
<template>
<h1>计数器1:{{ count }}</h1>
<button @click="increment">增加</button>
</template>

上述示例代码展示了一个简单的页面,页面上呈现了一个按钮组件和一个显示计数的文本标题。用户每次单击按钮时,标题中的计数便会递增。深入分析这段代码,我们可以认识到在Vue应用中,组件状态管理包含以下几个关键组成部分。
- 状态数据
状态数据指的是在setup函数中定义的数据,这些数据通过ref方法被封装成响应式,从而能够驱动视图的更新。 - 视图
视图是指template部分所定义的模板内容,它通过声明式语法把状态映射到界面元素上。 - 动作
动作指那些会引发状态变化的行为,即代码中setup函数内定义的方法,这些方法用于修改状态数据,状态数据的变更最终促使视图重新渲染。
这三部分的紧密协作构成了Vue状态管理的核心机制。总体而言,在此状态管理模式中,数据流动是单向且封闭的:视图触发动作,动作改变状态,状态刷新视图。这个过程如图所示。

单向数据流的状态管理模式以其简洁性著称,对于组件数量较少的简单Vue应用而言,这种模式的效率非常高。然而,在涉及多组件且交互复杂的场景中,采用该方式进行状态管理便显得尤为棘手。让我们深入思考以下两个问题:
- 问题1:多个组件如何依赖同一状态?
- 问题2:多个组件如何共同触发一个状态的变更?
针对问题1,如果使用前面提到的状态管理方法,其实现将颇为困难。对于嵌套关系中的多个组件,我们或许还能通过逐层传递值的方式来共享状态,但面对横向同级的多个组件时,共享同一状态则变得异常艰难。
对于问题2,若不同的组件需要更改为同一状态,最直截了当的做法是将触发权交给上层组件,对于深层嵌套的组件结构而言,这就意味着需要层层向上传递事件,直至顶层统一处理状态更新,这种做法无疑会大幅提升代码维护的难度。
Pinia正是为解决这类挑战而生的。在Pinia框架下,我们可以将需要跨组件共享的状态提取出来,以全局单例模式进行集中管理。这种模式使得无论视图位于视图树的哪个层级,都能直接访问这些共享状态,同时也能便捷地触发修改操作来动态更新这些共享状态。
1.2、安装与体验Pinia
与前面我们使用过的模块的安装方式类似,使用npm可以非常方便地为工程安装Pinia模块,命令如下:
shell
npm install pinia --save
在安装过程中,如果有权限相关的错误产生,可以在命令前添加sudo。安装完成后,即可在工程的package.json文件中看到相关的依赖配置以及所安装的Pinia的版本,
js
"dependencies": {
"pinia": "^3.0.4",
"vue": "^3.5.32"
},
使用Pinia模块的功能之前,需要先将其挂载到Vue应用实例上,修改main.js中的代码如下:
js
import { createApp } from 'vue'
import App from './App.vue'
import {createPinia} from 'pinia'
//创建应用实例
let app = createApp(App)
//挂载Pinia实例
app.use(createPinia())
app.mount('#app')
下面我们尝试体验一下Pinia状态管理的基本功能。首先仿照HelloWorld组件来创建一个新的组件,并将其命名为HelloWorld2.vue,其功能也是一个简单的计数器,代码如下:
html
<script setup>
import {ref} from 'vue'
const count = ref(0)
function increment() {
count.value++
}
</script>
<template>
<h1>计数器2:{{ count }}</h1>
<button @click="increment">增加</button>
</template>
修改App.vue文件如下:
html
<script setup>
import HelloWorld from './components/HelloWorld.vue'
import HelloWorld2 from './components/HelloWorld2.vue'
</script>
<template>
<HelloWorld/>
<HelloWorld2/>
</template>
运行此Vue工程,在页面上可以看到两个计数器,如图13-2所示。

此时,这两个计数器组件是相互独立的,即单击第1个按钮只会增加第1个计数器的值,单击第2个按钮只会增加第2个计数器的值。如果我们需要让这两个计数器共享一个状态,且同时操作此状态,就需要Pinia出马了。
Pinia框架的核心在于其Store,也就是仓库。我们可以将Store视为一个专门的容器,它负责存储和管理应用中需要被多个组件共享的状态。Pinia中的Store具有极高的灵活性,其中存储的状态是响应式的。这意味着当Store中的状态数据发生变化时,这些变化会自动反映到相应的组件视图上。更为关键的是,Store中的状态数据不允许开发者直接修改。改变Store中状态数据的唯一途径是提交action操作。这种严格的管理机制使得追踪每个状态的变化过程变得更加便捷,从而有助于应用的调试过程。
现在,我们来使用Pinia对上述代码进行改写。在项目的src文件夹下,新建一个名为CounterState.ts的文件,并编写以下代码:
js
import {defineStore} from 'pinia'
//定义一个状态仓库counter
export default defineStore('counter', {
//定义需要使用的状态数据
state: ()=>{
return {
count: 0
}
}
})
在上述代码中,使用Pinia中的defineStore函数创建了一个Store实例,Store可以理解为"仓库",即存储状态数据的地方。其中,defineStore函数的第1个参数设置了当前Store的名称,第2个参数是一个配置对象,通过state配置项来定义具体的状态数据,Store中定义的状态数据是具有响应性的,可以直接使用。修改HelloWorld和HelloWorld2组件的代码如下:
html
<script setup>
import counter from '../CounterState'
const store = counter()
</script>
<template>
<h1 @click="store.count++">Pinia计数器1:{{ store.count }}</h1>
</template>
html
<script setup>
import counter from '../CounterState'
const store = counter()
</script>
<template>
<h1 @click="store.count++">Pinia计数器2:{{ store.count }}</h1>
</template>
如以上代码所示,获取到Store实例后,直接通过状态名来使用状态即可。运行工程,可以看到两个计数器组件的值已经可以实时同步了。

使用Pinia进行状态管理的关键步骤是定义Store。在定义Store时,除state配置项外,我们还会遇到getters和actions。state用于声明状态数据,而getters可用于创建派生状态或计算属性。actions则承担着提供逻辑操作的职责,它使得状态的修改过程得以集中处理。
2、Pinia中的一些核心概念
2.1、Pinia中的Store
使用Pinia的核心在于定义"状态仓库"Store,在定义Store时,我们可以对要使用的状态数据进行定义,Pinia框架中提供了defineStore方法来生成Store实例。如前面的代码所示,定义一个Store非常简单:
js
const userInfoStore = defineStore('userInfo', {
//...
})
defineStore方法有两个参数,第1个参数为Store的名称,它需要是唯一的,第2个参数为配置对象或setup方法,示例代码采用了配置对象的方式定义Store,后续我们将采用setup方法的方式来定义Store,例如:
js
import {defineStore} from 'pinia'
import {ref} from 'vue'
export const userInfoStore = defineStore('userInfo', ()=>{
//和setup方法的用法一致
const name = ref('nick')
const age = ref(15)
function incrementAge() {
age.value += 1
}
return {
name,
age,
incrementAge
}
})
在使用时,只要引入此Store对象,接口创建出引用实例,对其内部的状态数据和动作方法可以直接调用,代码如下:
html
<script setup>
import { userInfoStore } from '../CounterState';
const userInfo = userInfoStore()
</script>
<template>
<h1 @click="userInfo.incrementAge">
name: {{ userInfo.name }}, age: {{ userInfo.age }}
</h1>
</template>

2.2、Pinia中的State
state指的是状态数据,在Pinia中,支持通过选项式API或组合式API来定义状态。如果选择使用选项式API进行定义,在配置对象中设置state选项即可。此选项是一个函数,其返回所需的状态数据。值得注意的是,使用选项式API返回的状态数据无须通过ref等方法包装,它默认具备响应式特性。而使用组合式API则更为直接,类似于编写Vue组件的过程,在setup方法中声明并返回所需的状态数据。
对于在Store中定义的状态,我们可以直接访问,包括读取和修改。但需注意,Pinia不允许在Store使用过程中动态添加新的状态。所有需要的状态数据必须在定义Store时就明确指定。
除直接访问状态数据进行修改外,还可以使用$patch方法来批量更新多个状态。例如:
html
<script setup>
import { userInfoStore } from '../CounterState';
const userInfo = userInfoStore()
</script>
<template>
<h1 @click="userInfo.incrementAge">
name: {{ userInfo.name }}, age: {{ userInfo.age }}
</h1>
<button @click="userInfo.$patch({
name: 'Jaki',
age: 30
})">修改用户信息</button>
</template>

在上面的例子中,调用 p a t c h 方法传入了一个对象,将要修改的状态数据在此对象中指定即可。 patch方法传入了一个对象,将要修改的状态数据在此对象中指定即可。 patch方法传入了一个对象,将要修改的状态数据在此对象中指定即可。patch方法支持通过一个函数来修改当前状态数据。
如果需要订阅Store中状态数据的变化来实现某些业务逻辑,可以使用$subscribe方法来增加状态订阅,例如:
html
<script setup>
import { userInfoStore } from '../CounterState';
const userInfo = userInfoStore()
userInfo.$subscribe((mutation, state)=>{
console.log(mutation)
//当前的状态
console.log(state)
})
</script>
<template>
<h1 @click="userInfo.incrementAge">
name: {{ userInfo.name }}, age: {{ userInfo.age }}
</h1>
<button @click="userInfo.$patch({
name: 'Jaki',
age: 30
})">修改用户信息</button>
</template>

无论是直接访问还是使用 p a t c h 方法产生的状态变更,都会执行 patch方法产生的状态变更,都会执行 patch方法产生的状态变更,都会执行subscribe注册的订阅回调,此回调中的mutation参数封装了当次变更的信息,包括的字段如表所示。
| 字段 | 值 | 意义 |
|---|---|---|
type |
direct:直接访问 patch object:patch对象修改 patch function:patch函数修改 | 当次变更的类型 |
storeId |
- | Store的名字 |
payload |
- | $patch方法携带的数据 |
2.3、Pinia中的Getters
与Vue组件中定义计算属性的方式类似,Pinia中的Store也支持定义"计算状态"。在Vue中,计算属性通常体现为Getter方法,它让我们能够对数据进行处理后再使用。对于Pinia而言,提供的状态数据可能并不总是直接适用于页面渲染,例如某些数值数据在展示时可能需要添加单位。通常,这些计算过程是通用的,即需要被多个组件共享,若每个使用该状态数据的组件都重复实现相同的计算逻辑,则会显得冗余。Pinia允许我们在定义Store时,为其添加特定的计算属性,即Getter方法,从而避免代码重复,提升维护性和效率。
以组合式API为例,使用Vue中的computed函数来定义"计算状态",代码如下:
js
import {defineStore} from 'pinia'
import {computed, ref} from 'vue'
export const userInfoStore = defineStore('userInfo', ()=>{
//和setup方法的用法一致
const name = ref('nick')
const age = ref(15)
function incrementAge() {
age.value += 1
}
const ageString = computed(()=>{
return age.value + '岁'
})
return {
name,
age,
incrementAge,
ageString
}
})
html
<template>
<h1 @click="userInfo.incrementAge">
name: {{ userInfo.name }}, age: {{ userInfo.ageString }}
</h1>
<button @click="userInfo.$patch({
name: 'Jaki',
age: 30
})">修改用户信息</button>
</template>

2.4、Pinia中的Actions
在Pinia中,另一个至关重要的概念是Action。在Store中,我们可以定义一系列操作函数,这些函数通常封装了对状态数据的修改行为。这样做允许我们将复杂的状态变更逻辑集中在Store内部,从而提升程序的可扩展性和可维护性。Pinia中的Action具备一项强大功能,即支持订阅。通过对Store中的Action进行订阅,我们能够监听方法的调用情况以及调用结果,这为状态管理的精细化控制提供了极大的便利。例如:
html
<script setup>
import { userInfoStore } from '../CounterState';
const userInfo = userInfoStore()
//添加Action订阅
userInfo.$onAction((action)=>{
//动作的名称
console.log(action.name)
//当前Store实例
console.log(action.store)
//方法执行的参数
console.log(action.args)
//方法成功完整执行后的回调函数
let afterCallback = action.after
//方法有异常抛出的回调函数
let errorCallback = action.onError
console.log("方法执行开始前...")
//注册完成的回调
afterCallback(()=>{
console.log("方法执行完成")
})
//注册异常回调
errorCallback(()=>{
console.log("方法执行异常")
})
})
</script>
<template>
<h1 @click="userInfo.incrementAge">
name: {{ userInfo.name }}, age: {{ userInfo.age }}, ageString: {{ userInfo.ageString }}
</h1>
</template>

使用$onAction添加行为的订阅时,其注册的回调函数的action参数中包含如表所示的数据。
| 字段 | 值 | 意义 |
|---|---|---|
name |
- | 当前所调用的action的名字 |
store |
- | 当前的Store实例对象 |
args |
- | 参数列表 |
after |
- | 一个用来注册Action成功执行回调的函数 |
onError |
- | 一个用来注册Action出现异常回调的函数 |
3、Pinia插件
在Pinia中,插件是一种极其强大的功能。它提供了API,允许开发者向Store中添加新的状态和行为,同时还能拦截和额外处理Store自身的操作。通过使用插件,开发者能够封装具有高聚合性和复用性的逻辑,随后轻松地将这些逻辑分配给任何需要该功能的Store。这极大地提升了代码的模块化和可重用性。
3.1、插件使用示例
我们可以通过一个简单的示例来体验插件的基本用法。前面我们定义过一个名为userInfoStore的Store,假设业务中的Store是有版本号标记的,当项目升级时,需要对版本号进行统一升级和维护,就需要所有定义的Store都有统一的版本号属性。
首先修改main.js文件的代码,进行Pinia插件的定义和加载:
js
import { createApp } from 'vue'
import App from './App.vue'
import {createPinia} from 'pinia'
//创建应用实例
let app = createApp(App)
//创建Pinia
let pinia = createPinia()
//挂载Pinia
app.use(pinia)
//定义插件
function PiniaVersionPlugin(context) {
return {version: '1.0.0'}
}
//加载插件
pinia.use(PiniaVersionPlugin)
//挂载Vue App
app.mount('#app')
在上述示例代码中,我们需要使用createPinia函数来创建Pinia的实例,并保存这个实例。接着,调用这个Pinia实例的use方法来安装插件。插件本质上是一个函数,其实现过程中可以返回一个对象。该对象中定义的数据将作为静态属性,自动加载到所有的Store中。
使用这些插件时,我们可以像操作普通状态一样使用这些静态属性。以下是如何使用插件中定义的静态属性的示例:
html
<script setup>
import {userInfoStore} from '../CounterState'
const userInfo = userInfoStore()
</script>
<template>
<h1>Store版本号:{{ userInfo.version }}</h1>
</template>

注意,必须将Pinia实例挂载到Vue应用后再加载插件,否则插件将不会生效。另外,在定义插件时,插件函数中会被传入context上下文对象,此对象中包含的数据列举如表所示。
| 字段 | 值 | 意义 |
|---|---|---|
pinia |
- | 使用createPinia方法创建的Pinia实例 |
app |
- | Vue3的App实例 |
store |
- | 加载此插件的Store实例 |
options |
- | 加载此插件的Store中定义的选项 |
注意,当Pinia加载了插件后,每个Store的定义都会调用此插件函数,在context参数中可以获取到当前的Store实例。
3.2、使用插件扩展Store
插件除可以为所有Store增加静态属性外,我们也可以单独为某个Store增加状态。例如:
js
function PiniaVersionPlugin(context) {
console.log(context)
if(context.store.$id == 'userInfo') {
context.store.customState = 'customState'
}
return {version: '1.0.0'}
}
然后,在使用名为userInfo的Store时,即可自动添加customState状态。
也可以在插件中进行Store的状态或行为订阅,这样方便我们将可复用的订阅逻辑进行封装,在多个Store中复用,例如:
js
context.store.$subscribe(()=>{
console.log('插件订阅状态')
})
context.store.$onAction(()=>{
console.log('插件订阅行为')
})
4、示例:一个简单的图书管理系统
下面我们介绍具体的实现过程。首先,使用Vite创建Vue项目,然后引入依赖,做好准备工作后,依次完成下述各个步骤。
在src目录下创建一个名为stores的文件夹,用于存放Pinia的状态管理文件。然后,在stores文件夹中创建一个名为bookStore.js的文件,用于存放图书信息的状态管理逻辑。代码如下:
js
在componests目录下创建一个名为BookList.vue的文件,用于显示图书列表。代码如下:
js
import {defineStore} from 'pinia'
import {ref} from 'vue'
//定义一个名为book的Pinia store,用于管理图书信息的状态
export const useBookStore = defineStore('book', ()=>{
//存储图书的数组
const books = ref([])
const addBook = (book)=>{
books.value.push(book)
}
//编辑图书的方法
const editBook = (index, updatedBook)=>{
books.value[index] = updatedBook
}
//删除图书的方法
const deleteBook = (index)=>{
books.value.splice(index, 1);
}
return {
books,
addBook,
editBook,
deleteBook
}
})
在components文件夹中创建一个名为AddBook.vue的文件,用于添加图书信息。代码如下:
html
<script setup>
import { useBookStore } from '../stores/bookStore';
import {ref} from 'vue'
import {useRouter} from 'vue-router'
let store = useBookStore()
let router = useRouter()
let book = ref({
title: '',
author: ''
})
//添加图书的方法
function addBook() {
store.addBook(book.value)
router.push('/') //跳转到图书列表页面
}
</script>
<template>
<div>
<h1>添加图书</h1>
<form @submit.prevent="addBook">
<label>书名:<input v-model="book.title"/></label>
<label>作者:<input v-model="book.author"/></label>
<button type="submit">提交</button>
</form>
<!-- 返回图书列表页面 -->
<router-link to="/">返回图书列表</router-link>
</div>
</template>
在components文件夹中创建一个名为EditBook.vue的文件,用于编辑图书信息。代码如下:
html
<script setup>
import { useBookStore } from '../stores/bookStore';
import {ref, onMounted} from 'vue'
import {useRouter, useRoute} from 'vue-router'
const route = useRoute()
const router = useRouter()
const store = useBookStore()
const bookIndex = ref('')//初始化图书索引为空
const book = ref({
title: '',
author: '',
})
onMounted(()=>{
bookIndex.value = route.params.index //从路由参数中获取图书索引
book.value = store.books[bookIndex.value] //根据索引获取对应的图书对象
})
//编辑图书的方法
function editBook() {
store.editBook(bookIndex.value, book.value)
router.push('/')//跳转到图书列表页面
}
</script>
<template>
<div>
<h1>编辑图书</h1>
<form @submit.prevent="editBook">
<label>书名:<input v-model="book.title"/></label>
<label>作者:<input v-model="book.author"/></label>
<button type="submit">提交</button>
</form>
<!-- 返回图书列表页面 -->
<router-link to="/">返回图书列表</router-link>
</div>
</template>
在src目录下创建一个名为router的文件夹,用于存放VueRouter的配置文件。在router文件夹中创建一个名为index.js的文件,用于配置路由。
js
import {createRouter, createWebHistory} from 'vue-router'
import BookList from '../components/BookList.vue'
import AddBook from '../components/AddBook.vue'
import EditBook from '../components/EditBook.vue'
const routes = [
{path: '/', component: BookList},
{path: '/add', component: AddBook},
{path: '/edit/:index', component: EditBook},
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router
在src目录下创建一个名为main.js的文件,用于引入Pinia、Vue Router和根组件。代码如下:
js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import {createPinia} from 'pinia'
//创建应用实例
let app = createApp(App)
//创建Pinia
let pinia = createPinia()
//挂载Pinia
app.use(pinia)
//挂载路由
app.use(router)
//挂载Vue App
app.mount('#app')
App.vue如下:
html
<script setup>
</script>
<template>
<router-view></router-view>
</template>




