Vue3+TS 优雅地使用状态管理

5,322 阅读6分钟

Vue3的引入了新特性Composition Api,相比于Vue2的代码出现了重大变化,也为状态管理方式提供了新的途径。而Vue3中利用typescript的全面加持则可以让我们更优雅地使用状态管理。

传统状态管理 - Vuex

示例

Vuex作为Vue官方出品的经典,支持vue-devtools调试工具(目前6.0.0 beta7版本还不支持,但未来肯定会支持),依旧是全局状态管理的首选项。尤其是有了TS的加持,state用起来更加丝滑。下面是一个简单的实例:

// store/modules/test.ts
import { Module } from 'vuex'

export interface User {
  name: string
}

export interface TestState {
  users: User[]
}

export default {
  state: {
    users: [{ name: 'sps' }]
  },
  getters: {
    userCount (state) {
      return state.users.length
    }
  },
  mutations: {
    ADD_USER (state, user: User) {
      state.users.push(user)
    }
  },
  namespaced: true
} as Module<TestState, Object>

// store/index.ts
import { createStore } from 'vuex'
import test, { TestState } from './modules/test'

export interface State {
  test: TestState
}

const store = createStore({
  modules: {
    test
  }
})

export default store

// App.tsx
import { defineComponent } from 'vue'
import { useStore } from 'vuex'
import { State } from './store'

export default defineComponent({
  name: 'App',
  setup () {
    const { state: { test }, getters, commit } = useStore<State>()
    const addUser = () => {
      commit('test/ADD_USER', { name: 'haha' })
    }
    return () => {
      const items = test.users.map(user => (
        <li>{ user.name }</li>
      ))
      return (
        <div>
          <div>用户总数: { getters['test/userCount'] }</div>
          <ul>{ items }</ul>
          <button onClick={ addUser }>添加</button>
        </div>
      )
    }
  }
})

使用体验

从上述代码可以看到,对于Vuex模块中的state,TS能够进行完全加持,但是却不能很好地对mutation, action以及getter进行很友好的类型检查,可以看看Vuex源码中的类型定义:

// Vuex源码 type/index.d.ts
// 泛型中S为模块state的类型,R为根state的类型
export interface Module<S, R> {
  namespaced?: boolean;
  state?: S | (() => S);
  getters?: GetterTree<S, R>;
  actions?: ActionTree<S, R>;
  mutations?: MutationTree<S>;
  modules?: ModuleTree<R>;
}

export interface GetterTree<S, R> {
  [key: string]: Getter<S, R>;
}

export interface ActionTree<S, R> {
  [key: string]: Action<S, R>;
}

export interface MutationTree<S> {
  [key: string]: Mutation<S>;
}

key值是通过[key: string]的方式来定义的,所以我们并不能知道 commit 或 dispatch 具体可以执行哪些方法,只能通过commit('test/ADD_USER', { name: 'haha' })这样的方式来使用。而且 mutation,action,getter 中的每一个函数都是统一定义的,没法对单独的每一个函数进行类型定义,所以在执行 commit 或 dispatch 时,并不能根据执行的函数名对其参数进行相应的类型检查。

为了更充分利用ts的类型检查,可以使用vuex-module-decorators

Vuex 装饰器 vuex-module-decorators

笔者在看 vue-vben-admin 源码时发现其用了这个神器后就一直在用。这个神器在Vue2的时代就已经存在了(可惜当时Vue和TS还没有合体),其优势是通过class的方式来定义Vuex模块,可以充分利用TS的类型检查。

用前说明

在使用 vuex-module-decorators 之前有两点需要注意:

  1. 在tsconfig.json中配置,启用装饰器语法:
{
  "compilerOptions": {
    "experimentalDecorators": true
    // ...
  }
}
  1. 创建一个工具函数,用以解决在调试热更新时,动态模块重复注册的问题:
// store/index.ts
export function hotModuleUnregisterModule (name: string) {
  if (!name) return
  if ((store.state as any)[name]) {
    store.unregisterModule(name)
  }
}

示例

使用 vuex-module-decorators 的动态模块功能可以不用在 store 中对模块手动注册,下面是一个简单是使用示例:

// store/modules/test.ts
import { getModule, Module, Mutation, VuexModule } from 'vuex-module-decorators'
import store, { hotModuleUnregisterModule } from '@/store'

const MODULE_NAME = 'test'
hotModuleUnregisterModule(MODULE_NAME)

export interface User {
  name: string
}

@Module({ name: MODULE_NAME, dynamic: true, namespaced: true, store })
export default class UserModule extends VuexModule {
  users: User[] = [{ name: 'sps' }]

  get userCount () {
    return this.users.length
  }

  @Mutation
  ADD_USER (user: User) {
    this.users.push(user)
  }
}

export const testStore = getModule(UserModule)

// App.tsx
import { defineComponent } from 'vue'
import { testStore } from './store/modules/test'

export default defineComponent({
  name: 'App',
  setup () {
    const { users, ADD_USER } = testStore
    return () => {
      // getter解构后不是响应式的,需要在render函数中解构
      const { userCount } = testStore
      const items = users.map(user => (
        <li>{ user.name }</li>
      ))
      return (
        <div>
          <div>用户总数: { userCount }</div>
          <ul>{ items }</ul>
          <button onClick={() => { ADD_USER({ name: 'haha' }) }}>添加</button>
        </div>
      )
    }
  }
})

使用体验

使用 vuex-module-decorators 可以充分利用TS的特性,但是有一个小缺陷。原版的 Vuex 的 getters 是可以接收参数的,但在 class 中的 get 属性是无法传入参数。不过在 getters 中传参数的应用场景不算多,而且也可以通过写工具函数来解决,总之体验还是相当不错的。

Vue3 新特性实现状态管理

VueConf 2021大会上,Vue.js核心团队成员Anthony Fu讲到了利用 Vue3 的 composition Api 中的 reactive 对象可以天然实现状态管理,不用引入任何其他库。在其基础上,将相关操作函数封装到一起就能以 hook 函数的方式来使用。

示例

// store/modules/test.ts
import { computed, reactive } from 'vue'

export interface User {
  name: string
}

export interface TestState {
  users: User[]
}

// reactive对象放到use函数外就可以作为全局状态
const state: TestState = reactive({
  users: [{ name: 'sps' }]
})

export default function useTestStore () {
  const userCount = computed(() => {
    return state.users.length
  })

  const ADD_USER = (user: User) => {
    state.users.push(user)
  }
  
  return {
    state,
    userCount,
    ADD_USER
  }
}

// App.tsx
import { defineComponent } from 'vue'
import useTestStore from './store/modules/test'

export default defineComponent({
  name: 'App',
  setup () {
    const { state, userCount, ADD_USER } = useTestStore()
    return () => {
      const items = state.users.map(user => (
        <li>{ user.name }</li>
      ))
      return (
        <div>
          <div>用户总数: { userCount.value }</div>
          <ul>{ items }</ul>
          <button onClick={() => { ADD_USER({ name: 'haha' }) }}>添加</button>
        </div>
      )
    }
  }
})

使用体验

用 composition Api 的方式来实现全局状态管理充分利用了 hook 函数带来的便利性,能在 hook 函数中使用 watch, onMounted, onUnmounted 等 Api 来实现更丰富的功能,还能享受TS的完美加持,且不用引入其他库,减少打包后的文件大小。

不过还是有一些缺点:

  1. 无法使用 Vuex 的插件(Vuex有成熟的社区,其中有很多好用的插件)
  2. 丢失了 Vuex 的封装性,可在外部对 reactive 对象进行直接操作

局部状态管理

上述的方法可以实现全局的状态管理,在一些场景下,我们需要的是在某个组件X及其所有子孙组件中维护一个局部状态,假如程序中用到了多个组件X,则有多个相对独立的局部状态,这种时候就需要用到 provide/inject。

示例

Anthony Fu在VueConf 2021上还提到了类型安全的 provide/inject 的使用方式:即通过给 provide/inject 使用的 key 进行类型定义(看到这里的时候笔者突然想到可以同理实现类型安全的localStorage,Cookie存取)。同样地这基础上,再将其相关操作函数一起进行一点简单的封装,就能像 hook 函数一样来使用了。

// components/useTestState.ts
import { computed, inject, InjectionKey, reactive } from 'vue'

export interface User {
  name: string
}

export interface TestState {
  users: User[]
}

// 创建局部状态
export const createTestState = () => {
  const state: TestState = reactive({
    users: [{ name: 'sps' }]
  })
  return state
}

// 定义 InjectionKey
export const injectTestKey: InjectionKey<TestState> = Symbol('Test')

export const useTestState = () => {
   // 这里一定要确保使用hook函数的组件其祖先组件进行了provide操作
  const state = inject(injectTestKey)!

  const userCount = computed(() => {
    return state.users.length
  })

  const ADD_USER = (user: User) => {
    state.users.push(user)
  }
  return {
    state,
    userCount,
    ADD_USER
  }
}

// components/Test.tsx
import { defineComponent } from 'vue'
import { useTestState } from './useTestState'

export default defineComponent({
  name: 'Test',
  setup () {
    const { state, userCount, ADD_USER } = useTestState()
    return () => {
      const items = state.users.map(user => (
        <li>{ user.name }</li>
      ))
      return (
        <div>
          <div>用户总数: { userCount.value }</div>
          <ul>{ items }</ul>
          <button onClick={() => {ADD_USER({ name: 'haha' })}}>添加</button>
        </div>
      )
    }
  }
})


// App.tsx
import { defineComponent, provide } from 'vue'
import Test from '@/components/Test'
import { createTestState, injectTestKey } from '@/components/useTestState'

export default defineComponent({
  name: 'App',
  setup () {
    const state = createTestState()
    provide(injectTestKey, state)
    return () => {
      return (
        <Test />
      )
    }
  }
})

使用体验

provide/inject 实现的局部状态管理使用体验也符合 Vue3 composition Api 的开发风格。最大的缺点仍然在于封装性的问题,即 reactive 对象可被子组件进行直接操作。在需要确保 reactive 对象不被子组件直接操作的场景下,可以不将 reactive 对象直接提供给子组件,而是提供给子组件一个只有 get 的ComputedRef,其内容为 reactive 对象的值,或者提供一个函数,其返回 reactive 对象克隆后的值。

总结

本文讲述的几种状态管理方式各有特点,需要根据实际场景来选择具体使用哪一个。由于 Vue3 composition Api 天然的共享特性让 Vuex 不再成为了状态管理的几乎唯一选择。在 Vue2 中很多需要用 Bus 和 mixin 来解决的问题都能依靠 composition Api 的共享性来解决,只能说 composition Api 真是太强大了!