玩转 TS - 实现 dva 的完整类型推导
前言
在 TypeScript 4.1 来临之前,对于像 dva、vuex 这种需要在触发时写入命名空间的函数,我们无奈的只能使用 any 对其进行类型定义。
dispatch({
type: "users/getUser",
payload: "...", // any
})这使得项目中本应良好的 TS 类型推导出现了断层,社区中也有相关的解决方案,但都是通过更加复杂类型、函数封装进而实现的,与官方写法大相径庭。
好在,TypeScript 4.1 带来了 Template Literal Types 特性,是我们可以对类型进行字符串拼接操作,从而使得此类函数的类型推导称为现实。
本文将带你一一讲解具体的推导过程,希望看完之后会有收获。
同时,本文的最终实现已经发布了 npm 包:dva-type,可以在项目中直接安装使用。
Dva 基本使用
写代码之前先让我们回顾一下 dva 的基本使用,也好让我们知道自己最终要实现什么。
Model 定义
dva 中通过定义 model 来声明各模块的状态,其中 reducers 就是 redux 的 reducers,effects 就是用来执行异步操作的地方,在 effect 中最终也会通过 reducers 将状态更新到 state 中。
cosnt model = {
state: {},
effects: {
getList() {}
}
reducers: {
merge() {}
}
}基本使用
使用方法同 redux
- 使用
connect高阶函数或者useSelector来获取state - 使用
connect或者useDispatch拿到dispatch函数
connect((state) => ({
userInfo: state.users.info,
}))
// 类型断层
dispatch({
type: "users/getUser",
payload: false,
})类型断层主要在于 dispatch 时的 action 类型无法推导,state 的类型提示则没有问题,而 action 之所以会失效主要在于参数 type 需要拼接命名空间。
所以我们要解决的其实就是拼接命名空间之后的类型提示和推导,而这在 Template Literal Types 特性出现之后,就使得解决方案变得异常简单与自然。
Dva-type
开始 dva-type 的源码解析之前,先看一下它是如何使用的
Dva-type 使用
-
定义单个
Model类型(注意Model、Effect不是从dva中导入的)import { Effect, Model } from "dva-type" interface ListModel extends Model { state: { list: any[] } effects: { // 定义effect 传入 payload 类型 getList: Effect<number> // 不需要 payload 的 effect getInfo: Effect } } -
定义项目中所有
Model的集合(使用type而不是interface)// 使用 type 定义 models,将项目中的所有 model 进行收集 type Models = { list: ListModel info: InfoModel // ... } -
将
Models传入ResolverModels获取state和actions的类型import { ResolverModels } from "dva-type" type State = ResolverModels<Models>["state"] type Actions = ResolverModels<Models>["actions"] -
使用
// hooks useSelector<State>() const dispatch = useDispatch<(action: Actions) => any>() // class const mapStateToProps = (state: State) => {} interface Props { dispatch: (action: Actions) => any }
Dva-type 源码解析
从上面的使用中可以看到,一切的秘密都在 ResolverModels 这个类型中,下面我们就看看其实现
interface ResolverModels<T extends Record<string, Model>> {
state: ResolverState<T> & Loading<T>
actions: ResolverReducers<T> | ResolverEffects<T>
}提取 State
state 的解析很简单,使用 keyof 遍历 models 的 state 定义即可。
type ResolverState<T extends Record<string, Model>> = UnionToIntersection<{
[k in keyof T]: T[k]["state"]
}>这是基本操作,让我们大致过一下这个过程发生了什么
T是我们传入的Models类型定义[k in keyof T]相当于遍历了T的键:list、infoT[k]['state']相当于:T['list']['state’]、T['info']['state’]
这样就把 state 的类型给推导出来了,但是推导出来的类型是联合类型,我们还需要将其转换为交叉类型才能正确进行类型提示。
联合类型转换交叉类型
而将联合转换为交叉类型则是网上找到的黑魔法:
type UnionToIntersection<U> =
(U extends any ? (k: U) => void : never)
extends (k: infer I) => void
? I
: never具体的深层原理我也没有搞懂,但是可以看一下具体做了那些事情:
U extends any ? (k: U) => void : neverextends any这个条件永远是true,所以这里就是把传入的类型U变为了 函数类型:(k: U) => void
extends (k: infer I) => void- 第一步我们把类型变为了
(k: U) => void,所以这里的extends的判断结果肯定也是true。 - 注意
infer I,这将类型U重新做了推断,就是这一步使联合类型变为了交叉类型。
- 第一步我们把类型变为了
? I : never- 很明显,根据第一个和第二步,这里的三元表达式永远都会返回
I。 - 至此,联合类型被转换为了交叉类型。
- 很明显,根据第一个和第二步,这里的三元表达式永远都会返回
提取 Actions
dva 提供的 Effect 类型不能传入 payload 的类型定义,所以这里我们需要封装一个 Effect 出来:
type Effect<P = undefined> = (
action: { type: any; payload?: P },
effect: EffectsCommandMap
) => void解析 effects 类型
type ResolverEffects<T extends Record<string, Model>> = ValueType<{
[t in keyof T]: ValueType<{
[k in keyof T[t]["effects"]]: T[t]["effects"][k] extends (
action: { type: any; payload?: infer A },
effect: EffectsCommandMap
) => void
? A extends undefined
? {
type: `${t}/${k}`
[k: string]: any
}
: {
type: `${t}/${k}`
payload: A
[k: string]: any
}
: never
}>
}>代码一大坨,按流程走一遍:
-
T依然是传入的Models类型 -
[t in keyof T]同state,不再赘述。 -
[k in keyof T[t]['effects']],这一步就是将每个model中定义的effect进行了遍历,相当于:Models['list']['effects']['getList’]、Models['info']['effects']['getInfo’] -
T[t]['effects'][k] extends (action: { type: any; payload?: infer A },effect: EffectsCommandMap) => voidextends后面的函数类型与我们定义的Effect类型一致- 注意
extends ... payload?: infer A …,这里将payload的类型提取了出来
-
A extends undefined,这一步是为了判断effect是否需要传入payload,如果不需要则不需要在类型中体现 -
{ type: `${t}/${k}` payload: A }payload: A这个就是将推导出来的类型又赋值回去了- type: ${t}/${k},其中
t表示了命名空间,k表示了effect的名称:type: 'list/getList'
-
至此,类型已经推导出来了,但是格式却不是我们想要的:
{ list: { getList: { type: 'list/getList', payload: number } } } -
我们只想要最里面的
{type: .. payload ..}部分,所以这里要做的就是将value的类型提取出来,做法也非常简单:T[keyof T],遍历并访问类型的键就可以将值类型全部取出。 -
将其简单封装,就是最外层的
ValueType的作用了。
effects 的类型提取出来了,reducers 也是同样的做法,就不赘述了。
Dva-loading 类型
dva-loading 中可以根据 effects 提供 loading 变量,我们解析了 effects 之后,loading 的变量提示也是顺其自然了
interface Loading<T extends Record<string, Model>> {
loading: {
global: boolean
models: {
[k in keyof T]: boolean
}
effects: {
[k in ResolverEffects<T>["type"]]: boolean
}
}
}End
OK,感谢看到这里,希望看完之后对你有所提升,