Vue3——Pinia状态管理

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应用中,组件状态管理包含以下几个关键组成部分。

  1. 状态数据
    状态数据指的是在setup函数中定义的数据,这些数据通过ref方法被封装成响应式,从而能够驱动视图的更新。
  2. 视图
    视图是指template部分所定义的模板内容,它通过声明式语法把状态映射到界面元素上。
  3. 动作
    动作指那些会引发状态变化的行为,即代码中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>


相关推荐
用户新15 小时前
V8引擎 精品漫游指南--Ignition篇(下 一) 动态执行前的事情
前端·javascript
神探小白牙20 小时前
eCharts 多系列柱状图增加背景图
javascript·ecmascript·echarts
追风筝的人er1 天前
SpringBoot+Vue3 企业考勤如何处理法定假期?节假日方案、调休补班与工作日判断链路拆解
前端·vue.js·后端
编程老船长1 天前
解决不同项目需要不同 Node.js 版本的问题
前端·vue.js
薛定猫AI1 天前
【深度解析】Gemma Chat 本地 AI 编程 Agent:Electron + MLX + 开源模型的离线 Vibe Coding 实战
javascript·人工智能·electron
全栈前端老曹1 天前
【前端地图】多地图平台适配方案——高德、百度、腾讯、Google Maps SDK 差异对比、封装统一地图接口
前端·javascript·百度·dubbo·wgs84·gcj-02·bd09
笑虾1 天前
Win10 修改注册表 让鼠标悬停PNG上时 tip 始终显示分辨率
开发语言·javascript·ecmascript
xiaogg36781 天前
spring oauth2 单点登录
java·vue.js·spring
雾岛听风6911 天前
JavaScript基础语法速查手册
开发语言·前端·javascript