欢迎各位
坐在屏幕前,阅读这本小册的读者,你们好~~~。作者知道,关于全局状态管理工具的介绍到处都有,但它们却极其的分散,极少有人对其进行总结。因此,在本小册中,作者由浅入深,从状态管理的本质,案例,到市面上常见的各种状态管理工具,以幽默诙谐的语气,向各位缓缓道来。请各位同学不要急躁,以小说的方式进行阅读,我相信各位同学阅读完,一定会有收获。
最简单的状态管理(window)
我们在开发时,两个组件想要共享数据,最简单的方法是什么?props,emit父子传值?eventBus?不,都不是。最简单的方式其实是将数据挂载在window上。因为window是顶级对象,任何地方都可以通过window.xxx进行获取对应的变量。但为何我们不用这种方式呢?你想想,如果遇到一个变量,就挂载到window上,那么项目一大,组件一多,window上挂载了几百个变量,这是多么可怕的事情。你如何区分上面哪个变量是当前组件需要使用的?至于localStorage、sessionStorage等等h5新增的浏览器本地存储方式也是一样的道理,它们的功能很强大,如果只是用于共享组件间数据,那可谓是得不偿失。
常见的组件之间通信方式
各位同学请看上面这张图,如果组件C有数据,count:1想要传递给组件A和组件B,我们可以通过props父子组件通信,很轻松的实现,如果想要传递给组件G,也顶多在透传一次。但是如果组件G内还有组件H,组件I呢?我们一层层的传递下去多麻烦。这是props的一个缺点。其次,如果组件D,组件E也需要 组件Ccount:1这个数据,那该怎么办?聪明的同学肯定想到了,状态提升!!! 把 count:1这个数据放到最外层的组件容器上,然后传递给组件C、组件D、组件E等等需要使用的组件。这是个不错的方法!但是吧,一层层的下去,还是觉得好麻烦,而且所有数据都放在外面那个大的容器组件上,那数据一多,不就又回到了之前的那个问题了吗?
所以,让我们来了解下大佬们提出的解决方案,状态管理!
状态管理
什么是状态管理?举个例子。小明和小王是好朋友,有一天,小明想寄个礼物给小王,这时候小明就需要把礼物交付给驿站,由驿站打电话去通知小王收礼物。小王收到以后,也想要回送礼物,也需要将礼物放到驿站,然后由驿站通知小明前去取件。这其中的礼物就可以理解为状态。小明和小王就是两个订阅者,而驿站就是发布者。状态管理本质上都是基于发布订阅者模式实现的,请各位同学,熟记这三个专有名词,因为它们在后面会常常出现。
在状态管理工具(如vuex、redux)中,状态是以一个对象树的形式存在的。这个对象树中的每个属性都代表着应用程序的一个状态。我们可以在任意一个组件,获取或修改全局任意状态,从而实现任意组件间的通信。
下面我先介绍两个最常用的全局状态管理工具vuex、redux。
Vuex
Vuex的优势所在
为什么要使用vuex呢?换句话来说vuex有什么好处?
- vuex可以集中管理共享的数据,便于开发和后期的维护
- 能够高效地实现组件之间的数据共享,即任意组件通信
- 存储在vuex中的数据是
响应式的。(这也是最重要的一点)
这些话都太官方了,作者总结了下,就两点。你可以把要共享的数据放到vuex这个 "仓库"里,然后想要使用该状态的组件,就直接去仓库里拿。最重要的一点是,如果某个组件修改了仓库里的数据,那么其他使用该数据的组件,它之前获取的状态也是响应式的变化的。
那么什么样的数据适合存放到vuex中呢?
一般情况下,只有多个组件之间共享的数据,才有必要存放到vuex中,对于组件中的私有数据,依旧存放在data中。(至于为什么,自然是因为使用vuex需要我们更多的开发成本,心智负担)
Vuex的安装配置
**注意:**vuex现在默认版本是vuex@4。如果你还在使用vue2,需要指定vuex的版本号
npm install vuex@3 --save
注册与基本使用
首先,你需要在src目录下创建store/index.js文件。然后导入注册Vuex,并通过Vuex.Store()方法创建store实例对象,最后导出挂载到Vue的组件实例上(类似于$router)。
//index.js文件
import Vuex from "vuex";
import Vue from "vue";
Vue.use(Vuex);
const store=new Vuex.store({
state:{count:0}
})
export default store
//main.js文件
import store from "./store/index";
new Vue({
router,
render: (h) => h(App),
store,
}).$mount("#app");
恭喜你,你的全局状态对象store已经创建成功,可以通过vue开发者工具进行查看
Vuex的工作原理
该图,是作者从vuex的官方文档那截下来的,可以很清除的看到vuex分出了三个重要核心,state、mutations、actions。箭头指向也清楚的阐明了vuex的工作原理,接下来我先详细介绍vuex的各个核心,最后再来解释这张流向图。
Vuex的核心
State
Vuex实际是一颗 单一状态树,它是唯一的数据源,这也就意味着每个应用仅包含一个store实例。
state其实就是一个对象,里面存放需要管理的全局状态。
import Vuex from "vuex";
import Vue from "vue";
Vue.use(Vuex);
const store=new Vuex.store({
state:{count:0}
})
//state就存放了count:0这个状态
那么,在组件中我们如何获取vuex中的数据呢?
因为之前我们将store挂载到vue的组件实例上,所以类似于store.state.key`获取vuex存放的状态
<template>
<div>
<div>store中的数据 {{ $store.state.count }}</div>
<hr />
</div>
</template>
但是,一直$store.state.xx太复杂了,我们可以配合计算属性这么来写:
<template>
<div>
<div>store中的数据 {{ count }}</div>
<hr />
</div>
</template>
<script>
computed: {
//模板中即可以直接调用
count() {
return this.$store.state.count
}
}
</script>
而且,vuex官方提供了一个mapState方法,通过扩展运算符,可以帮助我们快速定义多个计算属性,减少我们的开销成本
<script>
import { mapState } from 'vuex'
computed: {
...mapState(['count'])
}
</script>
既然可以获取到数据,自然也就可以修改,那么我们定义一个方法来修改state中的count:
<template>
<div>
<div>store中的数据 {{ $store.state.count }}</div>
<button @click="changeCount">修改count</button>
<hr />
</div>
</template>
methods: {
changeCount() {
/* 虽然成功修改了store中的数据,但是vue开发者工具并没有追踪到 */
this.$store.state.count++
}
},
可以从图中看到,虽然页面中的数据响应式的变化了,但是vue的开发者工具并没有追踪到。
因此,vuex官方规定了,不可以直接修改store中的状态,mutation是修改状态的唯一手段,接下来我们介绍mutation
Mutation
更改 Vuex 的 store 中的状态的唯一方法是提交 mutation。Vuex 中的 mutation 非常类似于事件:每个 mutation 都有一个字符串的 事件类型 (type) 和 一个 回调函数 (handler)。这个回调函数就是我们实际进行状态更改的地方,并且它会接受 state 作为第一个参数:
const store = new Vuex.Store({
state: {
count: 1
},
mutations: {
increment (state,payload) {
// 变更状态
state.count++
}
}
})
那么我们如何触发这个mutation呢?可以通过$store.commit(key,payload)方法提交mutation,同时可以携带额外的参数。
<script>
changeCount() {
this.$store.commit('increment',{c:1})
}
<script>
这时候,我们就可以发现vue开发者工具完美监测到了store中状态的改变。
同样的,vuex官方提供了mapMutations方法来方便我们的日常开发。
import { mapMutations, mapState } from 'vuex'
methods: {
...mapMutations(['increment']),
changeCount() {
this.increment()
}
},
//将 `this.increment()` 映射为 `this.$store.commit('increment')`
重点:Vuex中,mutation中都是同步操作,不推荐在mutation中进行异步操作。例如,当你调用了两个包含异步回调的 mutation 来改变状态,你怎么知道什么时候回调和哪个先回调呢?
为了处理异步操作,让我们来看一看 Action。
Action
Action类似于mutation,但是它的作用是提交mutation,并不是直接修改store中的状态。
让我们先创建一个action:
const store = new Vuex.Store({
state: {
count: 0
},
mutations: {
increment (state) {
state.count++
}
},
actions: {
add (context) {
context.commit('increment')
}
}
})
//在组件中通过dispatch方法调用
methods:{
changeCount() {
this.$store.dispatch('add')
}
}
Action 函数接受一个与 store 实例具有相同方法和属性的 context 对象,可以通过commit提交mutation,也可以继续dispatch派发一个新的action
乍一眼看上去感觉多此一举,我们直接分发 mutation 岂不更方便?实际上并非如此,还记得 mutation 必须同步执行这个限制么?Action 就不受约束!我们可以在 action 内部执行异步操作:
actions: {
incrementAsync ({ commit }) {
setTimeout(() => {
commit('increment')
}, 1000)
}
}
如果要进行多个异步操作,那么该如何处理呢,async、await这时候就大显身手了
actions: {
async actionA ({ commit }) {
const data1 = await getData()
commit('gotData',data1)
},
async actionB ({ dispatch, commit }) {
await dispatch('actionA') // 等待 actionA 完成
const data2 = await getOtherData()
commit('gotOtherData',data2)
}
}
组件中 this.$store.dispatch('actionB')
Getters
Getters其实就类似于vue的计算属性,可以对store中的state状态进行处理,同样的getter 的返回值会根据它的依赖被缓存起来,且只有当它的依赖值发生了改变才会被重新计算。
同样的,Getter 接受 state 作为其第一个参数,且vuex提供了mapGetters方法方便我们使用
const store = new Vuex.Store({
state: {
todos: [
{ id: 1, text: '...', done: true },
{ id: 2, text: '...', done: false }
]
},
getters: {
doneTodos: state => {
return state.todos.filter(todo => todo.done)
}
}
})
Getter 也可以接受其他 getter 作为第二个参数:
getters: {
// ...
getLength: (state, getters) => {
return getters.getUserName.length
}
}
//组件调用
computed: {
doneTodosCount () {
return this.$store.getters.doneTodosCount
}
}
//mapGetters
import { mapGetters } from 'vuex'
export default {
// ...
computed: {
// 使用对象展开运算符将 getter 混入 computed 对象中
...mapGetters([
'doneTodosCount',
'anotherGetter',
// ...
])
}
}
Module
由于使用单一状态树,应用的所有状态会集中到一个比较大的对象。当应用变得非常复杂时,store 对象就有可能变得相当臃肿。
为了解决以上问题,Vuex 允许我们将 store 分割成模块(module)。每个模块拥有自己的 state、mutation、action、getter、甚至是嵌套子模块——从上至下进行同样方式的分割:
首先,我们可以创建store/home/home.js 、store/user/user.js
const state = {
name: "user模块",
};
const mutations = {};
const getters = {};
const actions = {};
export default {
state,
mutations,
getters,
actions,
};
在store/index.js中注册模块。注意,这种模式下模块内部的 action、mutation 和 getter 是注册在全局命名空间的——这样使得多个模块能够对同一 mutation 或 action 作出响应。
const store = new Vuex.Store({
state: {
},
getters: {
},
mutations: {
},
actions: {
},
modules: {
home: homeModule,
user: userModule
}
})
如果希望你的模块具有更高的封装度和复用性,你可以通过添加 namespaced: true 的方式使其成为带命名空间的模块。当模块被注册后,它的所有 getter、action 及 mutation 都会自动根据模块注册的路径调整命名。
const store = new Vuex.Store({
modules: {
account: {
namespaced: true,
// 模块内容(module assets)
state: () => ({ ... }), // 模块内的状态已经是嵌套的了,使用 `namespaced` 属性不会对其产生影响
getters: {
isAdmin () { ... } // -> getters['account/isAdmin']
},
actions: {
login () { ... } // -> dispatch('account/login')
},
mutations: {
login () { ... } // -> commit('account/login')
},
// 嵌套模块
modules: {
// 继承父模块的命名空间
myPage: {
state: () => ({ ... }),
getters: {
profile () { ... } // -> getters['account/profile']
}
},
// 进一步嵌套命名空间
posts: {
namespaced: true,
state: () => ({ ... }),
getters: {
popular () { ... } // -> getters['account/posts/popular']
}
}
}
}
}
})
之前的mapState、mapGetters、mapMutations、mapActions都需要换个写法
methods: {
...mapMutations('模块名',['INCREMENT']),
...mapActions('模块名',['increment']),
},
computed:{
...mapState('模块名',['对应的状态key']),
...mapGetters('模块名',['对应的getter方法名'])
}
模块的局部状态
对于模块内部的 mutation 和 getter,接收的第一个参数是模块的局部状态对象,即当前模块的state。
如user模块,
同样,对于模块内部的 action,我们可以输出打印下第一个参数。
局部状态通过 context.state 暴露出来,根节点状态则为 context.rootState。
对于模块内部的 getter,根节点状态会作为第三个参数暴露出来:
const moduleA = {
// ...
getters: {
sumWithRootCount (state, getters, rootState) {
return state.count + rootState.count
}
}
}
Pinia
Pinia,这个大菠萝实际上是对vuex的一种优化,vuex5.0版本的替代品。与 Vuex 相比,Pinia 不仅提供了一个更简单的 API,也提供了符合组合式 API 风格的 API,最重要的是,搭配 TypeScript 一起使用时有非常可靠的类型推断支持。
pinia的优点
相信各位用vuex的时候已经感受到了代码的繁琐,派发了action后,还需要commit提交到mutations里,然后再修改state状态。划分模块后,我们还需要记住对应的模块名,才能找到对应模块的action等等。这一串步骤下来,让我们身心俱疲。而pinia的出现,正是为了改变这个状况。
- 弃用了mutation
- 不再具有嵌套结构的模块,无需动态添加store,它们默认都是动态的。
- 不再有可命名的模块。考虑到 Store 的扁平架构,Store 的命名取决于它们的定义方式。
相信看到这里,各位同学已经跃跃欲试了,既然如此,让我们出发吧!
pinia的使用
安装
用你喜欢的包管理器安装 pinia:
yarn add pinia
# 或者使用 npm
npm install pinia
创建一个pinia实例,并通过app.use()注册使用
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
app.mount('#app')
使用
import { defineStore } from 'pinia'
// 你可以对 `defineStore()` 的返回值进行任意命名,但最好使用 store 的名字,同时以 `use` 开头且以 `Store` 结尾。(比如 `useUserStore`,`useCartStore`,`useProductStore`)
// 第一个参数是你的应用中 Store 的唯一 ID。
export const useCounterStore = defineStore('counter', {
state:()=>{
return {}
},
actions:{},
getters:{}
})
defineStore会返回一个方法,通过调用该方法,就会生成对应的store实例。
<script setup>
import { useCounterStore } from '@/stores/counter'
// 可以在组件中的任意位置访问 `store` 变量 ✨
const store = useCounterStore()
</script>
一旦 store 被实例化,你可以直接访问在 store 的 state、getters 和 actions 中定义的任何属性。
但是,请注意,store 是一个用 reactive 包装的对象,这意味着不需要在 getters 后面写 .value,就像 setup 中的 props 一样,如果你写了,我们也不能解构它:
<script setup>
const store = useCounterStore()
// ❌ 这将不起作用,因为它破坏了响应性
// 这就和直接解构 `props` 一样。
const { name, doubleCount } = store
name // 将始终是 "Eduardo",永远不会改变
doubleCount // 将始终是 0
</script>
为了从 store 中提取属性时保持其响应性,我们可以使用 storeToRefs(),就像对props解构一样,我们使用toRefs()。它将为每一个响应式属性创建引用。
<script setup>
import { storeToRefs } from 'pinia'
const store = useCounterStore()
const { name, doubleCount } = storeToRefs(store)
但是,我们需要注意的是,我们是可以直接从store中解构action的。函数可不是响应式变量,它也是直接绑定在store实例上的
const { increment } = store
注意点
//这种使用方式只能在组件中使用,因为pinia会在beforeCreated生命周期创建
const store = useCounterStore()
如果想在非组件文件中获取相关store中的数据,需要导入pinia实例对象并传入
//xxx.ts
import pinia from '@/store/index'
let userStore = useUserStore(pinia)
核心
state
在 Pinia 中,state 被定义为一个返回初始状态的函数(不再是对象形式)。这使得 Pinia 可以同时支持服务端和客户端。
import { defineStore } from 'pinia'
const useStore = defineStore('storeId', {
// 为了完整类型推理,推荐使用箭头函数
state: () => {
return {
// 所有这些属性都将自动推断出它们的类型
count: 0,
name: 'Eduardo',
}
},
})
一般情况下,不需要做太多努力就能使你的 state 兼容 TS。 Pinia 会自动推断出你的 state 的类型。如果你愿意,你可以用一个接口定义 state,并添加 state() 的返回值的类型。
interface State {
userList: UserInfo[]
user: UserInfo | null
}
interface UserInfo {
name: string
age: number
}
const useStore = defineStore('storeId', {
state: (): State => {
return {
userList: [],
user: null,
}
},
})
访问state变量
在使用这一章节里,我曾介绍过,store是一个 reactive包裹的响应式对象,所以无需通过.value调用,可以直接进行读写操作。
const store = useStore()
store.count++
同时,pinia也提供了一个api,让我们重置state中的状态
const store = useStore()
//它会恢复到初始值
store.$reset()
Getter
Getter 完全等同于 store 的 state 的计算属性。可以通过 defineStore() 中的 getters 属性来定义它们。推荐使用箭头函数,并且它将接收 state 作为第一个参数:
export const useStore = defineStore('main', {
state: () => ({
count: 0,
}),
getters: {
doubleCount: (state) => state.count * 2,
},
})
大多数时候,getter 仅依赖 state,不过,有时它们也可能会使用其他 getter。因此,即使在使用常规函数定义 getter 时,我们也可以通过 this 访问到整个 store 实例。注意,如果使用this访问store,那么为了避免ts报错,需要明确返回值类型。
export const useStore = defineStore('main', {
state: () => ({
count: 0,
}),
getters: {
// 自动推断出返回类型是一个 number
doubleCount(state) {
return state.count * 2
},
// 返回类型**必须**明确设置
doublePlusOne(): number {
// 整个 store 的 自动补全和类型标注 ✨
return this.doubleCount + 1
},
},
})
Actions
Action 相当于组件中的 method。它们可以通过 defineStore() 中的 actions 属性来定义,**并且它们也是定义业务逻辑的完美选择。**和getters一样,它也可以内部通过this访问到store实例。不同的是,action 可以是异步的,你可以在它们里面 await 调用任何 API,以及其他 action。
export const useCounterStore = defineStore('main', {
state: () => ({
count: 0,
}),
actions: {
increment() {
this.count++
},
randomizeCounter() {
this.count = Math.round(100 * Math.random())
},
},
})
如果想要访问另一个store的话,直接在actions里调用就行。
import { useAuthStore } from './auth-store'
export const useSettingsStore = defineStore('settings', {
state: () => ({
preferences: null,
// ...
}),
actions: {
async fetchUserPreferences() {
const auth = useAuthStore()
if (auth.isAuthenticated) {
this.preferences = await fetchPreferences()
} else {
throw new Error('User must be authenticated')
}
},
},
})