xingbofeng.github.io icon indicating copy to clipboard operation
xingbofeng.github.io copied to clipboard

TypeScript 4.1 类型模板字符串实现Vuex的store.commit和store.dispatch类型判断

Open xingbofeng opened this issue 3 years ago • 0 comments

本文是在掘金的这篇文章(TS 4.1 新特性实现 Vuex 无限层级命名空间的 dispatch 类型推断。)的基础上进一步的实现,在阅读本文之前,可以先到掘金看看这篇文章。

TypeScript 4.1 类型模板字符串

先来看看TypeScript 4.1 beta 版本

首先在项目中安装 TypeScript 4.1 Beta 版本:

npm install typescript@beta --save-dev

可以在任何地方使用类型模板字符串:

1、直接使用

type World = "world";
type Greeting = `hello ${World}`;

上面的例子等价于:

type World = "world";
type Greeting = "hello world";

2、在类型映射中使用

type Options = {
    [K in "noImplicitAny" | "strictNullChecks" | "strictFunctionTypes"]?: boolean
};

上面的例子等价于:

type Options = {
    noImplicitAny?: boolean,
    strictNullChecks?: boolean,
    strictFunctionTypes?: boolean
};

3、用于联合类型

type Color = "red" | "blue";
type Quantity = "one" | "two";

type SeussFish = `${Quantity | Color} fish`;

上面的例子等价于:

type SeussFish = "one fish" | "two fish" | "red fish" | "blue fish";

4、常见的例子:用于事件定义

假设有一个函数makeWatchedObject,用于遍历一个对象,生成一个格式相同的对象,但是每次改变此对象属性时会跑出对应的事件,使用一个新的on方法来检测属性的更改:

let person = makeWatchedObject({
    firstName: "Homer",
    age: 42,
    location: "Springfield",
});

person.on("firstNameChanged", () => {
    console.log(`firstName was changed!`);
});

对于需要监听的事件名,可以用类型模板字符串实现:

type PropEventSource<T> = {
    on<K extends string & keyof T>
        (eventName: `${K}Changed`, callback: (newValue: T[K]) => void ): void;
};

declare function makeWatchedObject<T>(obj: T): T & PropEventSource<T>;

let person = makeWatchedObject({
    firstName: "Homer",
    age: 42,
    location: "Springfield",
});

person.on("firstNameChanged", newName => {
    console.log(`new name is ${newName.toUpperCase()}`);
});

person.on("ageChanged", newAge => {
    if (newAge < 0) {
        console.log("warning! negative age");
    }
});

5、字符串转换工具函数

type EnthusiasticGreeting<T extends string> = `${Uppercase<T>}`

type HELLO = EnthusiasticGreeting<"hello">;

上面的例子等价于:

type HELLO = "HELLO";

Uppercase外,还有LowercaseCapitalizeUncapitalize等工具函数

6、类型映射中使用 as 子句映射类型模板字符串生成的新key

type Options = {
    noImplicitAny?: boolean,
    strictNullChecks?: boolean,
    strictFunctionTypes?: boolean
};

type ChangedOptions = {
    [K in keyof Options as `Changed${Capitalize<keyof Options>}`]?: Options[K]
};

等价于:

type Options = {
    noImplicitAny?: boolean,
    strictNullChecks?: boolean,
    strictFunctionTypes?: boolean
};

type ChangedOptions = {
    ChangedNoImplicitAny?: boolean,
    ChangedStrictNullChecks?: boolean,
    ChangedStrictFunctionTypes?: boolean
};

问题描述

看下面这个例子,这是最常见的一个 Vuex 初始化方法:

const vuexOptions = {
    state: {},
    getters: {},
    actions: {
        async rootAction(actionsContext: ActionContext<{}, {}>, payload: string) { },
    },
    mutations: {
        rootMutation(state: {}, context: number) { },
    },
    modules: {
        home: {
            actions: {
                async homeAction(actionsContext: ActionContext<{}, {}>, homeContext: string) { },
            },
            mutations: {
                homeMutation(state: {}, homeContext: number) { },
            },
        },
        detail: {
            actions: {
                async detailAction(actionsContext: ActionContext<{}, {}>, detailContext: string) { },
            },
            mutations: {
                detailMutation(state: {}, detailContext: number) { },
            },
        },
    },
    plugins: process.env.NODE_ENV === 'development' ? [createLogger()] : [],
};

const store = new Vuex.Store(vuexOptions);

export default store;

我们希望实现store.dispatch时,可以出现智能提示和类型提示,如下图所示:

那么问题描述变为通过vuexOptions,拿到一个action名称到actionpayload类型和dispatch返回值的映射,等价于拿到如下类型:

type Actions = {
    "home/homeAction": (homeContext: string) => Promise<void>;
    "detail/detailAction": (detailContext: string) => Promise<void>;
    rootAction: (payload: string) => Promise<void>;
}

type Mutations = {
    "home/homeMutation": (homeContext: number) => void;
    "detail/detailMutation": (detailContext: number) => void;
    rootMutation: (context: number) => void;
}

通过上面的ActionsMutations,我们可以很轻松地通过infer拿到action名称、payload类型与dispatch返回值类型,即推断actionFunction的函数签名。

推断 actionFunction 的函数签名

我们知道在Vuex初始化时,action一般是如下定义的:async homeAction(actionsContext: ActionContext<{}, {}>, homeContext: string) {},它的类型会被推断为:rootAction(actionsContext: ActionContext<{}, {}>, payload: string): Promise<void>

因此我们这里需要过滤掉第一个参数ActionContext,通过infer就可以实现:

type GetRestFuncType<T> = T extends (context: any, ...params: infer P) => infer R ? (...args: P) => R : never;

获取root模块的Actions和Mutations映射

type GetActionsTypes<Module> = Module extends { actions: infer M } ? {
    [ActionKey in keyof M]: GetRestFuncType<M[ActionKey]>
} : never;

1、首先通过infer关键字拿到单个模块的所有actions的类型 2、再通过in关键字对actions的keys做类型映射 3、通过我们上面定义好的GetRestFuncType拿到 actionFunction 的函数签名

获取任一模块的Actions和Mutations映射

但是,上述的实现并不完善,对于非root模块,例如detail模块,例如想要dispatch detail模块的 detailAction时,就要用到模板字符串拼接为detail/detailAction

type AddPrefix<Keys, Prefix = ''> = `${Prefix & string}${Prefix extends '' ? '' : '/'}${Keys & string}`;

利用联合类型的自动展开,即可将GetActionsTypes通用化到非root模块:

type GetActionsTypes<Module, ModuleName = ''> = Module extends { actions: infer M } ? {
    [ActionKey in keyof M as AddPrefix<ActionKey, ModuleName>]: GetRestFuncType<M[ActionKey]>
} : never;

可以测试下如下代码:

type GetRestFuncType<T> = T extends (context: any, ...params: infer P) => infer R ? (...args: P) => R : never;

type AddPrefix<Keys, Prefix = ''> = `${Prefix & string}${Prefix extends '' ? '' : '/'}${Keys & string}`;

type GetActionsTypes<Module, ModuleName = ''> = Module extends { actions: infer M } ? {
    [ActionKey in keyof M as AddPrefix<ActionKey, ModuleName>]: GetRestFuncType<M[ActionKey]>
} : never;

const module = {
    actions: {
        async homeAction(actionsContext: ActionContext<{}, {}>, homeContext: string) { },
    }
};

type Actions = GetActionsTypes<typeof module, 'home'>;

拿到所有模块的Actions和Mutations映射

type GetModulesActionTypes<Modules> = {
    [K in keyof Modules]: GetActionsTypes<Modules[K], K>
}[keyof Modules];

type GetSubModuleActionsTypes<Module> = Module extends { modules: infer SubModules } ? GetModulesActionTypes<SubModules> : never;

1、对于 root 模块而言,所有带命名空间的子模块都是放到modules属性下面,同样可以通过infer关键字拿到modules下所有模块的类型 2、首先定义GetModulesActionTypes,通过in关键字对keyof Modules做类型映射,这样将拿到所有定义到modulesActionsMutations映射 3、然后定义GetSubModuleActionsTypes,通过infer拿到SubModules类型,并通过GetModulesActionTypes拿到ActionsMutations映射

测试以下代码:

type GetRestFuncType<T> = T extends (context: any, ...params: infer P) => infer R ? (...args: P) => R : never;

type AddPrefix<Keys, Prefix = ''> = `${Prefix & string}${Prefix extends '' ? '' : '/'}${Keys & string}`;

type GetActionsTypes<Module, ModuleName = ''> = Module extends { actions: infer M } ? {
    [ActionKey in keyof M as AddPrefix<ActionKey, ModuleName>]: GetRestFuncType<M[ActionKey]>
} : never;

type GetModulesActionTypes<Modules> = {
    [K in keyof Modules]: GetActionsTypes<Modules[K], K>
}[keyof Modules];

type GetSubModuleActionsTypes<Module> = Module extends { modules: infer SubModules } ? GetModulesActionTypes<SubModules> : never;

const vuexOptions = {
    state: {},
    getters: {},
    actions: {
        async rootAction(actionsContext: ActionContext<{}, {}>, payload: string) { },
    },
    mutations: {
        rootMutation(state: {}, context: number) { },
    },
    modules: {
        home: {
            actions: {
                async homeAction(actionsContext: ActionContext<{}, {}>, homeContext: string) { },
            },
            mutations: {
                homeMutation(state: {}, homeContext: number) { },
            },
        },
        detail: {
            actions: {
                async detailAction(actionsContext: ActionContext<{}, {}>, detailContext: string) { },
            },
            mutations: {
                detailMutation(state: {}, detailContext: number) { },
            },
        },
    },
};

type AllActionsUnion = GetSubModuleActionsTypes<typeof vuexOptions>;

此时,AllActionsUnion的类型为:

type AllActionsUnion = {
    "home/homeAction": (homeContext: string) => Promise<void>;
} | {
    "detail/detailAction": (detailContext: string) => Promise<void>;
}

显然我们希望得到的是这样的类型:

type AllActionsUnion = {
    "home/homeAction": (homeContext: string) => Promise<void>;
    "detail/detailAction": (detailContext: string) => Promise<void>;
}

这里我们需要将联合类型转化为交叉类型,我们可以利用Conditional Types in TypeScript

什么意思呢?对于T extends U ? X : Y,当T是联合类型时,例如A | B | CT extends U ? X : Y会被自动展开为:(A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y),利用这个特性,配合infer关键字可以实现联合类型转交叉类型:

type UnionToIntersection<T> = (T extends any ? (k: T) => void : never) extends (k: infer I) => void ? I : never;

上面的UnionToIntersection可以理解为:

TA | B | C时:

(T extends any ? (k: T) => void : never)会被展开为(A extends any ? (k: A) => void : never) | (B extends any ? (k: B) => void : never) | (C extends any ? (k: C) => void : never),即(k: A) => void | (k: B) => void | (k: C) => void,显然(k: A) => void | (k: B) => void | (k: C) => void会被(k: A & B & C) => void类型约束,那么使用infer就可以解出A & B & C了。

通过 actionFunction 的函数签名拿到 payload 和 返回值

1、拿到payload:定义一个GetParam泛型,通过infer可推断出函数的第一个参数:

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

2、拿到返回值:通过 TypeScript 内置的ReturnType可实现

这里可以直接通过TypeScript的工具函数ReturnType拿到,其原理也不复杂,就是通过infer关键字:

/**
 * Obtain the return type of a function type
 */
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

完整的实现

通过上面的方法,就可以实现Vuexstore.commitstore.dispatch类型判断,完整代码如下:

type GetRestFuncType<T> = T extends (context: any, ...params: infer P) => infer R ? (...args: P) => R : never;

type AddPrefix<Keys, Prefix = ''> = `${Prefix & string}${Prefix extends '' ? '' : '/'}${Keys & string}`;

type GetMutationsTypes<Module, ModuleName = ''> = Module extends { mutations: infer M } ? {
    [MutationKey in keyof M as AddPrefix<MutationKey, ModuleName>]: GetRestFuncType<M[MutationKey]>
} : never;

type GetActionsTypes<Module, ModuleName = ''> = Module extends { actions: infer M } ? {
    [ActionKey in keyof M as AddPrefix<ActionKey, ModuleName>]: GetRestFuncType<M[ActionKey]>
} : never;

type GetModulesMutationTypes<Modules> = {
    [K in keyof Modules]: GetMutationsTypes<Modules[K], K>
}[keyof Modules];

type GetModulesActionTypes<Modules> = {
    [K in keyof Modules]: GetActionsTypes<Modules[K], K>
}[keyof Modules];

type GetSubModuleMutationsTypes<Module> = Module extends { modules: infer SubModules } ? GetModulesMutationTypes<SubModules> : never;

type GetSubModuleActionsTypes<Module> = Module extends { modules: infer SubModules } ? GetModulesActionTypes<SubModules> : never;

type UnionToIntersection<T> = (T extends any ? (k: T) => void : never) extends (k: infer I) => void ? I : never;

type GetMutationsType<R> = UnionToIntersection<GetSubModuleMutationsTypes<R> | GetMutationsTypes<R>>;

type GetActionsType<R> = UnionToIntersection<GetSubModuleActionsTypes<R> | GetActionsTypes<R>>;

type GetParam<T> =
    T extends () => any ? undefined :
    T extends (arg: infer R) => any ? R : any;

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

type GetPayLoad<T, K extends keyof T> = GetParam<GetTypeOfKey<T, K>>;

type GetReturnType<T, K extends keyof T> = ReturnType<GetTypeOfKey<T, K>>;

const vuexOptions = {
    state,
    getters,
    actions,
    mutations,
    modules: {
        home,
        detail,
    },
    plugins: process.env.NODE_ENV === 'development' ? [createLogger()] : [],
};

type Mutations = GetMutationsType<typeof vuexOptions>;

type Actions = GetActionsType<typeof vuexOptions>;

declare module 'vuex' {
    export interface Commit {
        <T extends keyof Mutations>(type: T, payload?: GetPayLoad<Mutations, T>, options?: CommitOptions): GetReturnType<Mutations, T>;
    }
    export interface Dispatch {
        <T extends keyof Actions>(type: T, payload?: GetPayLoad<Actions, T>, options?: DispatchOptions): Promise<GetReturnType<Actions, T>>;
    }
}

const store = new Vuex.Store<RootState>(vuexOptions);

小结

上面是笔者在Vuex中实践TypeScript模板字符串的总结,笔者也是顺手发布了一个npm 包,感兴趣的小伙伴看看vuex-typescript-commit-dispatch-prompt

使用方法

1、首先安装依赖,需要安装TypeScript 4.1 以上版本

npm install typescript@beta --save-dev
npm i vuex-typescript-commit-dispatch-prompt --save

2、在初始化store处引入vuex-typescript-commit-dispatch-prompt,然后拓展vuex module 的类型定义

import Vuex from 'vuex';
import createLogger from 'vuex/dist/logger';
import { GetActionsType, GetMutationsType, GetPayLoad, GetReturnType } from 'vuex-typescript-commit-dispatch-prompt';

const vuexOptions = {
    state,
    getters,
    actions,
    mutations,
    modules: {
        home,
        detail,
    },
    plugins: process.env.NODE_ENV === 'development' ? [createLogger()] : [],
};

type Mutations = GetMutationsType<typeof vuexOptions>;

type Actions = GetActionsType<typeof vuexOptions>;

declare module 'vuex' {
    export interface Commit {
        <T extends keyof Mutations>(type: T, payload?: GetPayLoad<Mutations, T>, options?: CommitOptions): GetReturnType<Mutations, T>;
    }
    export interface Dispatch {
        <T extends keyof Actions>(type: T, payload?: GetPayLoad<Actions, T>, options?: DispatchOptions): Promise<GetReturnType<Actions, T>>;
    }
}

const store = new Vuex.Store<RootState>(vuexOptions);

文章最后,笔者近期维护了一个公众号,用于分享前端新技术,欢迎大家到Counter的前端小站逛逛,一起关注前端的新技术发展。

xingbofeng avatar Oct 04 '20 11:10 xingbofeng