深入Vuex与Pinia

本文旨在探讨Vue生态中与状态管理相关的相关库:包括Vuex及其后续版本Pinia。我们计划初步了解并实践基础应用方法。随后根据个人体验进行优化和完善,并进一步深入研究其源代码实现细节以掌握生产级状态管理库的设计思想。
前端数据管理
采用Vue3框架开发的Web应用通常包含三个主要部分:组件、数据和路由系统。当Web应用较为复杂时,在其架构中会存在多个深层次层级的嵌套情况,在这种情况下直接通过组件间传递值的方式将数据从最外层父组件传递到最内层子组件的行为显得不够优雅且难以有效维护。因此,在这种需求下,默认的数据管理功能成为必须解决的关键问题,并由此应运而生的是Vuex技术
经过简短思考发现解决多组件间数据互通需求最基础的方法是建立一个统一的数据存储空间用于整合各组件之间的交互信息为了实现高效的协作工作流程这个统一的数据存储空间需要结合现代前端开发的最佳实践因此决定采用ref机制或者基于reactive的状态管理机制来进行封装这样既保证了数据的一致性又便于后续开发操作其他依赖该共享数据的组件能够直接利用这个封装好的统一数据源而无需额外操作即可访问
使用Vuex
由于Vue3设计上的变动, 为了更好地支持基于Vue3的项目, 我们需要安装Vuex@next; 通过以下命令, 构建一个基本的Vue3+Vuex@next项目的构建过程:
yarn create vite MiniVuex --template vue
yarn add vuex@next
最基础的体验是使用Vuex。通过这个过程,我们可以更好地理解其核心机制,并在基础上进一步探索如何扩展它
最简单的做法是实践一下Vuex的基本功能。通过这个练习后,在基础上深入研究和开发一个小型的Vue库
体验是最简单的方式之一来了解Vuex的基本用法。在掌握了这些基础之后,在基础上进一步探索如何构建自己的MiniVuex实现
学习最基础的方法就是先尝试使用Vue库。通过这个过程后,在基础上深入研究和开发一个小型实现框架
在src下创建store目录,然后新建index.js,写入如下代码:
import { createStore } from 'vuex'
const store = createStore({
state() {
return {
count: 666
}
},
mutations: {
add(state) {
state.count++
}
}
})
export default store
使用createStore方法生成一个命名为store的数据存储,在该数据存储的内部为它定义了state函数。这些函数返回的对象即为Vuex所管理的数据。当需要对数据进行修改操作时,请通过mutations属性中的特定方法来进行操作。在这里我们采用add方法来完成这一操作
为了在Vue框架中实现基于Vuex的数据源管理,在项目入口处导入并配置文件src/main.js。其中,在该文件中需调用app.use(store)来进行数据源的注册与管理。
import { createApp } from 'vue'
import App from './App.vue'
import store from './store'
createApp(App).use(store).mount('#app')
让我们直接在App.vue中使用一下:
<script setup>
import { computed } from 'vue';
import { useStore } from 'vuex';
let store = useStore()
let count = computed(() => store.state.count)
function add(){
store.commit('add')
}
</script>
<template>
<img alt="Vue logo" src="./assets/logo.png" />
<!-- div标签渲染count并绑定点击函数 -->
<div @click="add">{{count}}</div>
</template>
这段代码极为简洁明了,在Vue框架中实现了简单的状态管理功能。具体来说,在代码逻辑中首先使用useStore组件获取了一个存储对象,并将该对象关联到存储中的count数据字段上。随后,在这个存储对象上封装了一个 Vue 的计算属性(computed)。每当用户点击 div 标签时,在 Vue 中就会自动绑定一个名为 add 的函数入口点,并对该函数进行操作:通过调用 $store.commit('add') 引发 Vuex 中的状态更新机制以修改相关数据状态。
这便是Vuex的基本用法呀!是不是很简单呢?从这个案例来看似乎直接采用Vue的ref或reactive属性来实现会更加便捷对吧?由此提出一个问题那就是何时采用Vuex来进行数据管理以及何时可以直接在组件内部的ref或reactive属性中进行数据存储与管理呢?
当数据仅限于组件内部时,请采用ref和reactive机制进行管理;若数据需跨越组件进行共享,则应将其放入Vuex系统中进行管理和运用。举例来说,在后端项目处理用户的个人信息时,多个组件都需要访问这些信息,则将它们放入Vuex系统会更加高效。
实现MiniVuex
为了透彻理解Vuex的基本原理,在编写代码之前,我们需要深入掌握Vue中的provide和inject功能;这两个功能主要用于实现数据共享。
在文章开头部分,在开发复杂Web项目中的场景中,在实现这些项目时发现当组件存在深度嵌套的情况时使用props的方法不够优雅且难以维护为此Vue提供了提供与注入函数来解决这一问题这样无论组件嵌套的层级有多深都可以轻松传递数据

具体而言,在某个项目中(举例来说),父组件利用 provide 函数输出数据(举例来说),而子组件采用 inject 接口获取数据(举例来说)。
Root
└─ TodoList
├─ TodoItem
└─ TodoListFooter
├─ ClearTodosButton
└─ TodoListStatistics
为了将todo-items中的数据发送给TodoListStatistics,并采用prop方式实现逐级传递:从TodoList开始到TodoListFooter再到TodoListStatistics。可以通过提供和注入的方式来实现这一过程。
// TodoList组件(父组件)
app.component('todo-list', {
data() {
return {
todos: ['Feed a cat', 'Buy tickets']
}
},
// 通过provide提供数据
provide: {
user: 'John Doe'
},
template: `
<div>
{{ todos.length }}
<!-- 模板的其余部分 -->
</div>
`
})
// TodoListStatistics组件(子组件)
app.component('todo-list-statistics', {
// 通过inject获得组件
inject: ['user'],
created() {
console.log(`Injected property: ${this.user}`) // > 注入的 property: John Doe
}
})
至此,在使用提供者和注入者时,你应该已经较为熟悉它们的基本用法。而Vuex通过一层包装,在这两个核心方法的基础上实现了更高级的功能。这使得在嵌套场景中使用这些方法不再繁琐。也不需要将提供者和注入者的相关代码分散到各个地方。
掌握提供者模式(Provide)与注入者模式(Inject)的概念后,请逐步实现一个MiniVuex组件,并在指定位置构建并生成相应的代码段。
import { inject, reactive } from "vue";
const STORE_KEY = '__store__'
function useStore() {
return inject(STORE_KEY)
}
function createStore(options) {
// 创建Store实例
return new Store(options)
}
class Store {
constructor(options) {
// reactive包裹,_state为响应式对象
this._state = reactive({
data: options.state()
})
this._mutations = options.mutations
}
get state() {
return this._state.data
}
commit = (type, playload) => {
// 获得函数对象
const entry = this._mutations[type]
// 执行函数
entry && entry(this.state, playload)
}
// main.js入口app.use(store)会执行该函数
install(app) {
// 利用provide将其注册给全局的组件使用
app.provide(STORE_KEY, this)
}
}
export{ createStore, useStore }
没错,上面30行代码就是MiniVuex的核心逻辑了,我们细品一下。
为了便于理解,我们可以从引入与注入两种技术作为切入点,并在Store类的install方法内部实现提供功能。
当我们导入src/main.js文件并在其中调用app.use(store)方法时,系统会自动生成一个与Store类绑定的安装方法,并将其作为参数传递给该类构造函数进行初始化配置。在此过程中,默认情况下系统会调用该绑定下的安装方法来完成初始化操作。因此,在调用app.provide(STORE_KEY, this)时,“this”将指向刚传入的store实例。其作用是将该store对象通过提供给Vue容器使其可用。
如何在该组件中进行操作?当然需要借助inject的方法来获取相应的功能。参考gvues.js中的实现可以看到,在其中的useStore方法里已经集成好了这个功能:当我们采用gvux库在一个组件里时,默认会调用useStore来获取store对象,并在此基础上完成后续的操作步骤:
import { useStore } from './store/gvuex';
let store = useStore()
let count = computed(() => store.state.count)
这是MiniVuex的核心机制,并通过提供与注入实现跨组件共享的状态,并遵循相同的机制。
接着考察Store类的逻辑,在构造方法中。利用reactive技术将options.state()进行封装,并将其返回值以响应式数据形式传递至this._store变量。这样使得页面内容相应地发生变更。
这部分内容相对简单,并不详细展开。将采用Vuex章节中的代码实现目标功能,并确保其余部分保持原样。现在就可以正常使用gvuex功能。
Pinia简单使用
支持Vue3的Vuex即为Vyjs系列中的核心库之一。基于其模块化namespace的设计理念,在每次操作时都需要通过commit方法来触发相应的功能。例如,在user组件中需要调用Vyjs中的add方法来存储用户信息。由于采用了namespace机制,在触发相关操作时通常会使用'commit('user/add')'这样的语法。而在此之前,在JavaScript和TypeScript早期缺乏强大的动态类型系统时,则难以实现类似的功能。因此,在Vue团队的研究下Vyjs最终演变为现在的Vyjs5
针对Vue Github的RFC文档(Issue #327),Vesx5已提供了相应的设计草案。该方案旨在集成功能模块,并去除了命名空间问题。通过混合存储架构实现良好的类型推导能力。同时将mutations整合为单一的行为模型(Action),这与现有的Vesx4框架保持一致。这样用户无需深入理解复杂的系统组件就能高效地完成开发任务。
Pinia遵循Vue 5.x设计稿进行开发。由于存在核心团队成员参与其中,其代码质量得到了显著提升。Pinia迅速成为官方认可的项目之一,并且多位开发者曾公开表示 Pinia 即将替代 Vue 5.x。
对Pinia有大体了解后,便安装并使用一下吧。
yarn add pinia@next
第一步操作是从src/main.js导入createPinia方法,并利用此方法生成pinia实例。接着将这些实例绑定到应用体上。
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia).mount('#app')
在store文件夹中创建count.js,在该文件中使用Pinia。
Pinia有两种主要的使用方式。其中一种采用了类似于Vuex4的方法,在具体实现上是通过调用defineStore函数创建一个Store实例。随后,在该Store实例中定义了一个State属性,并将该State属性设计为返回一个包含需要管理的数据的对象。若需对这些数据进行修改操作,则应通过该Store实例中的Actions进行相应的处理。值得注意的是,在这一实现过程中已经去除了Mutation概念,并采用Actions统一进行数据操作。
import { defineStore } from "pinia";
const useCounterStore = defineStore('count', {
id: 'count',
state: () => {
return {
count: 1
}
},
actions: {
add() {
this.count++
}
}
})
除此之外,在代码中还可以采用Composition风格创建一个Store对象,并在其中通过ref或reactive包裹数据。这种方法几乎彻底去除了所有的概念域,并使得构建和维护更加便捷。
const useCounterStore2 =defineStore('count2', () => {
const count = ref(0)
function add() {
count.value++
}
return {count, add}
})
这两种写法在组件上使用时,完全是一样:
<script setup>
import { useCounterStore } from './store/count';
const store =useCounterStore()
function add(){
store.add()
}
</script>
<template>
<img alt="Vue logo" src="./assets/logo.png" />
<div @click="add">{{store.count}}</div>
</template>
<style>
在上述代码中,呈现了第一种实现方式;若希望采用第二种方法,则需将useCounterStore替换为useCounterStore2即可。
Pinia源码浅析
获取Pinia源码(即可通过拉取获取),然后访问并查看createPinia函数的具体实现。
export function createPinia(): Pinia {
// 创建作用域
const scope = effectScope(true)
// ref创建响应式数据对象
const state = scope.run<Ref<Record<string, StateTree>>>(() =>
ref<Record<string, StateTree>>({})
)!
let _p: Pinia['_p'] = []
let toBeInstalled: PiniaPlugin[] = []
// markRaw
const pinia: Pinia = markRaw({
// install函数再app.use时被调用
install(app: App) {
setActivePinia(pinia)
if (!isVue2) {
pinia._a = app
// provide将pinia提供出去
app.provide(piniaSymbol, pinia)
// 全局变量$pinia
app.config.globalProperties.$pinia = pinia
if (__DEV__ && IS_CLIENT) {
registerPiniaDevtools(app, pinia)
}
toBeInstalled.forEach((plugin) => _p.push(plugin))
toBeInstalled = []
}
},
// 插件机制
use(plugin) {
if (!this._a && !isVue2) {
toBeInstalled.push(plugin)
} else {
_p.push(plugin)
}
return this
},
_p,
_a: null,
_e: scope,
_s: new Map<string, StoreGeneric>(),
state,
})
if (__DEV__ && IS_CLIENT && !__TEST__) {
// 对VueDevtools实现支持
pinia.use(devtoolsPlugin)
}
return pinia
}
上述代码的很多细节,先不用关系,抓大放小,关注其中的重点。
首先利用ref机制创建相应的数据对象state;随后定义名为install的方法,并进一步利用provide函数将pinia对象进行全局提供;同时,在此过程中还实现了对globalVar中的pinia进行绑定;这样一来,在实际应用中操作更加便捷。
不言而喻的是,在实现MiniVuex时所采用的机制与pinia的工作原理是一致的。具体而言,在pinia类内部通过使用ref或reactive来定义响应式变量,并借助provide方法将这些变量供全局引用使用,从而实现了对数据的有效管理。
那inject方法在哪里被使用了?
在项目中发现packages/pinia/store.ts中的defineStore函数后,在学习Pinia的简单使用方法时,我们主要依靠defineStore函数来创建一个特定类型的对象(称为store)。其中,在定义该store对象时主要依靠useStore函数来进行操作,并且每个这样的 store 对象都有独特的标识符来区分。相关代码如下:
function useStore(pinia?: Pinia | null, hot?: StoreGeneric): StoreGeneric {
const currentInstance = getCurrentInstance()
// 通过inject方法获取pinia实例
pinia =
(__TEST__ && activePinia && activePinia._testing ? null : pinia) ||
(currentInstance && inject(piniaSymbol))
if (pinia) setActivePinia(pinia)
// 省略其余代码...
在使用useStore方法时,在传递给useStore的对象如果是Pinia类的一个实例时,则由inject方法从该对象中取用相应的实例。此时,Pinia的核心功能就得到了明确的实现。
Pinia的源码内容较为丰富, 涵盖npm库的实现方式, 如何适配Vue2及Vue3以及是否支持其Debug插件等功能. 然而, 这些技术细节中与数据管理关联度较低的部分, 本文不予探讨.
结尾
在本文中,在透彻了解Vuex与Pinia所提供的数据管理能力后,在后续使用Vue开发项目时能够更好地掌控这些功能。在后续部分中我们将深入探讨Vue Router的相关功能并进一步提升对使用Vue构建项目的掌控感期待下次见面吧。
