重学vue(2, 3)及其生态+TypeScript 之 vuex4.x

952 阅读9分钟

什么是状态管理

在开发中,我们会的应用程序需要处理各种各样的数据,这些数据需要保存在我们应用程序中的某一个位置,对于这些数据的管理我们就称之为是 状态管理。

在vue项目中我们是如何管理自己的状态呢?

  1. 在Vue开发中,我们使用组件化的开发方式。而在组件中我们定义data或者在setup中返回使用的数据,这些数据我们称之为state。
  2. 在模块template中我们可以使用这些数据,模块最终会被渲染成DOM,我们称之为View。
  3. 在模块中我们会产生一些行为事件,处理这些行为事件时,有可能会修改state,这些行为事件我们称之为actions。

复杂的状态管理

JavaScript开发的应用程序,已经变得越来越复杂了。JavaScript需要管理的状态越来越多,越来越复杂。这些状态包括服务器返回的数据、缓存数据、用户操作产生的数据等等。也包括一些UI的状态,比如某些元素是否被选中,是否显示加载动效,当前分页。当我们的应用遇到多个组件共享状态时,单向数据流的简洁性很容易被破坏。多个视图依赖于同一状态。来自不同视图的行为需要变更同一状态。

我们是否可以通过组件数据的传递来完成呢?

对于一些简单的状态,确实可以通过props的传递或者Provide的方式来共享状态。

但是对于复杂的状态管理来说,显然单纯通过传递和共享的方式是不足以解决问题的。比如兄弟组件如何共享数据呢?

当然可以通过事件总线的方式传递数据。但是状态多了,也不好管理。所以我们就需要vue官方提供的状态管理库vuex。

Vuex的状态管理

下面这张图就可以概括vuex的一切了。 image.png 如果您不打算开发大型单页应用,您最好不要使用 Vuex。就足够您所需了。但是,如果您需要构建一个中大型单页应用,您很可能会考虑如何更好地在组件外部管理状态,Vuex 将会成为自然而然的选择。

vuex使用

下面我们就来介绍vuex的使用了。

这里我们介绍的是vuex4.x的版本,所以需要安装 npm install vuex@next --save

每一个Vuex应用的核心就是store(仓库):store本质上是一个容器,它包含着你的应用中大部分的状态(state)。

Vuex和单纯的全局对象有什么区别呢?

  • Vuex的状态存储是响应式的
    • 当Vue组件从store中读取状态的时候,若store中的状态发生变化,那么相应的组件也会被更新。
  • 你不能直接改变store中的状态
    • 改变store中的状态的唯一途径就显示提交 (commit) mutation。
    • 而且异步数据,需要在action中提交mutation, 在项目中通过dispatch将数据提交到action。 这样使得我们可以方便的跟踪每一个状态的变化,从而让我们能够通过一些工具帮助我们更好的管理应用的状态。

vux的基本使用

  • 通过createStoreAPI创建一个store对象,用于保存数据。
  • 在app.use中注册这个store对象。 下面我们来介绍vuex中的5个核心

state

用于定义项目的共享数据。参数为对象或者函数。我们建议使用函数作为state的值。

    const store = createStore({
      state() {
        return {
          rootCounter: 100
        }
      }
    })

我们项目中获取到定义的state呢?

  • 在template中,我们自己通过$store.state即可获取state的值。
  • 在optionsAPI中,我们可以通过this.$store.state获取state的值。
  • 在compositionAPI中,我们可以通过vuex提供的useStoreAPI获取到state的值。 一般我们都会都会将值放在computed中。
computed: {
    name() {
      return this.$store.name
    }
}

如果我们有很多数据需要在store中获取,直接通过上述方法取值,比较繁琐,所以我们可以通过vuex提供的对应的map方法,mapState

mapState做了什么呢?

他就是将store对象中对应的state做一层映射。他可以传入一个数组或者一个对象。并且返回一个对象,里面是计算函数。

  • 传入数组时,我们将state中的状态作为数组中的元素(字符串)
    // 直接使用
    <h2>Home:{{ age }}</h2>
    <h2>Home:{{ name }}</h2>
    
    const store = createStore({
      state() {
        return {
          name: 'zh',
          age: 20
        }
      }
    })
  
    computed: {
      ...mapState(["name", "age"])
    }

image.png

  • 传入对象时,我们可以指定对应state状态的名字。防止和组件本身的data数据名同名。
    <h2>Home:{{ sAge }}</h2>
    <h2>Home:{{ sName }}</h2>
    
    const store = createStore({
      state() {
        return {
          name: 'zh',
          age: 20
        }
      }
    })
  
    computed: {
       ...mapState({
          sAge: (state) => state.age,
          sName: (state) => state.name,
       })
    }

image.png 我们发现,在optionsAPI的computed中使用mapState非常简单,但是我们知道在compositionAPI的computed需要传入一个函数返回的是一个计算属性的值。这样mapState在其中就不好使用了。

下面我们来看看情况

    <h2>{{storeState.age}}</h2>
    <h2>{{storeState.name}}</h2>
    <h2>{{storeState.counter}}</h2>
    
    const storeState = computed(() => ({
      ...mapState(['counter', 'name', 'age']),
    }))

image.png 从上面可以看出storeState中的属性都是一个个函数。

这是为什么呢?

因为mapState返回的是一个对象,他的属性就是返回的一个个计算函数。

可能有人会说,那么我们就在template使用的时候当成函数调用就行了啊。哈哈,我们来试试。

    <h2>{{storeState.age()}}</h2>
    <h2>{{storeState.name()}}</h2>
    <h2>{{storeState.counter()}}</h2>

image.png 看到这,还有人不死心,说可以在调用的时候绑定this啊,将store对象绑定到该函数中啊。那确实。再来试试。

    <h2>{{storeState.age.call({$store: store})}}</h2>
    <h2>{{storeState.name.call({$store: store})}}</h2>
    <h2>{{storeState.counter.call({$store: store})}}</h2>
    
    setup() {
        const store = useStore()

        const storeState = computed(() => ({
          ...mapState(['counter', 'name', 'age']),
        }))
        return {
          storeState,
          store,
        }
   }

image.png 这时候就可以展示出具体内容了。不容易啊。可是这样的实现,还不如直接通过store.state直接一个个取出数据呢。所以我们需要封装一个hook。步骤其实就是上面的实现过程。

    import { useStore, mapState } from 'vuex'
    import { computed } from 'vue'

    const useState = function(mapper) {
        // mapper: Array | Object
        const store = useStore()

        //将返回一个对象
        const storeStateFns = mapState(mapper)
        // 用于存放获取到的state.属性: ref对象 键值对
        const storeState = {}

        Object.keys(storeStateFns).forEach(item => {
            // 这我们知道辅助函数的内部是通过this.$store来实现的
            // setup中没有this, 所以通过bind来改变this的指向
            const fn = storeStateFns[item].bind({$store: store})
            //将最后的值放在storeState中
            storeState[item] = computed(fn)
        })

        return storeState
    }

    export default useState

测试hook

    <hr>数组
    <h2>{{counter}}</h2>
    <h2>{{name}}</h2>
    <h2>{{age}}</h2>
    <hr>对象
    <h2>{{sAge}}</h2>
    <h2>{{sCounter}}</h2>
    <h2>{{sName}}</h2>
    <hr>
    
    setup() {
      const storeState = useState(["counter", "name", "age"])
      const storeState2 = useState({
        sCounter: state => state.counter,
        sName: state => state.name,
        sAge: (state) => state.age
      })

      return {
        ...storeState,
        ...storeState2
      }
    }

image.png

getters

有时候我们需要从 store 中的 state 中派生出一些状态。例如计算列表的长度,可以在多个地方进行复用。我们就可以在getters中定义,他的作用就好像计算属性computed。但是这个缓存好像有问题。 image.png

const store = createStore({ 
    getters: {
        doubleRootCounter (state) {
          return state.age * 2
        }
    }
})

getters中定义的方法可以接受state作为一个参数

  • 我们主要是为了处理state中的状态。
   state () {
     return {
       counter: 100,
       name: "zh",
       age: 20,
       books: [
         { name: "深入Vuejs", price: 200, count: 3 },
         { name: "深入Webpack", price: 240, count: 5 },
         { name: "深入React", price: 130, count: 1 },
         { name: "深入Node", price: 220, count: 2 },
       ],
       discount: 0.6,
       banners: []
     };
   },
   getters: {
     currentDiscount(state) {
       return state.discount * 0.9
     }
   }

getters中定义的方法也可以接受另一个参数getters

  • 主要是为了结合其他的getter做一些事情。 下面这个例子是结合当前的折扣,来计算总价格
   getters: {
     totalPrice(state, getters) {
       let totalPrice = 0
       for (const book of state.books) {
         totalPrice += book.count * book.price
       }
       return totalPrice * getters.currentDiscount
     }
   }

如果我们想要使用外界传入的数据,来结合state中的状态,我们可以让getter返回一个函数,并将外界传入的数据作为这个函数的参数。

下面这个例子是让外界传入一个整数,来过滤计算的总价格

  getters: {
    totalPriceCountGreaterN(state, getters) {
      return function(n) {
        let totalPrice = 0
        for (const book of state.books) {
          if (book.count > n) {
            totalPrice += book.count * book.price
          }
        }
        return totalPrice * getters.currentDiscount
      }
    }
  }

我们定义了上面的getters方法,如何在项目中用起来呢?

  • 在template中,直接通过 $store.getters获取即可。
    <h2>总价值: {{ $store.getters.totalPrice }}</h2>
    <h2>总价值: {{ $store.getters.totalPriceCountGreaterN(1) }}</h2>
  • 在optionsAPI中,我们通过this.$store.getters获取即可。
    computed: {
        totalPrice() {
          return this.$store.getters.totalPrice
        }
    }
  • 在compositionAPI中,我们通过vuex提供的useStoreAPI来获取。
  setup() {
    const store = useStore()
    const sGetter = computed(() => store.getters.totalPrice)
    return {
      sGetter,
    }
  }

同获取state一样,如果我们有很多getter需要在store中获取,直接通过上述方法取值,比较繁琐,所以我们可以通过vuex提供的对应的map方法,mapGetters

他的使用同mapState一样,可以传入数组或者对象,在optionsAPI中的computed使用不会出现问题。

    <h2>{{ sNameInfo }}</h2>
    <h2>{{ sAgeInfo }}</h2>
    <h2>{{ ageInfo }}</h2>
    <h2>{{ heightInfo }}</h2>
    
  getters: {
    nameInfo (state) {
      return `name: ${state.name}`
    },
    ageInfo (state) {
      return `age: ${state.age}`
    }
  },
   computed: {
    ...mapGetters(['nameInfo', 'ageInfo']),
    ...mapGetters({
      sNameInfo: 'nameInfo',
      sAgeInfo: 'ageInfo',
    }),
  }

对应的我们可以借鉴对mapState封装的一个hook。我们只需要给mapState改成mapGetters即可。

import { useStore, mapGetters } from 'vuex'
import { computed } from 'vue'

const useGetters = (mapper) => {
  const store = useStore()

  const storeGetterFns = mapGetters(mapper)

  const storeGetter = {}
  Object.keys(storeGetterFns).forEach((item) => {
    const fn = storeGetterFns[item].bind({ $store: store })
    storeGetter[item] = computed(fn)
  })

  return storeGetter
}

export default useGetters

测试hook


  <h2>{{ nameInfo }}</h2>
  <h2>{{ ageInfo }}</h2>
    
 setup() {
    const storeGetters = useGetters(['nameInfo', 'ageInfo'])
    return {
      ...storeGetters,
    }
  },

image.png

mutations

用于更改 Vuex 的 store 中的状态。每个 mutation 都有一个字符串的 事件类型 (type) 和一个 回调函数 (handler) 。并且回调函数中,可以有以下值。

  • state 为了修改state中的状态,肯定要传入state啊。
  • payload(这个可以自己命名) 用于提供修改状态的数据,他是当我们调用commit函数传递的值。并且可以传入任意数据。
const store = createStore({
  mutations: {
    changeName (state, payload) {
      state.name = payload.name
    }
  }
})

上面定义了mutation,如何在项目中触发呢?

  • 在template中, 通过$store.commit(对应的mutation名, payload)即可。
  • 在optionsAPI中,通过this.$store.commit(对应的mutation名, payload)即可。
<button @click="changeName">改变state.name: {{$store.state.name}}</button>
 methods: {
    changeName() {
      // this.$store.commit('changeName', {name: 'llm'})
      
      this.$store.commit({
        type: 'changeName',
        name: 'llm',
      })
    },
  },
  • 在optionsAPI中,通过vuex提供的useStoreAPI即可。
<button @click="changeName1">改变state.name: {{$store.state.name}}</button>
  setup() {
    const store = useStore()
    const changeName1 = () => {
      store.commit('changeName', {
        name: 'llm',
      })
    }
    return {
      changeName1
    }
  },

从上面的代码示例中,我们可以看出,提交commit有两种方法。

  • 先传入对应的mutation名,在传入对应的数据
  • 直接传入一个对象,且对象中定义一个type属性来指定mutation的名 相应的,vuex也提供了对应的map函数。mapMutations。 终于这次不需要我们自己封装hook,vue2和vue3中使用mapMutations行为是一样的。因为当我们结构mapMutations返回的对象,他们本身就是一个个函数,我们直接调用,并传入对应的payload。
// 我们只需要在template中触发对应的method时传入对应的payload
<button @click="updateName({name: 'llm'})">改变state.name: {{$store.state.name}}</button>
<button @click="updateName({type: 'changeName', name: 'jcl'})">改变state.name: {{$store.state.name}}</button>
    methods: {
      ...mapMutations(['changeName']),
      ...mapMutations({
        updateName: "changeName"
      })
    },
    setup() {
      const storeMutations = mapMutations(['changeName'])

      return {
        ...storeMutations
      }
    }

mutation触发.gif 一条重要的原则就是要记住 mutation 必须是同步函数。这是因为devtool工具会记录mutation的日记。每一条mutation被记录,devtools都需要捕捉到前一状态和后一状态的快照。但是在mutation中执行异步操作,就无法追踪到数据的变化,所以Vuex的重要原则中要求 mutation必须是同步函数。

actions

Action 类似于 mutation,不同在于:

  • Action 提交的是 mutation,而不是直接变更状态。变更状态依旧是在mutations中。
  • Action 可以包含任意异步操作。 actions中的回调函数第一个参数不再是state,而是一个store对象并且增加了rootState和rootDetters属性,一般命名为context。它里面存在着以下属性。
  • commit, 用于提交mutation,触发mutations中对应的回调函数。
  • dispatch, 我们可能还会触发对应的actions回调函数。
  • getters, 为了获取当前模块中的getters。
  • rootGetters, 为了获取根模块的getters。当我们设置了modules选项时,就会有用。
  • rootState, 为了获取根模块的state。当我们设置了modules选项时,就会有用。
  • state, 获取当前模块的state。 actions回调函数还可以传入第二个参数,让用户调用dispatch时,传入的数据。

下面我们就来看看如何使用actions吧。

const store = createStore({
   mutations: { 
     changeAge (state, payload) {
       state.age = payload.age
     }
   }
   actions: {
    changeAge (context, payload) {
      // 异步提交commit
      setTimeout(() => {
        context.commit('changeAge', payload)
      }, 1000);
    },
})

上面定义了action,如何在项目中触发呢?

  • 在template中, 通过$store.dispatch(对应的action名, payload)即可。
  • 在optionsAPI中,通过this.$store.dispatch(对应的dispatch名, payload)即可。
<button @click="changeAge">异步改变state.age {{$store.state.age}}</button>

 methods: {
    changeAge() {
      this.$store.dispatch('changeAge', {
        age: 999,
      })
    },
  }
  • 在optionsAPI中,通过vuex提供的useStoreAPI即可。
<button @click="changeAge1">异步改变state.age {{$store.state.age}}</button>

  setup() {
    const store = useStore()
    const changeAge1 = () => {
      store.dispatch('changeAge', {
        age: 1000,
      })
    }
    return {
      changeAge1
    }
  }

同样,vuex也提供了对应的map方法,mapActions。使用同mapMutions

    methods: {
      ...mapActions(['changeAge']),
      ...mapActions({
        updateAge: "changeAge"
      })
    }
    
    setup() {
      const actions = mapActions(['changeAge'])
      const actions2 = mapActions({
        updateAge: "changeAge"
      })

      return {
        ...actions,
        ...actions2
      }
    }

重点:当我们通过dispatch分发action时,我们可以结合async await 给action回调返回一个promise对象。用于在组件中知道异步请求的状态。

modules

什么是Module?

由于使用单一状态树,应用的所有状态会集中到一个比较大的对象,当应用变得非常复杂时,store 对象就有可能变得相当臃肿。

为了解决以上问题,Vuex 允许我们将 store 分割成模块(module)。每个模块拥有自己的 state、mutation、action、getter、甚至是嵌套子模块。

具体用法请看官网:next.vuex.vuejs.org/zh/guide/mo…

下面介绍一下使用事项:

  • 获取子模块中的state数据$store.state.模块名.数据
    $store.state.home.homeCounter
  • 模块中的getters中的回调可以接受4个参数,分别是state, getters, rootState, rootGetters。可以获取当前模块中的state, getters, 父模块中的state, getters。 image.png
  • 我们一般使用模块的时候,都需要在定义模块的时候加上namespaced: true。不然模块内部的 action 和 mutation 仍然是注册在全局命名空间的——这样使得多个模块能够对同一个 action 或 mutation 作出响应。

下面测试,如果我们不加命名空间时

    <h1>{{$store.state.rootCounter}}</h1>
    <h1>{{$store.state.home.homeCounter}}</h1>
    <button @click="increment">触发increment commit函数</button>
    
      methods: {
        increment() {
          this.$store.commit('increment')
        }
      }

modules.gif 他会触发子模块和根模块中的对应的mutations回调函数。 但是我们加上命名空间时,就不会出现这样的问题。因为vuex内部会自动将触发回调的函数名改成子模块/对应的mutation名

    <h1>rootCounter: {{$store.state.rootCounter}}</h1>
    <h1>homeCounter: {{$store.state.home.homeCounter}}</h1>
    <button @click="increment">触发increment commit函数</button>
    <button @click="homeIncrement">触发increment commit函数</button>
    
  methods: {
    homeIncrement() {
      this.$store.commit('home/increment')
    },
    increment() {
      this.$store.commit('increment')
    },
  },

之modules.gif

  • 当我们触发action时,基本和mutation是一样的。只是我们在触发子模块对应的action的时候,可以指定也触发父模块同名的action。
    <h1>rootCounter: {{$store.state.rootCounter}}</h1>
    <h1>homeCounter: {{$store.state.home.homeCounter}}</h1>
    <button @click="homeIncrementAction">触发increment dispatch函数</button>
    
actions: {
    incrementAction ({ commit, dispatch, state, rootState, getters, rootGetters }) {
      // 指定触发当前模块的increment mutation
      commit("increment")
      // 指定触发父模块的increment mutation
      commit("increment", null, { root: true })
    }
  }
  
  methods: {
    homeIncrementAction() {
      this.$store.dispatch('home/incrementAction')
    }
  }

之modules-action.gif

  • 如果想要在项目中通过对应的map方法,来映射对应模块中的state, getters, mutations, actions我们有很多方法。但是这种方式是比较方便的。createNamespacedHelpers(namespace), 创建基于命名空间的组件绑定辅助函数。其返回一个包含 mapStatemapGettersmapActions 和 mapMutations 的对象。它们都已经绑定在了给定的命名空间上。

在optionsAPI中使用都是没有问题的。下面我们基于上面开发的hook函数来看在compositionAPI中的使用。

修改useState hook

 import { useStore, mapState,  createNamespacedHelpers} from 'vuex'
 import { computed } from 'vue'

    const useState = function(mapper, moduleName) {
        // mapper: Array | Object
        const store = useStore()
        // 先将vuex提供的mapState赋值给mapperFn,它用于映射对应模块的store.state
        let mapperFn = mapState 
        // 看其是否传入了moduleName, 没有就是使用默认的mapState
        if (typeof moduleName === 'string' && moduleName.length > 0) {
          mapperFn = createNamespacedHelpers(moduleName).mapState 
        }
        //将返回一个对象
        const storeStateFns = mapperFn(mapper)
        // 用于存放获取到的state.属性: ref对象 键值对
        const storeState = {}

        Object.keys(storeStateFns).forEach(item => {
            // 这我们知道辅助函数的内部是通过this.$store来实现的
            // setup中没有this, 所以通过bind来改变this的指向
            const fn = storeStateFns[item].bind({$store: store})
            //将最后的值放在storeState中
            storeState[item] = computed(fn)
        })

        return storeState
    }
    export default useState

修改useGetters hook

    import { useStore, mapGetters, createNamespacedHelpers } from 'vuex'
    import { computed } from 'vue'

    const useGetters = (mapper, moduleName) => {
      const store = useStore()

    // 先将vuex提供的mapState赋值给mapperFn,它用于映射对应模块的store.state
    let mapperFn = mapGetters 
    // 看其是否传入了moduleName, 没有就是使用默认的mapState
    if (typeof moduleName === 'string' && moduleName.length > 0) {
      mapperFn = createNamespacedHelpers(moduleName).mapGetters 
    }

      const storeGetterFns = mapperFn(mapper)

      const storeGetter = {}
      Object.keys(storeGetterFns).forEach((item) => {
        const fn = storeGetterFns[item].bind({ $store: store })
        storeGetter[item] = computed(fn)
      })

      return storeGetter
    }

    export default useGetters

从上面可以看出,两个hook实现逻辑基本一样,只是调用的内部API不同,如果有想法封装一下,自己发挥。

下面就来测试一下

    <h1>rootCount_state: {{rootCounter}}</h1>
    <h1>homeCounter_state: {{homeCounter}}</h1>
    <h1>doubleHomeCounter_getters: {{doubleHomeCounter}}</h1>
    <h1>doubleRootCounter_getters: {{doubleRootCounter}}</h1>
    
    setup() {
        return {
          ...useState(['homeCounter'], 'home'),
          ...useState(['rootCounter']),
          ...useGetters(['doubleHomeCounter'], 'home'),
          ...useGetters(['doubleRootCounter'])
        }
    }
// homeModule.js
const homeModule = {
  namespaced: true,
  state () {
    return {
      homeCounter: 1
    }
  },
  getters: {
    doubleHomeCounter (state, getters, rootState, rootGetters) {
      return state.homeCounter * 2
    }
  }
}

export default homeModule
    //store.js
    import { createStore } from "vuex"
    import home from './modules/home'

    const store = createStore({
      state () {
        return {
          rootCounter: 100
        }
      },
      getters: {
        doubleRootCounter (state) {
          return state.rootCounter * 2
        }
      }
      modules: {
        home
      }
    });

    export default store;

image.png