状态管理工具

123 阅读8分钟

欢迎各位

坐在屏幕前,阅读这本小册的读者,你们好~~~。作者知道,关于全局状态管理工具的介绍到处都有,但它们却极其的分散,极少有人对其进行总结。因此,在本小册中,作者由浅入深,从状态管理的本质,案例,到市面上常见的各种状态管理工具,以幽默诙谐的语气,向各位缓缓道来。请各位同学不要急躁,以小说的方式进行阅读,我相信各位同学阅读完,一定会有收获。

最简单的状态管理(window)

我们在开发时,两个组件想要共享数据,最简单的方法是什么?props,emit父子传值?eventBus?不,都不是。最简单的方式其实是将数据挂载在window上。因为window是顶级对象,任何地方都可以通过window.xxx进行获取对应的变量。但为何我们不用这种方式呢?你想想,如果遇到一个变量,就挂载到window上,那么项目一大,组件一多,window上挂载了几百个变量,这是多么可怕的事情。你如何区分上面哪个变量是当前组件需要使用的?至于localStorage、sessionStorage等等h5新增的浏览器本地存储方式也是一样的道理,它们的功能很强大,如果只是用于共享组件间数据,那可谓是得不偿失。

常见的组件之间通信方式

image-20230521182548527

各位同学请看上面这张图,如果组件C有数据,count:1想要传递给组件A和组件B,我们可以通过props父子组件通信,很轻松的实现,如果想要传递给组件G,也顶多在透传一次。但是如果组件G内还有组件H,组件I呢?我们一层层的传递下去多麻烦。这是props的一个缺点。其次,如果组件D,组件E也需要 组件Ccount:1这个数据,那该怎么办?聪明的同学肯定想到了,状态提升!!! 把 count:1这个数据放到最外层的组件容器上,然后传递给组件C、组件D、组件E等等需要使用的组件。这是个不错的方法!但是吧,一层层的下去,还是觉得好麻烦,而且所有数据都放在外面那个大的容器组件上,那数据一多,不就又回到了之前的那个问题了吗?

所以,让我们来了解下大佬们提出的解决方案,状态管理

状态管理

什么是状态管理?举个例子。小明和小王是好朋友,有一天,小明想寄个礼物给小王,这时候小明就需要把礼物交付给驿站,由驿站打电话去通知小王收礼物。小王收到以后,也想要回送礼物,也需要将礼物放到驿站,然后由驿站通知小明前去取件。这其中的礼物就可以理解为状态。小明和小王就是两个订阅者,而驿站就是发布者。状态管理本质上都是基于发布订阅者模式实现的,请各位同学,熟记这三个专有名词,因为它们在后面会常常出现。

在状态管理工具(如vuex、redux)中,状态是以一个对象树的形式存在的。这个对象树中的每个属性都代表着应用程序的一个状态。我们可以在任意一个组件,获取或修改全局任意状态,从而实现任意组件间的通信

image-20230424210826081

下面我先介绍两个最常用的全局状态管理工具vuex、redux。

Vuex

Vuex的优势所在

为什么要使用vuex呢?换句话来说vuex有什么好处?

  1. vuex可以集中管理共享的数据,便于开发和后期的维护
  2. 能够高效地实现组件之间的数据共享,即任意组件通信
  3. 存储在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的工作原理

image-20230424211628016

该图,是作者从vuex的官方文档那截下来的,可以很清除的看到vuex分出了三个重要核心,statemutationsactions。箭头指向也清楚的阐明了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的组件实例上,所以类似于router,通过router,通过`store.state.key`获取vuex存放的状态

<template>
  <div>
    <div>store中的数据 {{ $store.state.count }}</div>
    <hr />
  </div>
</template>

image-20230425200324415

但是,一直$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的开发者工具并没有追踪到。

image-20230425200620790image-20230425200626574

因此,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中状态的改变。

image-20230425202203750

image-20230425202214513

同样的,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
  }
})

image-20230425205038239

如果希望你的模块具有更高的封装度和复用性,你可以通过添加 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模块,image-20230425205746103

同样,对于模块内部的 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 的 stategettersactions 中定义的任何属性。

但是,请注意,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')
      }
    },
  },
})