Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
xingbofeng opened this issue Oct 4, 2020 · 0 comments

Comments

@xingbofeng
Copy link
Owner

xingbofeng commented Oct 4, 2020

本文是在掘金的这篇文章(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的前端小站逛逛,一起关注前端的新技术发展。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant