Pinia
为什么是 Pinia
怎么说呢,其实在过往的大部分项目里面,我并没有引入过状态管理相关的库来维护状态。因为大部分的业务项目相对来说比较独立,哪怕自身功能复杂的时候,可能也仅仅是通过技术栈自身的提供的状态管理能力来处理业务场景问题,比如 React 中的 context,基本都能解决我遇到的问题。
针对 Redux 或者 Vuex 这类状态管理的库,我认为在较为复杂的大型业务场景下才能发挥他们的真实作用,在场景较为简单单一,数据状态相对不复杂的时候,引入他们可能并不能带来那么多的便捷。
说回 Pinia,接触使用到它主要是因为有一次手里的发布功能需要进行重构。虽然仅仅是一个发布页面,但是梳理起来发现,里面涉及几类数据需要维护,包括主表单信息、错误校验信息、公共弹窗信息以及关联用户的各种状态信息等。想起之前有看到过关于小 🍍 的介绍,抱着试试看的心态接入试试。(不行我就继续 Provide/Inject 了[捂脸])
认识 Pinia
Pinia 是 Vue 的存储库,它允许您跨组件/页面共享状态。
上面这个是官网对 Pinia 的一个定义,从定义上我们其实可以看出来,它可能比 Vuex 要精炼一些(Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式 + 库。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。)具体如何我们还是得具体从使用情况来看一下。
Pinia 的常规用法
安装
通过常用的包管理器进行安装:
yarn add pinia
// 或者
npm install pinia
安装完成后,我们需要将 pinia 挂载到 Vue 应用中,也就是我们需要创建一个根存储传递给应用程式。我们需要修改 main.js,引入 pinia 提供的 cteatePinia 方法:
import { createApp } from 'vue';
import { createPinia } from 'pinia';
const pinia = createPinia();
const app = createApp(App);
app.use(pinia).mount('#app');
上述安装引入基于 Vue3,如果使用 Vue2 的话,轻参照官网相关说明即可。
Store
store 简单来说就是数据仓库的意思,我们 的数据都放在 store 里面。当然你也可以把它理解为一个公共组件,只不过该公共组件只存放数据,这些数据我们其它所有的组件都能够访问且可以修改。它有三个概念,state、getters和actions,我们可以将它们等价于组件中的“数据”、“计算属性”和“方法”。
store 中应该包含可以在整个应用中访问的数据、全局性数据,我们应该避免将可以管理在具体组件内部的数据放到 store 中。
我们需要使用 pinia 提供的defineStore()
方法来创建一个 store,该 store 用来存放我们需要全局使用的数据。我们可以在项目中创建 store 目录存储我们定义的各种 store:
// src/store/formInfo.js
import { defineStore } from 'pinia';
// 第一个参数是应用程序中 store 的唯一 id
const useFormInfoStore = defineStore('formInfo', {
// 其他配置项,后面逐一说明
})
export default useFormInfoStore;
defineStore 接收两个参数:
- name:一个字符串,必传项,该 store 的唯一 id。
- options:一个对象,store 的配置项,比如配置 store 内的数据,修改数据的方法等等。
将返回的函数命名为 use… 是组合式开发的约定,使其符合使用习惯。我们可以根据项目情况定义任意数量的 store 存储不同功能模块的数据,一个 store 就是一个函数,它和 Vue3 的实现思想也是一致的。
使用 store
我们可以在任意组件中引入定义的 store 来进行使用
<script setup>
// 引入定义
import useFormInfoStore from "@/store/formInfo";
// 调用方法,返回store实例
const formInfoStore = useFormInfoStore();
</script>
store 被实例化后,你就可以直接在 store 上访问 state
、getters
和 actions
中定义的任何属性。
解构 store
store
是一个用reactive
包裹的对象,这意味着不需要在 getter 之后写.value
,但是,就像setup
中的props
一样,我们不能对其进行解构,如果我们想要提取 store 中的属性同时保持其响应式的话,我们需要使用storeToRefs()
,它将为响应式属性创建 refs。
<script setup>
import { storeToRefs } from "pinia";
// 引入定义
import useFormInfoStore from "@/store/formInfo";
// 调用方法,返回store实例
const formInfoStore = useFormInfoStore();
const { name, age } = formInfoStore; // ❌ 此时解构出来的name和age不具有响应式
const { name, age } = storeToRefs(formInfoStore); // ✅ 此时解构出来的name和age是响应式引用
</script>
State
store 是用来存储全局状态数据的仓库,那自然而然需要有地方能够保存这些数据,它们就保存在 state 里面。defineStore 传入的第二个参数 options 配置项里面,就包括 state 属性。
// src/store/formInfo.js
import { defineStore } from 'pinia';
const useFormInfoStore = defineStore('formInfo', {
// 推荐使用 完整类型推断的箭头函数
state: () => {
return {
// 所有这些属性都将自动推断其类型
name: 'Hello World',
age: 18,
isStudent: false
}
}
// 还有一种定义state的方式,不太常见,了解即可
// state: () => ({
// name: 'Hello World',
// age: 18,
// isStudent: false
// })
})
export default useFormInfoStore;
访问 state
默认情况下,您可以通过 store
实例来直接读取和写入状态:
<script setup>
import useFormInfoStore from '@/store/formInfo';
const formInfoStore = useFormInfoStore();
console.log(formInfoStore.name); // 'Hello World'
formInfoStore.age++; // 19
</script>
pinia 还提供了几个常见场景的方法供我们使用来操作 state:$reset
、$patch
、$state
、$subscribe
:
<script setup>
import useFormInfoStore from '@/store/formInfo';
const formInfoStore = useFormInfoStore();
console.log(formInfoStore.name); // 'Hello World'
// 直接修改state中的属性
formInfoStore.age++; // 19
// 1.$reset 重置状态,将状态重置成为初始值
formInfoStore.$reset();
console.log(formInfoStore.age); // 18
// 2.$patch 支持对state对象的部分批量修改
formInfoStore.$patch({
name: 'hello Vue',
age: 198
});
// 3.$state 通过将其 $state 属性设置为新对象来替换 Store 的整个状态
formInfoStore.$state = {
name: 'hello Vue3',
age: 100,
gender: '男'
}
// 4.$subscribe 订阅store中的状态变化
formInfoStore.$subscribe((mutation, state) => {
// 监听回调处理
}, {
detached: true // 💡如果在组件的setup中进行订阅,当组件被卸载时,订阅会被删除,通过detached:true可以让订阅保留
})
</script>
针对上面示例,有几点需要关注一下:
- 1.同直接修改 state 中的属性不同,通过$patch 方法更新多个属性时,在 devtools 的 timeline 中,是合并到一个条目中的。
- 2.通过实验得知,$state 在进行替换时,会更新已有的属性,新增原来 state 不存在的属性,针对不在替换范围内的,则保持不变。
如上图,针对 gender 属性,执行的是"add"操作,然后这个替换过程我们没有设置 isStudent 属性,它仍然保持原状态在 state 中不变。
- 3.subscribe 只会订阅到 patches 引起的变化,即上面的通过 subscribe 只会订阅到 patches 引起的变化,即上面的通过 subscribe 只会订阅到 patches 引起的变化,即上面的通过 patch 方法和$state 覆盖时会触发到状态订阅,直接修改 state 的属性则不会触发。
Getters
pinia 中的 getters 可以完全等同于 Store 状态的计算属性
,通常在 defineStore 中的 getters 属性中定义。同时支持组合多个 getter,此时通过this
访问其他 getter。
import { defineStore } from 'pinia';
const useFormInfoStore = defineStore('formInfo', {
state: () => {
return {
name: 'Hello World',
age: 18,
isStudent: false,
gender: '男'
};
},
getters: {
// 仅依赖state,通过箭头函数方式
isMan: (state) => {
return state.gender === '男';
},
isWoman() {
// 通过this访问其他getter,此时不可以用箭头函数
return !this.isMan;
}
}
});
export default useFormInfoStore;
在使用时,我们可以直接在 store 实例上面访问 getter:
<template>
<div>The person is Man: {{ formInfoStore.isMan }} or is Woman: {{ formInfoStore.isWoman }}</div>
</tempalte>
<script setup>
import useFormInfoStore from '@/store/formInfo';
const formInfoStore = useFormInfoStore();
</script>
通常 getter 是不支持额外传参的,但是我们可以从 getter 返回一个函数的方式来接收参数:
import { defineStore } from 'pinia';
const useFormInfoStore = defineStore('formInfo', {
state: () => {
return {
name: 'Hello World',
age: 18,
isStudent: false,
gender: '男'
};
},
getters: {
isLargeBySpecialAge: (state) => {
return (age) => {
return state.age > age
}
}
}
});
export default useFormInfoStore;
在组件中使用时即可传入对应参数,注意,在这种方式时,getter 不再具有缓存性。
<template>
<div>The person is larger than 18 years old? {{ formInfoStore.isLargeBySpecialAge(18) }}</div>
</tempalte>
<script setup>
import useFormInfoStore from '@/store/formInfo';
const formInfoStore = useFormInfoStore();
</script>
Actions
actions 相当于组件中的 methods,它们定义在 defineStore 中的 actions 属性内,常用于定义业务逻辑使用。action 可以是异步的,可以在其中await
任何 API 调用甚至其他操作
import { defineStore } from 'pinia';
const useFormInfoStore = defineStore('formInfo', {
state: () => {
return {
name: 'Hello World',
age: 18,
isStudent: false,
gender: '男'
};
},
getters: {
isMan: (state) => {
return state.gender === '男';
},
isWoman() {
return !this.isMan;
}
},
actions: {
incrementAge() {
this.age++;
},
async registerUser() {
try {
const res = await api.post({
name: this.name,
age: this.age,
isStudent: this.isStudent,
gender: this.gender
});
showTips('用户注册成功~');
} catch (e) {
showError('用户注册失败!');
}
}
}
});
export default useFormInfoStore;
使用起来也非常方便
<script setup>
import useFormInfoStore from "@/store/formInfo";
const formInfoStore = useFormInfoStore();
const registerUser = () => {
formInfoStore.registerUser();
};
</script>
$onAction()
可以使用 store.$onAction()
订阅 action 及其结果。传递给它的回调在 action 之前执行。 after
处理 Promise 并允许您在 action 完成后执行函数,onError
允许您在处理中抛出错误。
const unsubscribe = formInfoStore.$onAction(
({
name, // action 的名字
store, // store 实例
args, // 调用这个 action 的参数
after, // 在这个 action 执行完毕之后,执行这个函数
onError, // 在这个 action 抛出异常的时候,执行这个函数
}) => {
// 记录开始的时间变量
const startTime = Date.now()
// 这将在 `store` 上的操作执行之前触发
console.log(`Start "${name}" with params [${args.join(', ')}].`)
// 如果 action 成功并且完全运行后,after 将触发。
// 它将等待任何返回的 promise
after((result) => {
console.log(
`Finished "${name}" after ${
Date.now() - startTime
}ms.\nResult: ${result}.`
)
})
// 如果 action 抛出或返回 Promise.reject ,onError 将触发
onError((error) => {
console.warn(
`Failed "${name}" after ${Date.now() - startTime}ms.\nError: ${error}.`
)
})
}
)
// 手动移除订阅
unsubscribe()
和$subscribe 类似,在组件中使用时,组件卸载,订阅也会被删除,如果希望保留的话,需要传入 true 作为第二个参数。
<script setup>
import useFormInfoStore from '@/store/formInfo';
const formInfoStore = useFormInfoStore();
formInfoStore.$onAction(callback, true);
</script>
Pinia 的基础使用我们暂时介绍到这里,其他使用场景大家可以参照官方文档进一步学习。
Pinia VS Vuex
回过头来,我们再来看一下,Pinia 为什么现在这么受到推崇。和我们过往常用的 Vuex 相比,它到底好在哪里呢?
对于 Vuex,我们知道,它的背后基本思想借鉴了 Flux。Flux 是 Facebook 在构建大型 Web 应用程序时为了解决数据一致性问题而设计出的一种架构,它是一种描述状态管理的设计模式。绝大多数前端领域的状态管理工具都遵循这种架构,或者以它为参考原型。Vuex 在它的基础上进行了一些设计上的优化:
Vuex 主要有五部分核心内容:
-
📦 state:整个应用的状态管理单例,等效于 Vue 组件中的 data,对应了 Flux 架构中的 store。
-
🧮 getter:可以由 state 中的数据派生而成,等效于 Vue 组件中的计算属性。它会自动收集依赖,以实现计算属性的缓存。
-
🛠 mutation:类似于事件,包含一个类型名和对应的回调函数,在回调函数中可以对 state 中的数据进行同步修改。
-
Vuex 不允许直接调用该函数,而是需要通过
store.commit
方法提交一个操作,并将参数传入回调函数。 -
commit 的参数也可以是一个数据对象,正如 Flux 架构中的 action 对象一样,它包含了类型名
type
和负载payload
。 -
这里要求 mutation 中回调函数的操作一定是同步的,这是因为同步的、可序列化的操作步骤能保证生成唯一的日志记录,才能使得 devtools 能够实现对状态的追踪,实现 time-travel。
-
🔨 action:action 内部的操作不受限制,可以进行任意的异步操作。我们需要通过
dispatch
方法来触发 action 操作,同样的,参数包含了类型名type
和负载payload
。 -
action 的操作本质上已经脱离了 Vuex 本身,假如将它剥离出来,仅仅在用户(开发者)代码中调用
commit
来提交 mutation 也能达到一样的效果。 -
📁 module:store 的分割,每个 module 都具有独立的 state、getter、mutation 和 action。
-
可以使用
module.registerModule
动态注册模块。 -
支持模块相互嵌套,可以通过设置命名空间来进行数据和操作隔离。
通过和我们上面学习到的 Pinia 的基础内容对比可以看出,Pinia 舍弃了 mutation 和 module 两部分,这样我们在使用时就更加的便捷。
与 Vuex3.x/4.x 对比,主要区别如下:
- mutations 不再存在。他们经常被认为是 非常 冗长。他们最初带来了 devtools 集成,但这不再是问题。
- 无需创建自定义复杂包装器来支持 TypeScript,所有内容都是类型化的,并且 API 的设计方式尽可能利用 TS 类型推断。
- 不再需要注入、导入函数、调用函数、享受自动完成功能!
- 无需动态添加 Store,默认情况下它们都是动态的,您甚至都不会注意到。请注意,您仍然可以随时手动使用 Store 进行注册,但因为它是自动的,您无需担心。
- 不再有 modules 的嵌套结构。您仍然可以通过在另一个 Store 中导入和 使用 来隐式嵌套 Store,但 Pinia 通过设计提供平面结构,同时仍然支持 Store 之间的交叉组合方式。 您甚至可以拥有 Store 的循环依赖关系。
- 没有 命名空间模块。鉴于 Store 的扁平架构,“命名空间” Store 是其定义方式所固有的,您可以说所有 Store 都是命名空间的。
其实对于我来说,之所以选择 Pinia,甚至是喜欢上它,是因为它和 Vue3 的组合是 API 形式更加贴合,只需要把它当作一个特殊的数据状态组件来使用就好,不需要那么复杂的流程。
小结
通过对 Pinia 的基本功能的使用介绍,以及和 Vuex 进行对比,让我们比较清晰的来认识 Pinia,使我们能够入门使用。在具体业务场景中,有效的划分 Store,合理的组合使用,可以帮助我们完成复杂的业务逻辑。
内容收录于github 仓库
补充 Pinia 的 options 和 setup 写法对比
上面的例子都是基于 options 的配置性写法,补充一个 options 和 composition api 的使用方法的对比图。
参考内容: