赋予Vuex 4.x 更好的 TypeScript体验

5,812 阅读2分钟

Vue 3.x 已经问世很长时间了,但由于我工作技术栈全是 React,所以一直没怎么关注,最近觉着 Vue 3.x 差不多也稳定下来了,抽空学习了一下,不得不说,Vue 3.x对于 TypeScript 的支持性真的不错,配合上 Volar 插件,代码写得行云流水

顺便又将 vue-routervuex 啥的相关生态库也都学习了一下,并且写了个 Demo进行测试,因为既然已经对于 TypeScript有了很好的支持,再加上本人也比较推崇使用 ts,所以就尽量避免写 any 类型,在引入 vuex的时候,看了一下其对于 ts 的支持介绍,看完之后发现这介绍得也太简单了吧,正好最近学习 TypeScript 体操有所心得,遂决定对 vuex这枚钉子挥舞起手中的锤子

直观起见,对 vuex 放在 github购物车例子 进行改造,主要以 cart模块为例,products 模块类似

本文将要实现的效果

  1. 能够提示 getters/rootGetters 里所有的可用属性及其类型
  2. 能够提示 dispatch 所有的可用 type 及其对应 payload 的类型
  3. 能够提示 commit 所有的可用 type 及其对应 payload 的类型
  4. 以上效果无论在 module、全局(this.$store)还是 useStore 都有效

作为 TypeScript 上道 没多久的新手,在撰写本文的时候也遇到了很多问题,但最终都一一解决,本文在写的时候也会将一些我遇到的认为比较有价值的问题提出来以供参考

本文写法循序渐进,并且贴近实际工作场景,可以认为是一个小小的实战体验了,就算你对于 TypeScript 的类型编程不太明白,但看完之后相信也会有所收获

  1. 本文完整实例代码已上传到 github,可以直接拿来主义当做样板代码用在自己的项目里
  2. 文中代码的编写环境为 TypeScript 4.3.5,由于使用到了一些高级特性,所以低于此版本可能无法正确编译

改造 Getters

根据代码的业务逻辑,先把 state 的类型补充好

// store/modules/cart.ts
export type IProductItem = { id: number, title: string, inventory: number; quantity: number }
export type TState = {
  items: Array<{ id: number, quantity: number }>;
  checkoutStatus: null | 'successful' | 'failed'
}

const state: () => TState = () => ({
  items: [],
  checkoutStatus: null
})

然后开始给 getters 加类型,先去 vuextypes 文件夹下看看有没有已经定义好的类型,发现有个叫 GetterTree 的,先加上

import { GetterTree } from 'vuex'

const getters: GetterTree<TState, TRootState> = {
  // ...
}

TState就是当前模块的 state 类型,TRootState 则是全局 state 类型,比如这个例子里包含 cartproducts 两个模块,那么 TRootState 的类型就是:

// store/index.ts
import { TState as TCartState } from '@/store/modules/cart'
import { TState as TProductState } from '@/store/modules/products'

export type TRootState = {
  cart: TCartState;
  products: TProductState;
}

TRootState的属性 cartproducts 分别是 cart 模块和 products模块的命名空间名称,后续肯定还会经常遇到的,所以最好统一定义一下

// store/modules/cart.ts
export const moduleName = 'cart'
// store/modules/products.ts
export const moduleName = 'products'

// store/index.ts
import { moduleName as cartModuleName } from '@/store/modules/cart'
import { moduleName as productsModuleName } from '@/store/modules/products'

export type TRootState = {
  [cartModuleName]: TCartState;
  [productsModuleName]: TProductState;
}

加上 GetterTree 之后,getters 下方法(例如 cartProducts)的第一个参数 state 和第三个参数 rootState 的类型就能自动推导出来了

但是第二个参数 getter 和 第四个参数 rootGetters 的类型依旧是 any,因为 GetterTree 方法给这两个参数的默认类型就是 any,所以需要我们手动改造下

// store/modules/cart.ts
import { TRootState, TRootGetters } from '@/store/index'
import { GettersReturnType } from '@/store/type'

const GettersTypes = {
  cartProducts: 'cartProducts',
  cartTotalPrice: 'cartTotalPrice'
} as const
type VGettersTypes = (typeof GettersTypes)[keyof typeof GettersTypes]

export type TGetters = {
  readonly [key in VGettersTypes]: (
    state: TState, getters: GettersReturnType<TGetters, key>, rootState: TRootState, rootGetters: TRootGetters
  ) => key extends typeof GettersTypes.cartProducts ? Array<{
    title: string;
    price: number;
    quantity: number;
  }> : number
}

// getters
const getters: GetterTree<TState, TRootState> & TGetters = {
  [GettersTypes.cartProducts]: (state, getters, rootState, rootGetters) => {
    // ...
  }
}

新增 GettersTypes 对象,用于明确枚举 getterskey,新增 TGetters 类型用于覆盖 GetterTree

主要看用于定义 getters 的类型 GettersReturnType<TGetters, key>,用于获取 getters 的返回类型

// store/type.ts
export type GettersReturnType<T, K extends keyof T> = {
  [key in Exclude<keyof T, K>]: T[key] extends (...args: any) => any ? ReturnType<T[key]> : never
}
  1. Exclude 用于将 K 从联合类型 keyof T 里排除出去
  2. ReturnType 用于返回函数的返回类型,这里就是获取到 getters 的返回类型

TRootGetters 就是全局 getters,其 key 就是模块 getters的命名空间 + key,值还是模块 getters对应的值,利用 ts模板字符串 能力,将命名空间与 getterskey 进行组合,得到 TRootGetterskey

// store/type.ts
type RootGettersReturnType<T extends Record<string, any>, TModuleName extends string> = {
  readonly [key in keyof T as `${TModuleName}/${Extract<key, string>}`]: T[key] extends ((...args: any) => any) ? ReturnType<T[key]> : never
}

// store/index.ts
export type TRootGetters = RootGettersReturnType<TCartGetters, typeof cartModuleName>
  & RootGettersReturnType<TProductsGetters, typeof productsModuleName>
  1. GettersTypes 设计成一个 js对象而不是 enum,首先是考虑到这里并不是一个枚举的适用场景,其次是为了明确 getterkey,以及 key 对应的方法及其返回值,如果使用 enum,会因为类型模糊导致丢失 key 与 对应的方法及其返回值之间的联系丢失
  2. GettersTypes 必须使用 as const 进行修饰,详细可见我之前 发过的一篇文章,里面有关于 Const Assertions 的描述
  3. Extract<key, string>写法是由于 TypeScript 存在的一个 feature
  4. [key in keyof T as ${TModuleName}/${Extract<key, string>}] 的写法可见于 Key Remapping in Mapped Types

经过上述改造后,现在就可以在编辑器里gettersrootGetters 上所有的属性了

1.jpg

2.jpg

改造 Mutions

先去 vuex 里看下有没有定义好的类型,发现有个叫 ActionTree,先写上去

// store/modules/cart.ts
import { MutationTree } from 'vuex'
const mutations: MutationTree<TState> = {
  // ...
}

因为这个 MutationTreekey 的类型是 string,所以是没法直接 出来的,需要手动再写个类型进行覆盖,为了明确 mutationkey,还是和 getters一样,先定义好对应的对象变量 MutationTypes

// store/modules/cart.ts
import { MutationTree } from 'vuex'

const MutationTypes = {
  pushProductToCart: 'pushProductToCart',
  incrementItemQuantity: 'incrementItemQuantity',
  setCartItems: 'setCartItems',
  setCheckoutStatus: 'setCheckoutStatus'
} as const
type TMutations = {
  [MutationTypes.pushProductToCart]<T extends { id: number }>(state: TState, payload: T): void;
  [MutationTypes.incrementItemQuantity]<T extends { id: number }>(state: TState, payload: T): void;
  [MutationTypes.setCartItems]<T extends { items: TState["items"] }>(state: TState, payload: T): void;
  [MutationTypes.setCheckoutStatus](state: TState, payload: TState["checkoutStatus"]): void;
}
// mutations
const mutations: MutationTree<TState> & TMutations = {
  [MutationTypes.pushProductToCart] (state, { id }) {
    state.items.push({ id, quantity: 1 })
  },
  [MutationTypes.incrementItemQuantity] (state, { id }) {
    const cartItem = state.items.find(item => item.id === id)
    cartItem && cartItem.quantity++
  },
  [MutationTypes.setCartItems] (state, { items }) {
    state.items = items
  },
  [MutationTypes.setCheckoutStatus] (state, status) {
    state.checkoutStatus = status
  }
}

3.jpg Mutions 的改造还是比较简单的,这就完事了

改造 Actions

先定义好对应的枚举对象变量

// store/modules/cart.ts
export const ActionTypes = {
  checkout: 'checkout',
  addProductToCart: 'addProductToCart',
} as const

然后再找到预先定义好的类型,加上去

// store/modules/cart.ts
const actions: ActionTree<TState, TRootState> {
  // ...
}

最后照例写个类型 TActions 进行覆盖

// store/modules/cart.ts
type TActions = {
  [ActionTypes.checkout](context: any, payload: TState["items"]): Promise<void>
  [ActionTypes.addProductToCart](context: any, payload: IProductItem): void
}
const actions: ActionTree<TState, TRootState> & TActions {
  // ...
}

context 还是个 any类型,这肯定是不行的,从 vuex 已经给定的类型定义来看,context 是一个对象,其具有5个属性:

export interface ActionContext<S, R> {
  dispatch: Dispatch;
  commit: Commit;
  state: S;
  getters: any;
  rootState: R;
  rootGetters: any;
}

stategettersrootStaterootGetters 的类型都已经确定了,至于 dispatchcommit,类型签名的 key 都是 string,所以 不出来,需要对着这两个进行改造

// store/type.ts
import { ActionContext } from 'vuex'

type TObjFn = Record<string, (...args: any) => any>

export type TActionContext<
  TState, TRootState,
  TActions extends TObjFn, TRootActions extends Record<string, TObjFn>,
  TMutations extends TObjFn, TRootMutations extends Record<string, TObjFn>,
  TGetters extends TObjFn, TRootGetters extends Record<string, any>
> = Omit<ActionContext<TState, TRootState>, 'commit' | 'dispatch' | 'getters' | 'rootGetters'>
  & TCommit<TMutations, TRootMutations, false>
  & TDispatch<TActions, TRootActions, false>
  & {
    getters: {
      [key in keyof TGetters]: ReturnType<TGetters[key]>
    }
  }
  & { rootGetters: TRootGetters }

// store/index.ts
type TUserActionContext = TActionContext<TState, TRootState, TActions, TRootActions, TMutations, TRootMutations, TGetters, TRootGetters>
export type TActions = {
  [ActionTypes.checkout]: (context: TUserActionContext, payload: TState["items"]) => Promise<void>
  [ActionTypes.addProductToCart]: (context: TUserActionContext, payload: IProductItem) => void
}

context 定义了类型 TUserActionContext,底层是 TActionContextTActionContext依旧借助了 vuex 提供的类型 ActionContext的能力,沿用对 staterootState 类型的支持,需要手动重写的是 dispatchcommitgettersrootGetters ,那么使用 Omit 将这几个类型挑出来另行定义

由于可以在module之间相互调用 dispatchcommit,所以给 TCommitTDispatch传入第三个参数用于标识是位于当前模块内还是位于其他模块或者全局环境内,主要用于决定是否添加模块命名空间,例如 cart/checkout

commit依赖于 mutation,所以给 TCommit 再传入 TMutationsTRootMutations;同理,给 TDispatch 传入 TActionsTRootActions

TRootStateTRootActions 类似,TRootMutationsTRootActions 也即是所有 modulemutaionsactions 的集合

// store/index.ts
import {
  moduleName as cartModuleName,
  TActions as TCartActions, TMutations as TCartMutations, TCartStore
} from '@/store/modules/cart'
import {
  moduleName as productsModuleName,
  TActions as TProductsActions, TMutations as TProductsMutations, TProductsStore
} from '@/store/modules/products'

export type TRootActions = {
  [cartModuleName]: TCartActions;
  [productsModuleName]: TProductsActions;
}
export type TRootMutations = {
  [cartModuleName]: TCartMutations;
  [productsModuleName]: TProductsMutations;
}

getters & rootGetters

{
  getters: {
    [key in keyof TGetters]: ReturnType<TGetters[key]>
  }
}

覆盖原有的 getters,主要就是给明确定义属性的 key,以及 key 对应的类型,即 getter 的类型

// store/index.ts
export type TRootGetters = RootGettersReturnType<TCartGetters, typeof cartModuleName>
  & RootGettersReturnType<TProductsGetters, typeof productsModuleName>

// store/type.ts
export type RootGettersReturnType<T extends Record<string, any>, TModuleName extends string> = {
  readonly [key in keyof T as `${TModuleName}/${Extract<key, string>}`]: T[key] extends ((...args: any) => any)
    ? ReturnType<T[key]>
    : never
}

rootGetters 即是在全局范围内可访问的 getters,带有命名空间

这一步做完之后就可以在 gettersrootGetters 出所有的可用属性了

11.jpg

12.jpg

TCommit

// store/type.ts
export type TCommit<
  TMutations extends TObjFn, TRootMutations extends Record<string, TObjFn>, UseInGlobal extends boolean
> = {
  commit<
    M = UseInGlobal extends true
      ? UnionToIntersection<FlatRootObj<TRootMutations>>
      : (UnionToIntersection<FlatRootObj<TRootMutations>> & TMutations),
    K extends keyof M = keyof M
  >(
    key: K,
    payload: Parameters<Extract<M[K], (...args: any) => any>>[1],
    options?: CommitOptions
  ): void
}

commit是一个方法,接收三个参数,最后一个参数 options 依旧是使用 vuex 提供的,返回值固定是 void

commit第一个参数为提交的 type,存在两种情况,在 module 内部使用(UseInGlobalfalse)和在其他module或者全局使用(UseInGlobalfalse),前者的type不需要命名空间前缀,而后者需要,所以使用 UseInGlobal 区分开这两种情况,方便后续判断

TRootMutations 是所有 modulemutations总的集合,所以需要借助 FlatRootObj 进行拍平

type FlatRootObj<T extends Record<string, TObjFn>> = T extends Record<infer U, TObjFn>
  ? U extends keyof T ? {
    [key in keyof T[U] as `${Extract<U, string>}/${Extract<key, string>}`]: T[U][key]
  } : never : never

FlatRootObj 拍平之后的结果是一个联合类型,拍平出来的对象的 key 已经添加了命名空间,但这是一个联合类型,我们希望结果是一个交叉类型,所以再借助 UnionToIntersection 将联合类型转为交叉类型

type UnionToIntersection<U extends TObjFn> =
  (U extends TObjFn ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never

UnionToIntersection 用于将联合类型转为交叉类型,即 A | B | C => A & B & C,原理可见 type-inference-in-conditional-types

逻辑比较饶,可能看得不是太清楚,大概解释下 FlatRootObjUnionToIntersection 所起的作用

type A = { q: 1; w: '2' }
type B = { e: []; r: true; }
type C = { a: A; b: B; }

type D = FlatRootObj<C>
// => { "a/q": 1; "a/w": '2'; } | { "b/e": []; "b/r": true; }
type E = UnionToIntersection<D>
// => { "a/q": 1; "a/w": '2'; } & { "b/e": []; "b/r": true; }
// 也即 { "a/q": 1; "a/w": '2'; "b/e": []; "b/r": true; }

第二个参数 payload 是提交的值,也就是 TMutations类型方法签名的第一个参数,借助 Parameters 内置方法可以取出函数的参数

到了这一步,就可以在 commit 后面 出所有可用属性了

15.jpg

甚至可以自动根据第一个选中的 key,自动提示第二个 payload 的参数类型

16.jpg

TDispatch

export type TDispatch<
  TActions extends TObjFn, TRootActions extends Record<string, TObjFn>, UseInGlobal extends boolean,
> = {
  dispatch<
    M = UseInGlobal extends true
      ? UnionToIntersection<FlatRootObj<TRootActions>>
      : (UnionToIntersection<FlatRootObj<TRootActions>> & TActions),
    K extends keyof M = keyof M
  >(
    key: K,
    payload: Parameters<Extract<M[K], (...args: any) => any>>[1],
    options?: DispatchOptions
  ): Promise<ReturnType<Extract<M[K], (...args: any) => any>>>;
}

其实和 TCommit 差不多,只不过它们的数据来源不一样,并且 dispatch 返回的是一个 Promise,就不多说了

上述类型写完之后,就可以愉快地看到编辑器会智能地给出包括自身 module 内以及其他 module内所有可dispatchcommit 方法的参数签名了

13.jpg

同样的,当你写完 dispatchcommit 的第一个type参数之后,还能正确地给出第二个参数 payload 的准确类型

14.jpg

$store

事情还没完,除了在 module 内获取 gettersstate,调用 dispatchcommit 之外,我还可以全局使用,比如:

this.$store.dispatch(...)
this.$store.commit(...)
this.$store.getters.xxx

有了上面的基础,这个就简单了,只需要把类型映射到全局即可

首先在 shims-vue.d 增加 $store 的签名

// shims-vue.d
import { TRootStore } from '@/store/index'

declare module '@vue/runtime-core' {
  interface ComponentCustomProperties {
    $store: TRootStore
  }
}

这个 TRootStore 就是所有 modulestore 的集合,和 TRootStateTRootActions 差不多

// store/index.ts
import { TCartStore } from '@/store/modules/cart'
import { TProductsStore } from '@/store/modules/products'

export type TRootStore = TCartStore & TProductsStore

那么看下 TCartStore 是怎么得到的

// store/modules/cart.ts
import { TStore } from '@/store/type'
import { TRootActions, TRootMutations } from '@/store/index'

export const moduleName = 'cart'
type TModuleName = typeof moduleName

export type TCartStore = TStore<
  { [moduleName]: TState },
  TCommit<TMutations, TRootMutations, true>,
  TDispatch<TActions, TRootActions, true>,
  {
    [key in keyof TGetters as `${TModuleName}/${key}`]: ReturnType<TGetters[key]>
  }
>

借助了 TStore 类型,接收四个参数,分别是当前 moduleTStateTCommitTDispatch 以及 getters

最后一个 getters 额外处理了一下,因为在全局调用 getters 的时候,肯定需要加命名空间的,所以这里使用模板字符串先把 TModuleName 给拼接上

再看 TStore 的实现

// store/type.ts
import { Store as VuexStore, CommitOptions, DispatchOptions } from 'vuex'

export type TStore<
  TState extends Record<string, any>,
  TCommit extends { commit(type: string, payload?: any, options?: CommitOptions | undefined): void },
  TDispatch extends { dispatch(type: string, payload?: any, options?: DispatchOptions | undefined): Promise<any> },
  TGetters
> = Omit<VuexStore<TState>, 'commit' | 'dispatch' | 'getters'> & TCommit & TDispatch & {
  getters: TGetters
};

借助了 vuex 提供好的类型 VuexStore,然后因为 commitdispatchgetters 是自定义的,所以把这三个剔除出来,换成自己定义的 TCommitTDispatchTGetters

这里再次对 TGetters 进行了额外处理,因为全局使用 $store 调用 getters的时候不是直接调用的,而是需要通过 getters 属性,即:

this.$store.getters['cart/cartProducts']

最后 useStore 同样也需要赋予相同的类型

// store/index.ts
import { InjectionKey } from 'vue'
import { Store as VuexStore, useStore as baseUseStore } from 'vuex'

export type TRootStore = TCartStore & TProductsStore

const key: InjectionKey<VuexStore<TRootState>> = Symbol('store')

export function useStore (): TRootStore {
  return baseUseStore(key)
}

然后就可以愉快地在全局使用 $store

7.jpg

8.jpg

9.jpg

包括 useStore

10.jpg

小结

寻找资料的过程中,发现了一个第三方支持 vuexTypeScriptvuex-typescript-interface,但感觉支持性还不是那么好

当然了,本文对于 vuexTypeScript 支持也远没有达到完美的地步,比如 mapStatemapActions、多级嵌套 module 等都没有支持,但相对于官方给的 TypeScript支持,显然又好了很多

为了获得良好的类型体验,类型体操的代码量必然不会少到哪里去,甚至比肩真正的业务代码,但这也只是在项目初期,随着项目逐渐复杂化,类型代码占比肯定越来越少,但作用却可以不减当初

对于 javascript 这种弱类型语言而言,能在庞大臃肿的多人协作项目代码里正确地 出一个变量的所有属性,我愿称之为 第二注释