在TypeScript中实现Vuex精准的类型推断

2,908 阅读8分钟

和Vuex耗上了

上一次我也对Vuex进行了推断,但结果很难让人满意: zyyzg.cn/2021/06/02/…

于是这次认真思考了一下,有了一个新的方案,复杂的映射后(我觉得过程写得很麻烦,毕竟水平有限)能拿到差强人意的结果了:

State

state.gif

Commit

commit.gif

Dispatch:

dispatch.gif

可以看到state能够拿到modules内的,commit和dispatch能拿到命名空间key,并且第二个参数(payload)的类型也能够与之对应。

store/index.ts文件

import Vue from 'vue'
import Vuex, { Commit } from 'vuex'
import { isOwnKey, NonNeverState, GetState, GetMutationKeyParamMap, GetActionKeyParamMap } from "vuex-with-type"Vue.use(Vuex)
​
const state = {
  token: "",
  openId: "",
  name: "",
  appId: "1"
}
​
/** modules测试 */
const modOneState = {
  name: "z",
  age: 24,
  job: 'frontier-engineer'
}
​
const modOneMutation = {
  SET_STATE(state: typeof modOneState, obj: Partial<typeof modOneState>) {
    for (const key in obj) {
      if (isOwnKey(key, obj)) {
        state[key] = obj[key];
      }
    }
  },
  SET_NAME(state: { name: string }, v: string) {
    state.name = v;
  }
}
​
const modTwoState = {
  name: "q",
  age: 25,
  job: 'back-end-engineer',
  996: true
}
​
const modTwoMutation = {
  SET_STATE(state: typeof modTwoState, obj: Partial<typeof modTwoState>) {
    for (const key in obj) {
      if (isOwnKey(key, obj)) {
        state[key] = obj[key];
      }
    }
  },
  SET_AGE(state: { age: number }, v: number) {
    state.age = v;
  },
  SET_996(state: { 996: boolean }, v: boolean) {
    state[996] = v;
  }
}
​
// 将vuex store独立出来
const storeOptions = {
  state,
  mutations: {
    SET_STATE(s: NonNeverState<typeof state>, obj: Partial<NonNeverState<typeof state>>) {
      for (const key in obj) {
        if (isOwnKey(key, obj)) {
          s[key] = obj[key];
        }
      }
    },
    SET_NAME(s: NonNeverState<typeof state>, v: string) {
      s.name = v;
    }
  },
  actions: {
    SET_ASYNC_STATE({ commit }: { commit: Commit }, obj: Partial<NonNeverState<typeof state>>) {
      return new Promise(resolve=> {
        setTimeout(()=> {
          commit("SET_STATE", obj);
          resolve(obj);
        });
      });
    }
  },
  modules: {
    modOne: {
      namespaced: true,
      state: modOneState,
      mutations: modOneMutation,
      actions: {
        SET_ASYNC_NAME({ commit }: { commit: Commit }, name: string) {
          return new Promise(resolve=> {
            setTimeout(()=> {
              commit("modOne/SET_NAME", name);
              resolve(name);
            });
          });
        }
      },
      modules: {
        modOneSon: {
          namespaced: true,
          state: {
            jk: true
          },
          mutations: {
            SET_JK(state: { jk: boolean }, v: boolean) {
              state.jk = v;
            }
          },
          actions: {
            SET_ASYNC_JK({ commit }: { commit: Commit }, v: boolean) {
              return new Promise(resolve=> {
                setTimeout(()=> {
                  commit("modOne/modOneSon/SET_JK", v);
                  resolve(v);
                });
              });
            }
          },
          modules: {
            modOneSonSon: {
              namespaced: true,
              state: {
                kpi: true
              },
              mutations: {
                SET_KPI(state: { kpi: boolean }, v: boolean) {
                  state.kpi = v;
                }
              },
              actions: {
                SET_ASYNC_KPI({ commit }: { commit: Commit }, v: boolean) {
                  return new Promise(resolve=> {
                    setTimeout(()=> {
                      commit("modOne/modOneSon/modOneSonSon/SET_KPI", v);
                      resolve(v);
                    });
                  });
                }
              },
            }
          }
        }
      }
    },
    modTwo: {
      namespaced: true,
      state: modTwoState,
      mutations: modTwoMutation,
      actions: {
        SET_ASYNC_AGE({ commit }: { commit: Commit }, v: number) {
          return new Promise(resolve=> {
            setTimeout(()=> {
              commit("modTwo/SET_AGE", v);
              resolve(v);
            });
          });
        },
        SET_ASYNC_996({ commit }: { commit: Commit }, v: boolean) {
          return new Promise(resolve=> {
            setTimeout(()=> {
              commit("modTwo/SET_996", v);
              resolve(v);
            });
          });
        }
      }
    }
  }
};
​
export type TState = NonNeverState<GetState<typeof storeOptions>>
​
export type TMutation = GetMutationKeyParamMap<typeof storeOptions>;
​
export type TAction = GetActionKeyParamMap<typeof storeOptions>;
​
const store = new Vuex.Store<TState>(storeOptions);
​
export default store;

遇到的问题

利用工作间隙断断续续写了两三周时间,中间也出现因为隔了很长时间没写都忘记上次想到哪儿了XD,确实遇到了几个问题,以下由易到难:

模板字符串拼接

因为要实现modules嵌套后mutation与`action以"/"为分隔的命名空间,必须要用上TS4.1出的模板字符串新特性:www.typescriptlang.org/docs/handbo….

本质上这就是js里的模板字符串拼接,不过我遇到的问题是传入的范型扔到字符串拼接type后会报错:

// 定义key的类型
type KeyType = string | number;
// 拼接key
type AddPrefix<Prefix extends KeyType, Keys extends KeyType> = `${Prefix}/${Keys}`;
// 使用遇到问题
type AddPrefixKeys<P, S extends string> = GetValue<{
  [K in S]: P extends "" ? K : AddPrefix<P, K>;
}>

这里P会报错:类型“P”不满足约束“KeyType” 。实际上把范型P那儿改成P extends KeyType就可以了,但是在一些使用场景下你拿到并试图传入的并不一定能将类型缩窄到KeyType,所以自己写了个方法解决这个问题。

type Revert2Key<T> = T extends KeyType ? T : KeyType;
​
type AddPrefixKeys<P, S extends string> = GetValue<{
  [K in S]: P extends "" ? K : AddPrefix<Revert2Key<P>, K>;
}>

但后面发现其实还可以用交叉类型:

type AddPrefixKeys<P, S extends string> = GetValue<{
  [K in S]: P extends "" ? K : AddPrefix<P & string, K>;
}>

很明显,第二种方式更优雅。

使用者定义state时,出现never类型如何解决?

其实当我们给一个state其中一个变量赋初始值为[]时,它的类型就会被自动推断为never[]。我写了一个工具来解决这个问题:

export type DealNeverType<T> = T extends never ? any : T extends never[] ? any[] : T;
​
export type NonNeverState<R extends object> = {
  [K in keyof R]: R[K] extends (any[] | never) ? 
  DealNeverType<R[K]> : (R[K] extends object ? 
  NonNeverState<R[K]> : DealNeverType<R[K]>);
}

以上开始我写的版本,这其中有个很大的问题,原因是我没有理解never这个特殊类型:

type CheckNever<T> = T extends never ? true : false;
// 结果并不是想象那样
type Test = CheckNever<never>; // never

另外,never是所有类型的subType,这里只随便举个例子:

type Test2 = never extends symbol ? true : false; // true

很快,我在Typescript的仓库找到了答案:github.com/microsoft/T…

export type DealNeverType<T> = [T] extends [never] ? any : ([T] extends [never[]] ? any[] : T);
​
export type NonNeverState<R extends object> = {
  [K in keyof R]: R[K] extends (any[] | never) ? 
  DealNeverType<R[K]> : (R[K] extends object ? 
  NonNeverState<R[K]> : DealNeverType<R[K]>);
}

解决方案就是让他们变成元祖。

当Store树有N层modules,如何获取所有的key?

老实说这个其实是最简单的问题,不过我中间走了一次弯路,还是把它老老实实写出来吧!

 // 判断是否添加/
type AddSlashe<T> = T extends "" ? T : `${T & string}/`;
​
export type GetMutationKeys<N extends ModuleStoreOptions, Prefix = ""> = {
  [K in keyof N]: N[K] extends { modules: ModuleStoreOptions } ? 
  (
    AddPrefix<`${AddSlashe<Prefix>}${K & string}` , keyof N[K]["mutations"] & string> | 
    GetMutationKeys<N[K]["modules"], `${AddSlashe<Prefix>}${K & string}`>
  ) : 
  AddPrefix<`${AddSlashe<Prefix>}${K & string}`, keyof N[K]["mutations"] & string>; 
}[keyof N];

通过判断当前对象是否有modules来决定是否走递归三元分支

可以看到类型确实推断出来了,是那么回事,不过我发现这个方法行不通:

  • 这样只能拿到mutations或者actions的key,我拿不到参数(即commit(xxx, payload)函数的这个payload)。
  • 这样拿不到第一层state,用户要单独传入modules类型再与外层state交叉,很麻烦。

第二个问题很好解决,先不谈,第一个问题就要难一点:要实现本文最上面的图示的效果,就必须要把mutations的key与函数的第二个参数联系起来。

这也就引出了下一个问题

如何将modules嵌套的key与函数参数对应起来?

实现这点大体要分为两个步骤:

获取mutations或actions的payload类型

想实现上面的效果,infer关键字就要大显神威了,下面是一个获取mutation或action函数第二个参数的类型:

type GetSecondParamType<M, MK extends string, K extends string, KEY = "mutations", E = never> = M extends { 
  [X in MK]: { 
    [X1 in KEY & string]: { 
      [X2 in K]: (s: any, v: infer N)=> any 
    } 
  } 
} ? N | E : never;

看起来很头大,换一种写法看着会好一点:

type XXX<....> = M extends {
  mod: {
    mutations: {
      SET_NAME: (state: any, value: any[])=> any     
    }      
  }         
}

这个类型转门用于推断vuex的modules内的mutations或actions,M是模块类型,MK是要推断的指定模块的key,KEY是选择mutations还是actions,K是指定要推断的mutations或actions的某一个函数。

infer关键字

可以把infer理解为一个陷阱(trap),以TS内置的ReturnType为例:

type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

当传入函数时,就一定extends (...args: any),那么把infer R放到函数返回值的地方,它就会捕获到函数的返回类型。

为什么要设置这么多范型参数,像K直接传入keyof mutations不行吗?

答案是不行,继续往后看,你会发现直接传入keyof mutations会让结果变成一对多

这里最后一个范型参数E是做什么的

这个E可以让所有推断的类型有一个联合类型,比如null。这里设置初始值为never是因为任何类型 | never都会得到其本身,这能兼容不传入E的情况。

递归将key和payload对应起来

到这一步代码就显得非常凌乱了😂:

// 定义键类型
type KeyType = string | number;
​
// 推断出参数类型 返回元祖
type ParamsType<T extends (...args: any[]) => any> = T extends (...args: infer R) => any ? R : any;
​
// 获取对象值
type GetValue<T> = T[keyof T];
​
// 联合类型转交叉类型
type UnionToIntersection<U> = 
  (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : nevertype AddPrefix<Prefix extends KeyType, Keys extends KeyType> = `${Prefix}/${Keys}`;
​
 // 判断是否添加/
type AddSlashe<T> = T extends "" ? T : `${T & string}/`;
​
 // 支持联合类型的字符串模板拼接 support string splicing of union type 
type AddPrefixKeys<P, S extends string> = GetValue<{
  [K in S]: P extends "" ? K : AddPrefix<P & string, K>;
}>
​
// 模块Store的类型,用于继承判断 type of modules's store
type ModuleStoreOptions = { 
  [k: string]: { 
    mutations: { [x: string]: (...args: any[])=> any }; 
    state: any; 
    actions?: { [x: string]: (...args: any[])=> any }; 
    modules?: ModuleStoreOptions 
  } 
};
​
// 获取mutation或action函数第二个参数的类型 get second param type of function which named K
type GetSecondParamType<M, MK extends string, K extends string, KEY = "mutations", E = never> = M extends { 
  [X in MK]: { 
    [X1 in KEY & string]: { 
      [X2 in K]: (s: any, v: infer N)=> any 
    } 
  } 
} ? N | E : never;
​
type GetModuleMutationKeyParamMap<M extends ModuleStoreOptions, P = ""> = UnionToIntersection<GetValue<{
  // 这里拿到传入的模块M的key 如modOne
  [R in keyof M]:
    // 判断内部是否还有modules,如modOne: { modules: ... }
    M[R] extends { modules: ModuleStoreOptions } ? 
    // 注意我们需要的只是值,而不是对象,因此这里都是拿GetValue类型包着的。
    GetValue<{
      // 遍历M modules下R的mutations
      [K in keyof M[R]["mutations"]]: { 
        // 最后 这里才是遍历key,并对应payload类型的地方,这也是最后输出的结果
        [K1 in AddPrefixKeys<`${AddSlashe<P>}${R & string}`, K & string>]: GetSecondParamType<M, R & string, K & string>
      } 
    }> & GetMutationKeyParamMap<M[R]["modules"], P extends "" ? R : AddPrefix<P & string, R & string>> : 
    GetValue<{
      [K in keyof M[R]["mutations"]]: {
        [K1 in AddPrefix<`${AddSlashe<P>}${R & string}`, K & string>]: GetSecondParamType<M, R & string, K & string>
      }
    }>
}>>;
​
// 支持直接传入整个vuex store树 解析出mutation的key与函数第二个参数类型
export type GetMutationKeyParamMap<M extends (ModuleStoreOptions | GetValue<ModuleStoreOptions>), P = ""> = 
    M extends ModuleStoreOptions ? 
    GetModuleMutationKeyParamMap<M, P> : 
    M extends GetValue<ModuleStoreOptions> ? 
    (
      // 对于非modules的第一层,直接推出类型就行了
      { [K in keyof M["mutations"]]: ParamsType<M["mutations"][K & string]>[1] } &
      (
        M extends { modules: ModuleStoreOptions } ? 
        GetModuleMutationKeyParamMap<M["modules"]> : 
        {}
      )
    ) : never;

从上往下看,多了三个东西:GetValueAddPrefixKeys以及UnionToIntersection,前两者很好理解,前者是获取对象值的,后者是扩展了模板字符串拼接类型AddPrefix,可以接收联合类型拼接:

type AddPrefixKeys<P, S extends string> = GetValue<{
  [K in S]: P extends "" ? K : AddPrefix<P & string, K>;
}>
​
type Test = AddPrefixKeys<"vwt", "1" | "2" | "3">; // "vwt/1 | vwt/2 | vwt/3"

第三个UnionToIntersection,其实是用来把联合类型转为交叉类型的:

type A = { name: string } | { age: number };
type Intersection = UnionToIntersection<A>; // { name: string; age: number }

推断key和param对应的过程不仅要推出他们的类型,还要让结构扁平化

GetModuleMutationKeyParamMap类型

这个类型用于推断modules内的mutation类型,推断结果会是这样:

{
  SET_NAME: string;
  modOne/SET_STATE: Partial<typeof modOneState>
  ...
}

做了三个工作:

  • 使用递归与交叉类型把嵌套modules中原本树形结构的mutation扁平化(UnionToIntersection)
  • 利用多层对象遍历(就是那堆xxx in keyof)保证GetSecondParamType拿到的mutations函数的KEY不是联合类型
  • 同样是利用递归,把上一轮的嵌套结果P传入下一轮,实现mod/modOne/modOneSon这样的效果

GetMutationKeyParamMap类型

这个类型就没那么复杂了,就是判断用户传入的类型是整个Vuex store还是只穿了modules的类型,作为一个兼容两者的类型暴露出去。

actions的获取过程和mutations的基本就是一样的,就变了个key的名字,就不单独写出来了

解决最后一个麻烦

到这里其实还有个最重要的问题还没有解决,那就是声明合并的问题,在vuex的官方声明文件vue.d.ts中,有这么几行代码:

declare module "vue/types/vue" {
  interface Vue {
    $store: Store<any>;
  }
}

vuex官方声明了$store对象,但给了个any类型,这也导致后续声明合并都无效,最多也就合并下Commit这些类型,所以我直接用node程序把这个代码删掉了hhh。

之后创建一个声明文件:

import { CommitOptions, DispatchOptions, Payload, Store } from "vuex";
import { TAction, TMutation, TState } from "@/store";
​
type TupleTypeToUnions<T, K = any> = T extends Array<K> ? T[number] : T
​
// 移除指定key,可接受元祖
type ExcludeKey<T, U> = T extends (U extends Array<any> ? TupleTypeToUnions<U> : U) ? never : T;
​
// 移除state与commit、dispatch 方便后续重写 
type CutStore = {
  [K in ExcludeKey<keyof Store<any>, ["state", "commit", "dispatch"]>]: Store<any>[K];
};
​
declare module "vue/types/vue" {
  interface Vue {
    $store: CutStore & {
      commit: {
        // 连带出第二个参数类型
        <R extends keyof TMutation>(
          type: R,
          payload?: TMutation[R],
          options?: CommitOptions
        ): void;
        <P extends Payload>(payloadWithType: P, options?: CommitOptions): void;
      };
    } & {
      dispatch: {
        <R extends keyof TAction>(
          type: R,
          payload?: TAction[R],
          options?: DispatchOptions
        ): void;
        <P extends Payload>(payloadWithType: P, options?: DispatchOptions): Promise<any>;
      }
    } & {
      state: TState;
    };
  }
}
​
declare module "vuex/types/index" {
  interface Commit {
    <K extends keyof TMutation>(
      type: K,
      payload?: TMutation[K],
      options?: CommitOptions
    ): void;
  }
​
  interface Dispatch {
    <K extends keyof TAction>(
      type: K,
      payload?: TAction[K],
      options?: DispatchOptions
    ): Promise<any>;
  }
}

注意commitdispatch的类型,typekeyof TMutation, payload: TMutation[R], 这样才能实现输入函数第一个参数后连带出第二个参数的类型!至此算是完工了。

工具完整代码

type UnionToIntersection<U> = 
  (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never// 推断出函数的参数类型 infer param type of function
type ParamsType<T extends (...args: any[]) => any> = T extends (...args: infer R) => any ? R : any;

export type DealNeverType<T> = [T] extends [never] ? any : ([T] extends [never[]] ? any[] : T);
/**
 * state =  {
 *    arr: []
 * };
 * 这种会被推断为never[]
 * this one will be infered to never[]
 */
export type NonNeverState<R extends object> = {
  [K in keyof R]: R[K] extends (any[] | never) ? 
  DealNeverType<R[K]> : (R[K] extends object ? 
  NonNeverState<R[K]> : DealNeverType<R[K]>);
}
​
// 定义键类型
type KeyType = string | number;
​
// 获取值 get value of an object
type GetValue<T> = T[keyof T];
​
type AddPrefix<Prefix extends KeyType, Keys extends KeyType> = `${Prefix}/${Keys}`;
​
 // 判断是否添加/
type AddSlashe<T> = T extends "" ? T : `${T & string}/`;
​
 // 支持联合类型的字符串模板拼接 support string splicing of union type 
type AddPrefixKeys<P, S extends string> = GetValue<{
  [K in S]: P extends "" ? K : AddPrefix<P & string, K>;
}>
​
// 模块Store的类型,用于继承判断 type of modules's store
type ModuleStoreOptions = { 
  [k: string]: { 
    mutations: { [x: string]: (...args: any[])=> any }; 
    state: any; 
    actions?: { [x: string]: (...args: any[])=> any }; 
    modules?: ModuleStoreOptions 
  } 
};
​
// 获取mutation或action函数第二个参数的类型 get second param type of function which named K
type GetSecondParamType<M, MK extends string, K extends string, KEY = "mutations", E = never> = M extends { 
  [X in MK]: { 
    [X1 in KEY & string]: { 
      [X2 in K]: (s: any, v: infer N)=> any 
    } 
  } 
} ? N | E : never;
​
// 获取modules中的mutation key与第二个参数类型
type GetModuleMutationKeyParamMap<M extends ModuleStoreOptions, P = ""> = UnionToIntersection<GetValue<{
  [R in keyof M]: 
    M[R] extends { modules: ModuleStoreOptions } ? 
    GetValue<{ 
      [K in keyof M[R]["mutations"]]: {
        [K1 in AddPrefixKeys<`${AddSlashe<P>}${R & string}`, K & string>]: GetSecondParamType<M, R & string, K & string>
      } 
    }> & GetMutationKeyParamMap<M[R]["modules"], P extends "" ? R : AddPrefix<P & string, R & string>> : 
    GetValue<{
      [K in keyof M[R]["mutations"]]: {
        [K1 in AddPrefix<`${AddSlashe<P>}${R & string}`, K & string>]: GetSecondParamType<M, R & string, K & string>
      }
    }>
}>>;
​
// 支持直接传入整个vuex store树 解析出mutation的key与函数第二个参数类型
export type GetMutationKeyParamMap<M extends (ModuleStoreOptions | GetValue<ModuleStoreOptions>), P = ""> = 
    M extends ModuleStoreOptions ? 
    GetModuleMutationKeyParamMap<M, P> : 
    M extends GetValue<ModuleStoreOptions> ? 
    (
      { [K in keyof M["mutations"]]: ParamsType<M["mutations"][K & string]>[1] } &
      (
        M extends { modules: ModuleStoreOptions } ? 
        GetModuleMutationKeyParamMap<M["modules"]> : 
        {}
      )
    ) : never;
​
// 获取模块下的action的key与函数第二个参数map
type GetModuleActionKeyParamMap<M extends ModuleStoreOptions, P = ""> = UnionToIntersection<GetValue<{
  [R in keyof M]: 
    M[R] extends { modules: ModuleStoreOptions } ? 
    GetValue<{ 
      [K in keyof M[R]["actions"]]: {
        [K1 in AddPrefixKeys<`${AddSlashe<P>}${R & string}`, K & string>]: GetSecondParamType<M, R & string, K & string, "actions">
      } 
    }> & GetActionKeyParamMap<M[R]["modules"], P extends "" ? R : AddPrefix<P & string, R & string>> : 
    GetValue<{
      [K in keyof M[R]["actions"]]: {
        [K1 in AddPrefix<`${AddSlashe<P>}${R & string}`, K & string>]: GetSecondParamType<M, R & string, K & string, "actions">
      }
    }>
}>>;
​
// 支持直接传入整个vuex store树 解析出action的key与函数第二个参数类型
export type GetActionKeyParamMap<M extends (ModuleStoreOptions | GetValue<ModuleStoreOptions>), P = ""> = 
    M extends ModuleStoreOptions ? 
    GetModuleActionKeyParamMap<M, P> : 
    M extends Required<GetValue<ModuleStoreOptions>> ? 
    (
      { [K in keyof M["actions"]]: ParamsType<M["actions"][K & string]>[1] } &
      (
        M extends { modules: ModuleStoreOptions } ? 
        GetModuleActionKeyParamMap<M["modules"]> : 
        {}
      )
    ) : never;
​
// 模块state的类型 会递归拿到嵌套模块的state类型
type GetSubState<M extends ModuleStoreOptions> = {
  [K in keyof M]?: M[K] extends { modules: ModuleStoreOptions } ? 
    M[K]["state"] & GetSubState<M[K]["modules"]> : 
    M[K]["state"]
} 
​
// 获取根据传入的vuex store 获取state
export type GetState<M extends (ModuleStoreOptions | GetValue<ModuleStoreOptions>)> = M extends ModuleStoreOptions ? 
  GetSubState<M> : (
    { [K in keyof M["state"]]: M["state"][K] } & (
    M extends { modules: ModuleStoreOptions } ? 
    GetState<M["modules"]> : 
    {}
  )
);

总结

这个文章也是断断续续写的,写完感觉乱七八糟。以上是大概实现,实际上还有很多坑没填,比如namespaced设置为false的没有单独处理,又比如commit与dispatch只支持了一种写法,再比如要通过删除库源码的一部分才能实现(不过好在ts只是元编程基本不涉及数据逻辑),再再比如getters,mapMutations等等。。。最后的最后,这整套我有整合发布到npm上,使用npm i vuex-with-type安装,npx vwt init使用。掘金写文章,发npm包都是第一次,如果本文有人看,希望能留下评论批评指点一下。