赋予Vuex 4.x 更好的 TypeScript体验
Vue 3.x
已经问世很长时间了,但由于我工作技术栈全是 React
,所以一直没怎么关注,最近觉着 Vue 3.x
差不多也稳定下来了,抽空学习了一下,不得不说,Vue 3.x
对于 TypeScript
的支持性真的不错,配合上 Volar 插件,代码写得行云流水
顺便又将 vue-router
、vuex
啥的相关生态库也都学习了一下,并且写了个 Demo
进行测试,因为既然已经对于 TypeScript
有了很好的支持,再加上本人也比较推崇使用 ts
,所以就尽量避免写 any
类型,在引入 vuex
的时候,看了一下其对于 ts 的支持介绍,看完之后发现这介绍得也太简单了吧,正好最近学习 TypeScript
体操有所心得,遂决定对 vuex
这枚钉子挥舞起手中的锤子
直观起见,对 vuex
放在 github
的 购物车例子 进行改造,主要以 cart
模块为例,products
模块类似
本文将要实现的效果
- 能够提示
getters
/rootGetters
里所有的可用属性及其类型 - 能够提示
dispatch
所有的可用type
及其对应payload
的类型 - 能够提示
commit
所有的可用type
及其对应payload
的类型 - 以上效果无论在
module
、全局(this.$store
)还是useStore
都有效
作为 TypeScript
上道 没多久的新手,在撰写本文的时候也遇到了很多问题,但最终都一一解决,本文在写的时候也会将一些我遇到的认为比较有价值的问题提出来以供参考
本文写法循序渐进,并且贴近实际工作场景,可以认为是一个小小的实战体验了,就算你对于 TypeScript
的类型编程不太明白,但看完之后相信也会有所收获
- 本文完整实例代码已上传到 github,可以直接拿来主义当做样板代码用在自己的项目里
- 文中代码的编写环境为
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
加类型,先去 vuex
的 types
文件夹下看看有没有已经定义好的类型,发现有个叫 GetterTree
的,先加上
import { GetterTree } from 'vuex'
const getters: GetterTree<TState, TRootState> = {
// ...
}
TState
就是当前模块的 state
类型,TRootState
则是全局 state
类型,比如这个例子里包含 cart
和 products
两个模块,那么 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
的属性 cart
和 products
分别是 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
对象,用于明确枚举 getters
的 key
,新增 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
}
Exclude
用于将K
从联合类型keyof T
里排除出去ReturnType
用于返回函数的返回类型,这里就是获取到getters
的返回类型
TRootGetters
就是全局 getters
,其 key
就是模块 getters
的命名空间 + key
,值还是模块 getters
对应的值,利用 ts
的 模板字符串 能力,将命名空间与 getters
的 key
进行组合,得到 TRootGetters
的 key
// 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>
经过上述改造后,现在就可以在编辑器里点
出 getters
和 rootGetters
上所有的属性了
改造 Mutions
先去 vuex
里看下有没有定义好的类型,发现有个叫 ActionTree
,先写上去
// store/modules/cart.ts
import { MutationTree } from 'vuex'
const mutations: MutationTree<TState> = {
// ...
}
因为这个 MutationTree
的 key
的类型是 string
,所以是没法直接 点
出来的,需要手动再写个类型进行覆盖,为了明确 mutation
的 key
,还是和 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
}
}
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;
}
state
、getters
、rootState
、rootGetters
的类型都已经确定了,至于 dispatch
和 commit
,类型签名的 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
,底层是 TActionContext
,TActionContext
依旧借助了 vuex
提供的类型 ActionContext
的能力,沿用对 state
、rootState
类型的支持,需要手动重写的是 dispatch
、commit
、getters
、rootGetters
,那么使用 Omit
将这几个类型挑出来另行定义
由于可以在module
之间相互调用 dispatch
、commit
,所以给 TCommit
、TDispatch
传入第三个参数用于标识是位于当前模块内还是位于其他模块或者全局环境内,主要用于决定是否添加模块命名空间,例如 cart/checkout
commit
依赖于 mutation
,所以给 TCommit
再传入 TMutations
、TRootMutations
;同理,给 TDispatch
传入 TActions
、TRootActions
和 TRootState
、TRootActions
类似,TRootMutations
、TRootActions
也即是所有 module
下 mutaions
和 actions
的集合
// 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
,带有命名空间
这一步做完之后就可以在 getters
、rootGetters
上 点
出所有的可用属性了
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
内部使用(UseInGlobal
为 false
)和在其他module
或者全局使用(UseInGlobal
为 false
),前者的type
不需要命名空间前缀,而后者需要,所以使用 UseInGlobal
区分开这两种情况,方便后续判断
TRootMutations
是所有 module
下 mutations
总的集合,所以需要借助 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
逻辑比较饶,可能看得不是太清楚,大概解释下 FlatRootObj
和 UnionToIntersection
所起的作用
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
后面 点
出所有可用属性了
甚至可以自动根据第一个选中的 key
,自动提示第二个 payload
的参数类型
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
内所有可dispatch
、commit
方法的参数签名了
同样的,当你写完 dispatch
、commit
的第一个type
参数之后,还能正确地给出第二个参数 payload
的准确类型
$store
事情还没完,除了在 module
内获取 getters
、state
,调用 dispatch
、commit
之外,我还可以全局使用,比如:
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
就是所有 module
的 store
的集合,和 TRootState
、TRootActions
差不多
// 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
类型,接收四个参数,分别是当前 module
的 TState
、TCommit
、TDispatch
以及 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
,然后因为 commit
、dispatch
、getters
是自定义的,所以把这三个剔除出来,换成自己定义的 TCommit
、TDispatch
、TGetters
这里再次对 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
了
包括 useStore
小结
寻找资料的过程中,发现了一个第三方支持 vuex
的 TypeScript
库 vuex-typescript-interface,但感觉支持性还不是那么好
当然了,本文对于 vuex
的 TypeScript
支持也远没有达到完美的地步,比如 mapState
、mapActions
、多级嵌套 module
等都没有支持,但相对于官方给的 TypeScript
支持,显然又好了很多
为了获得良好的类型体验,类型体操的代码量必然不会少到哪里去,甚至比肩真正的业务代码,但这也只是在项目初期,随着项目逐渐复杂化,类型代码占比肯定越来越少,但作用却可以不减当初
对于 javascript
这种弱类型语言而言,能在庞大臃肿的多人协作项目代码里正确地 点
出一个变量的所有属性,我愿称之为 第二注释
转载自:https://juejin.cn/post/6999886459343732772