【Rematch 源码系列】六、Rematch type system

Rematch 源码解读系列的第 6️⃣ 篇,也是最后一篇,关于 Rematch 的 TS 类型系统。
系列的最后一篇,让我们来聊聊 Rematch 背后的类型系统,这是我在 Rematch 团队的主要贡献,重构它的时候遇到了不少问题,有一些得到了解决,有一些权衡之后采取了”独特“的设计,有一些是因为 TS 的语言限制,还有一些目前我未能解决。在这篇文章中,我会把上面的问题抛出来和大家一起探讨。
由于相关代码较多,下面不一定全贴出来,点此查看全部代码
1 核心类型
1.1 Model
Rematch 中有一个很重要的概念叫做 Model,在深入 Rematch 类型系统之前,我们需要先了解它,下面是其定义:
| |
我们主要关注的是 state,reducers 和 effects 三个。state 比较简单,来看看后两个。
1.2 ModelReducers
| |
ModelReducers 中要注意的是 state 类型信息的传递,因为 Reducer 函数的第一个参数为 state。
1.3 ModelEffects 和 ModelEffectsCreator
effects 支持两种类型,分别是纯对象定义 ModelEffects 和 函数 ModelEffectsCreator,我们先看第一个:
ModelEffects
| |
ModelEffects 和 ModelReducers 类似,区别是前者接收的泛型为 TModels,其包含了所有 Model 的信息,我们需要借助 RematchRootState 这个类型从中提取全局的 rootState,作为 ModelEffect 的第二个参数。而后者仅仅需要对应的 model state 信息即可。我们下面会具体讲 RematchRootState。
此外,ModelEffect 还将上下文的 this 绑定到了 dispatch[currentModel],因此可以使用 this[reducerName | effectName] 的形式来派发 action。不过这部分的类型推断存在问题,我会在最后的问题汇总部分说明。
ModelEffectsCreator
| |
除了 ModelEffects 这种方式,effects 还可以定义为一个函数,参数为 dispatch,返回值为 ModelEffects。这样一来,在 effects 中不仅可以使用 上下文 this 来派发当前 model 的 actions,还可以使用 dispatch 派发所有 model 的 actions。关于 RematchDispatch,也放在下面讲。
1.4 RematchRootState
了解 Redux 的都知道其核心就两个部分,一个是负责派发 action 的 dispatch,另外一个则是全局 RootState。而我们在前面学习 Rematch Model 时,出现了两个类型,RematchRootState 和 RematchDispatch,这一部分我们来详细说说。
先看看 RematchRootState 的类型定义:
| |
简单来说就是获取各个 Model 的 state,以 modelName 作为 key,合并起来。其中,model 包含了用户自定义的 model,也包含了使用的插件导出的 model(例如使用 loading 插件就导出了 loading Model)。
1.4.1 关于泛型 TModels 和 TExtraModels
细心的同学也许发现上面的很多类型都使用了两个泛型参数,分别是 TModels 和 TExtraModels,在 Rematch 的类型系统中,这两个泛型参数会被广泛使用。其中,TModels 是必填的,表示用户自定义的 Model,而 TExtraModel 是选填的,如果用户使用了插件且插件有导出 Model,用户就需要加上这个。
最开始的时候,我为两个泛型参数都设置了默认值 {},但是 {} 并不意味着空类型,而是任意的非空值,因此要避免使用它。我改成了 Record<string, any>,不过这样也是类型不安全的。直到最后,我考虑到 TModels 其实是必填的(用户既然使用 Rematch 就肯定需要定义 Model),所以我删除了 TModels 类型参数的默认值,把 TExtraModel 的默认值改为 Record<string, never>。其实使用 Record<string, unknown> 也是类型安全的,但 Record<string, unknown> 不满足 extends Models<TModels> 的约束(因为 unknown 肯定无法赋值给 Model),所以换成了 never。
1.5 RematchDispatch, the hybrid ReduxDispatch
再来看看 RematchDispatch:
| |
RematchDispatch 是 Rematch 的核心,整个类型推断较为复杂,因此这里我做了一些简化。首先,Rematch 的 dispatch 是基于 Redux dispatch 的一个复合类型。因此使用了 ReduxDispatch & ...
其次,它需要从 effects 和 reducers 配置中分别提取对应的 actions。由于 reducer 的第一个参数为 model state,因此将 TModel['state'] 信息传入。
最后,使用 MergeExclusive 来合并 reducerActions 和 effectActions。一开始 Rematch 直接使用 & 操作符合并,但由于 Rematch 内置的每一个 action 都会被附带上 { isEffect: boolean } 这样的信息,因此如果 reducer 和 effect 同名,则会出现类型不兼容的问题(因为没有类型能同时兼容 { isEffect: true } 和 { isEffect: false }),举个简单例子:
| |
其实 effect 和 reducer 即使同名,Rematch 代码层面也是支持的,这里先简单说下类型兼容的问题,后面关于这部分我还会继续和大家讨论。
2 createModel helper function
除了上述核心类型,在 Rematch 中我还设计了一个工具函数 createModel,这个函数没有什么实际作用,只用来完善类型,以此减少用户手动添加,下面是相关代码:
| |
这个函数参数为空,调用后的返回值也是一个函数,此函数参数为 Model 对象,并返回该 Model 对象自身,整个保持 Model 的属性类型不变。主要功能如下:
- 通过定义
state的类型,打通reducers中单个 reducer 第一个参数的类型,无需重复定义 - 通过传入
RootModel泛型参数,自动推断出effects中第一个参数dispatch类型 - 通过传入
RootModel泛型参数,自动推断出effects中单个 effect 的第二个参数rootState类型
最开始我的 ModelCreator 是这样的:
| |
虽然简洁了很多,但是上面第一点无法满足,因为 state 类型并没打通。然后我改成了:
| |
但是上面这两种都无法保证返回值类型被推断为用户定义的 Model 类型,第一个的返回值类型 M 直接推断成 Model<RootModel, ModelState>。第二种虽然属性罗列出来了,但是单个属性比如 reducers 类型就是 ModelReducers<ModelState> 。也就是说,具体的单个 reducer 例如上面例子中的 SET_PLAYERS 类型没有没推导出来,上下文类型丢失。
所以最后我才采取了全展开的方式,这样一来,所有功能均可实现。
虽然功能实现了,乍一看这个函数,可能有同学会觉得奇怪:为什么使用了两个函数?
其实这是因为目前 TS 还不支持部分类型参数推断,也就是说,如果函数指定了多个泛型参数,在调用这个函数时,要么全部由用户传参,要么参数全交给 TS 自动推断。
所以我才设计了两个函数的方案,第一个函数提供给用户传递指定泛型参数,第二个函数用户则无需指定,交给 TS 自动推断。有趣的是,设计之初我并不知道什么是「部分类型参数推断」,也并不知道这种“双函数”的设计有助于解决这个问题,后来才发现这个 PR,甚至回复里面还专门有人提到了这个设计。
最后,可能有人会问为什么不单独定义一个函数类型,而是使用了 ModelCreator 这个 interface 定义。这是为了支持不同模块下的函数重载,将函数使用 interface 表示后,便可以使用 Module Augmentation 了,后面会单独讲讲这个方案。
3 Circularly Reference
前面提到 Model 类型的时候,大家有没有注意到一个细节:
| |
仔细看上面的 Models 泛型参数 TModels 的约束条件为 Models<TModels>。是不是有点迷惑,其实这也是我无意之中设计的,最开始的时候,我是直接使用了默认参数:
| |
但上面毕竟使用了 any,作为一个严格要求自己的”体操队员“,我当然要减少 any 出现的情况,因此我打算将其改为 never 或 unknown。但是我很快又发现了问题:
如果 Model 的泛型参数 TModels 使用 Record<string, never>:那么作为 Model 属性之一的 effects 类型则为 ModelEffectsCreator<Record<string, never>>,又由于 ModelEffectsCreator 是一个函数,其参数便推断为 RematchDispatch<Record<string, never>>。
在我前面的文章中提到过,函数参数兼容是逆变的,因此此处只需要判断RematchDispatch<Record<string, never>> 是否兼容 RematchDispatch<TModels>(即后者是否可以赋值给前者)。
我们继续对 RematchDispatch 进行解析,其中需要从 Model 中提取 effects 信息:
| |
而由于此时的 Model 为 never,那么 effects 也会是 never,因此上面的索引 effectKey 也会为 never。而我们知道,never 无法兼容除了其自身的任何类型,也就是说除了它自身的任何类型都无法赋值给它,因此上面的 RematchDispatch<TModels> 就无法赋值给 RematchDispatch<Record<string, never>>。所以最终会报索引签名的兼容性错误,下面是错误栈:
| |
既然 never 行不通,换成 unknown 呢?
| |
显然这里使用 unknown 甚至无法满足 extends Models 的约束,因为 unknown 是肯定不能赋值给 Model 的。
最后,由于我始终认为用户既然使用 Rematch,肯定会定义 TModels,因此这个泛型参数其实是不需要有默认值的。所以我删除了默认值,但此时没有了默认值,extends Models 肯定是不行了,我就碰巧改成了 extends Models<TModels>,最终就变成了下面这种看起来有些奇怪的循环引用模样:
| |
虽然一切都正常了,但我其实并不了解这种写法,只知道它可以解决我当下的问题。说实话,如果不是写这篇文章,这个地方我可能不会深入研究,但通过写作可以倒逼我去了解这种写法。搜索的过程中,还发现了一个不错的解释。
回到我的问题上来,为什么我会使用这样的方法?首要原因是我需要创建约束,前面提到,Model 需要获取所有的 models 信息,而每一个单独的 Model 又作为 Models 的一个属性,因此为了打通类型,我需要给 Models 也加上 TModels 的泛型参数,而且 TModels 需要满足约束,因为它肯定也是 Models 子集,它代表着用户自定义的全局 models 类型。所以最终 TModels 的约束就为 extends Models<TModels>。可能还是有点绕口,下面我举一个实际例子:
| |
把 TModels 换成 RootModel 代入,是不是好理解很多。这里我其实就是想保证 TModels 为 Models 的子集(也就是用户实际创建的 RootModel),而不是 Models 自身。如果还难以理解,可以直接查看该回答,回答中的例子应该更恰当一些。
答主还提到,TS 支持多态 this,因此我这里的定义其实可以绕开繁琐且难以理解的“循环”,改为更优雅的方式:
| |
这里的 this 就也能表示用户自定义的 models 了。值得注意的是这与直接替换成 Models 不同,如果使用 Model<Models>,而此时的 Models 无法表示用户自定义的 models,它的 key 都是 string 类型,这样传入给 Model 是没有实际意义的。
当然,上面这种方法我还没实践,后期我会找个时间提出一个 PR,来继续重构这部分代码。
4 Module Augmentation
TS 里有一个功能叫做声明合并,针对第三方的模块和全局环境,分别有 Module Augmentation 和 Global Augmentation 两种合并模式,Augmentation 有「增强,增加」的意思,表示扩展模块或全局的功能(类型层面)。
Global Augmentation 这里简单提一下,如果在模块中(文件包含 import, export 关键字),需要将额外的声明放入 declare global {} 下面:
| |
而如果本来就是全局脚本文件,则无需添加 declare global {} 块。
这里主要讲讲 Module Augmentation,Rematch 有 3 个地方使用了这种模式,用来消除类型不兼容。
4.1 @rematch/core
首先是 declare module '@rematch/core' {},在 select 和 typed-state 插件中均有使用。先来看看 select 插件:
| |
select 插件在 Model 中增加了一个 selectors 属性,同时导出了一个 select 函数,挂在 RematchStore 上。
在前面讲解 createModel helper 时,提到了 ModelCreator 为什么定义成了一个 interface。由于 select 插件是支持在 model 里定义 selectors 的,所以这里可以方便地利用 Module Augmentation 进行函数重载。
typed-state 插件主要是提供给以纯 JS 使用 Rematch 的开发者,通过 typings 配置,方便在开发环境中发现一些错误定义的类型,它也使用了 Module Augmentation:
| |
4.2 redux
@rematch/core 中针对 redux 也使用了 Module Augmentation:
| |
前面提到 RematchDispatch 时,讲到它其实是一个复合类型,由 Rematch 自己的 dispatcher 联合上 ReduxDispatch(& ReduxDispatch),下面是 ReduxDispatch:
| |
而增加上面的定义,并使用 any,可以消除很多源码中的类型报错。对于使用 Rematch 的开发者来说,models 信息都是提前定义好的,可是在源码中只能使用泛型 TModels 表达,这里面有不少错误,有部分我也没发现原因,只是用 any 来避免它们,可以说这部分也算是一个残留的问题,感兴趣的同学可以去看看源码,如果能解决,欢迎 PR。
可能有人会问为什么还要额外定义一个 RematchDispatch,直接使用 Module Augmentation 不也可以吗?这是因为 TS 限制同名的声明需要使用相同的泛型参数,而 RematchDispatch 还需要 TModels 信息,无法和 ReduxDispatch 保持一致。
4.3 react-redux
针对 react-redux 的 connect 方法,Rematch 也做了兼容,由于 connect 只能识别 ReduxDispatch,所以需要对它进行重载,使其也可以支持 RematchDispatch:
| |
通过重载,我们引入了 RM 泛型,它就是 TModels ,这样一来,dispatch 参数便可以兼容了,从而 connect 也兼容了。
这里多提一句,在 Redux 的 StyleGuide 中,更建议使用 hooks,也就是 useSelector 和 useDispatch 来替代 connect,对于 Rematch 也是一样的,Redux 官方也认为 connect 的类型定义实在过于复杂,不易使用,过多的函数重载,可选参数,还需要合并 props 等等,感兴趣可以点击上面的链接去看看。
注意:前面提到同名的声明需要使用相同的泛型参数,不过如果这个声明用于函数重载,里面的重载函数的泛型参数是可以不同的。
5 问题汇总
最后的部分,我挑了几个典型的问题,有一些已经得到了解决,也有部分暂时未能解决。这些问题我都搜索了大量资料,其中还发现了一些 TS 的设计限制(design limitation),它们都十分有趣,抛出来和大家探讨。
5.1 【已解决】dispatcher inference
原始的 PR 请点我查看。
前面提到过 RematchDispatch,这个其实是 Rematch 的一个核心,而且类型的推导也比较复杂。比如我们在 model 中定义的 reducer 有三个参数,分别是 modelState,payload 和 meta,但是 dispatch 调用时只要传递 payload 和 meta 即可。在 effect 中也是三个参数,分别是 payload,rootState 和 meta,在调用时传递 payload 和 meta。
提取参数并生成新的函数定义这一过程看似简单,实则也踩了不少坑。
先看看 reducer 的提取:
| |
注意这里的 RematchDispatcher 使用了 void 作为泛型的默认参数。直到写这篇文章,我才发现一个 bug。因为 void 除了其自身,只能赋值给 any 和 unknown,但是反过来的行为却很怪异:
| |
由于上面的 case1 成立,当 reducer 的第二个参数 payload 被用户自己定义成 any 时,生成的 dispatch 函数会没有参数,这显然不对。
其实,void 类型的兼容性比 never 好,never 只能兼容其自身,但对于 any 则和 void 表现一样,不过使用 [] 以后则不一样:
| |
利用这一点,我后面会提一个 PR 来修复这个问题。
回到正题,推导的思路如下:
如果用户没有定义参数,或者只使用了 state -> 参数为空的 dispatch
否则提取第一个参数
payload和 第二个参数meta:- 如果未定义
meta,即[TMeta] extends [void]:payload可选,即undefined extends TPayload->payload为可选参数的 dispatch- 否则 ->
payload为必选参数的 dispatch
- 否则,提取
TMeta,并判断:- 如果
meta和payload均可选,即[undefined, undefined] extends [TPayload, TMeta]-> 两个参数均可选的 dispatch - 否则,
payload必选,并判断TMeta:TMeta可选,即undefined extends TMeta->payload必选,meta可选的 dispatch- 否则 -> 两个参数均为必选的 dispatch
- 如果
- 如果未定义
可以观察到,上面我们做了一个优化,比如即使用户定义了 (state, payload: number | undefined),在生成 dispatch 函数时,对应的 payload 也会是可选的,这在逻辑上是合理的(这里值得更多讨论,也有部分人认为这种形式的参数应该是必选,哪怕传一个 undefined),但正常的定义还是 payload?: number。
reducer 的推导相对简单,因为 state 作为第一个参数,用户定义的参数都在它后面。但 effect 则复杂一些,主要体现在两个方面:
- effect 可以为一个对象,也就是上面提到的
ModelEffects,还可以为一个函数ModelEffectsCreator - effect 的
rootState参数位于第二个,payload位于第一个,而meta在最后
关于第二点,有人可能会问为什么不统一。因为最初设计的时候,是考虑了实际使用情况的,在 reducer 中,一般都需要拿到当前 modelState,所以把它放在了第一个参数,而在 effect 中,大多时候是只需要 payload 的,因此把 rootState 挪到了中间,而最不常使用的 meta 则都放到最后。
那我们来看看 effect 的推导:
| |
首先是使用 extends (...args: any[]) 来判断 effect 是对象还是函数,如果是函数则需要提取返回值,否则直接使用。重点来看看第二步:这里我使用了一个巧妙的方式,那就是先判断 rootState 参数,如果它为 undefined 说明用户没有定义该参数,则只需要考虑 TRest[0] 也就是 payload 即可。其次核对一下 rootState 的类型,这里为什么使用 RematchRootState<TModels> extends TRest[1] 而不是反过来呢?因为 rootState 这里作为第二个参数,存在一种情况:用户可以将第一个参数 payload 定义为可选,而 TS 不允许必选参数跟在可选后面,所以需要把 rootState 也定义为可选,在这种情况下,由于参数逆变,就必须使用上面的顺序。更多信息可以参考这个讨论。关于 TS 为什么有这样的限制,这也有个不错的回答。
如果 rootState 合法,则分别提取 TRest[0] 和 TRest[2],并附带上返回值信息 TReturn,传递给 EffectRematchDispatcher。之后要做的事就和 reducer 一样了,唯一不同的是多了一个 TReturn,effect 允许用户自定义返回值,而 reducer 返回的必须是一个 ReduxAction。
注意:使用 infer TRest 来提取参数,还有一个比较好的地方,就是如果参数未定义,传入 EffectRematchDispatcher 时,如果泛型使用了默认值,则会使用默认值。这和直接把参数定义为 undefined 不一样。
5.2 【已解决】type guard
原始的 issue 请点我查看。
我们先来看一个代码片段:
| |
上面的 error 确实是 TS 的一个问题,且现在仍然存在。一个解决方案是:
| |
但是我当时简化出来的例子更复杂,而且使用了上面的解决方案也没用。虽然这个问题后来在 TS v4.3.x 中修复了,但当时我是怎么做的呢?
由于传递的参数我使用了 NonNullable,既然 if 语句判断后无法构造出一个 NonNullable 类型,那么便自己手动构造:
| |
当时发现使用 Non-null assertion 操作符和 Nullish coalescing 操作符都可以达到效果。前者是 TS 的语法,感觉是直接给你构造了一个 NonNullable 类型,而后者是 JS 语法,这样操作以后,hoook 的值变成了 undefined | NonNullable<hook>,然后再经过 if,则把 undefined 过滤了。
特别是最后一个方法,感觉就很神奇,本来自身是可能为 undefined 的(我称之为隐式的),现在这个“隐式 undefined”被转化成了显式的 undefined,就可以被过滤了。当然,这只是我的一个描述,具体原因也没明白,而且后面这个问题被 TS 修复了,就没有再去深究。
5.3 【已解决】distributive conditional types
原始的 issue 请点我查看。
这个问题其实是和上面提到的 dispatcher inference 有关,而且应该是在我重写这部分之前提出来的。那个时候我并不知道什么是 distributive conditional type,且前面也提到过,Dispatcher 的类型推导实在过于复杂(前面是我后来优化的,之前的判断更复杂),看着大段的条件分支,也不知道错误是从哪个分支开始出现的。
记得当时我用的都是很蠢的方法,就是人工把条件拆开,一步一步判断,才终于发现了蹊跷,但由于就是不知道 Distributive conditional types 这个概念,搜索也费了很大劲。最后终于发现还有这个概念,突然豁然开朗。下面是一个简单的例子:
| |
官方说明如下:
Conditional types in which the checked type is a naked type parameter are called distributive conditional types. Distributive conditional types are automatically distributed over union types during instantiation. For example, an instantiation of T extends U ? X : Y with the type argument A | B | C for T is resolved as (A extends U ? X : Y) | (B extends U ? X : Y) | (C extends U ? X : Y).
我还咨询了一下为什么要这么设计,RyanCavanaugh的回答如下:
Distributivity is usually what you want in those scenarios, so it’s the default when the checked type is a naked type parameter. This was in favor of making special syntax for distributivity/non-distributivity since, with this behavior, you just generally don’t have to think about it.
大概意思就是这确实是一个很好的设计,而且这个设计是很符合我们的常规思考的(这也是为什么我之前没发现什么蹊跷),而且这种分发泛型的设计可以用来开发出很多工具类型:
| |
我们熟知的 Pick, Exclude 等等类型都是基于这个设计。
讲了这么多已解决的问题,其实未解决的问题也有很多,部分是我目前的能力所限,部分也是 TS 的限制。如果大家读完以后可以帮助参与 Rematch 的贡献,真是感激不尽!接下来一起看看那些未解决的问题吧。
5.4 【未解决】circularly reference
原始的 issue 请点我查看。
前面提到过 Circularly Reference,主要针对 Models 这个类型。而这里还有一种循环引用的情况:
| |
可以看到,在 effectsB 这种写法里面,TS 是会报循环引用错误的,但是这在代码层面其实是 OK 的,因为 effectsB 和 effectsA 的写法几乎一致,唯一不同的是 effectsA 函数中始终通过 dispatch 来访问 foo,而 effectsB 则是先解构,这可能会造成 effectsB 中的 foo 始终为 undefined,因为 foo 可能是后面才被添加到 dispatch 中的,对于这种问题,使用 effectsA 这种方式可以避免。Rematch 就曾出现过这样的问题。
虽然上面的问题解决了(方案可以查看问题详情链接),但是 TS 层面还会认为存在循环引用:
Root['foo']的类型为typeof foo- 要知道
foo的类型,需要知道effectsB的参数类型effectsB的参数类型为Extract<Root>- 解构的
foo类型为Extract<Root>['foo']也就是Root['foo']- 回到第一个
- 解构的
- 要知道
这个问题一直没有得到解决,而且还存在几个类似的问题,有一些也比较奇怪,感兴趣可以参考下面的一些评论:
- https://github.com/microsoft/TypeScript/issues/35546#issuecomment-611485331
- https://github.com/microsoft/TypeScript/issues/35546#issuecomment-645036905
- https://github.com/microsoft/TypeScript/issues/35546#issuecomment-668287404
5.5 【未解决】partial arg inference
原始的 issue 请点我查看。
这个 issue 其实涉及到两个问题,其中的一个和 createModel 工具函数那个部分说的一样,涉及到部分类型参数推断,而另外一个,则是我想得简单了,比如下面这个简化的例子:
| |
在上面的例子的 foo 中,我本以为在 setSomething 中使用默认参数 payload = 1 就可以将 payload 推断为 number。后来想明白了,由于我们对 createModel 中的 reducers 做了类型约束,不管是使用参数默认赋值,还是显式声明参数类型,都需要确保这个类型和约束的类型是兼容的。但默认赋值并不能改变推断的类型。
其实,在 Rematch 代码中,Reducer 的类型是这样的:
| |
前面提到过,由于无法做部分类型推断(也就是这里的 TState 使用用户定义的泛型,而 payload 使用推断),所以我们把 payload 定义为了一个可选的 any 类型,这样一来,由于任何类型都兼容 any,所以用户在实际定义 payload 时可以缩小它的类型。
注意:上面的
payload可选和必选效果是一样的,因为undefined | any等于any。
5.6 【未解决】同名 reducer 和 effect 的类型设计
前面提到,在 Rematch 中 reducer 可以和 effect 使用相同命名。而且调用时,reducer 会先执行,其次是 effect。我也不太清楚最初为什么这么设计,而且这样的行为对用户来说是隐藏的。
除此之外,这也对类型的设计造成了很大挑战,甚至说根本无法做到。下面是 effect 中间件的代码片段:
| |
上述代码会先判断 action.type 是否存在于 effects 中,如果有,则先调用 reducer(next(action) 表示调用下一个中间件,最后为执行 reducer)。
那么,类型层面,该怎么考虑?使用 union 提示两种类型?或者是考虑只提供 reducer 类型?我觉得两个方案都不太合理,虽然现在 Sergio 采用的是方案 2,这个方案在上面讲 RematchDispatch 的时候提到过,这里再来回顾下:
| |
由于 isEffect: boolean 这个设置的影响,同名 reducer 和 effect 会导致 never 类型的出现,函数无法调用。所以我们换成了 MergeExclusive 这个工具类型来做这件事,这个工具类型也比较好理解。比如有两个类型 A 和 B,其最终是使用联合来替代交叉,但是在联合前它对 A 和 B 分别做了两个处理:
- 对于
A,将其与B不同的属性全设置为?: never(由于可选也相当于undefined,这里实际上就是undefined | never,也就是undefined) - 再与
B相交
同样,对于 B 再做一遍,然后将它们联合。
回到前面,这个方法的优缺点分别是什么呢?优点就是当 reducer 和 effect 同名,但是 payload 完全不同时,由于使用了联合类型,会导致 payload 为 never 从而无法调用,参考上面代码中的 actionsAfter.decrement(1),为什么说是优点?因为这恰好符合 Rematch 的设计,因为经过 reducer 先处理后的 action,还会继续传到 effect,如果这俩的 payload 类型完全不一致,那么显然可能导致错误。
那么缺点呢?缺点也同样是因为联合类型,看上面代码中的 actionsAfter.increment(1),reducer 的 payload 类型是 effect 的 payload 类型的子集,这是符合预期的,比如上面会提示 payload: number,这样代码也能正常执行。但如果反过来,TS 仍然可以保证代码的成功运行,可由于 reducer 先执行,本意是提示 reducer 的类型,这里却提示了 effect,见上面代码中的 actionsAfter.incrementCopy('1')。
说实话,同名的 reducer 和 effect 确实很奇怪,我们应该避免这种情况。
5.7 【未解决】this types
原始的 issue 请点我查看。
在本专栏的第三篇文章,讲 Rematch 的核心插件时,提到了 effect 函数的上下文 this 被绑定到了 dispatch[modelName]。这样做可以方便地在 effect 中使用 this 来派发当前 model 的所有 actions。但是,这也给 TS 层面的类型兼容带来了挑战。目前的 effect 类型定义如下:
| |
而如果把它改为:
| |
这样的话,几乎所有类型都要增加 TModel 泛型参数,而且也会造成 Model 变成下面这样:
| |
这样一来,Model 也循环引用自身了,前面的 Circularly Reference 部分中的 Models 也是一样,且我提到也许可以使用 this 来表示类型中的自身。如果可行,我觉得对于 Model 也可以使用这种方法。目前我还没有太多时间,但我会持续关注这两个问题。
5.8 【未解决】select plugin types
最后一个未解决的问题,便是如何完善 select 插件的类型,这个插件原作者的代码写得比较复杂,甚至我看了 reselect 源码,都觉得比这个简单,我没办法完全理解,因此该插件的类型定义也就是稍微完善了一下,很多地方其实没走通。如果有感兴趣的同学,可以了解一下,顺便能修复就更不错了。
6 总结
其实在重构 Rematch 类型系统的初期,我的”体操“水平是相当不足的,所以你会看到我的很多设计都是碰巧、偶然实现的,只是发现这样可行,感到很神奇。但是通过这篇文章,我溯源了很多所谓”奇怪“的设计,并发现了 TS 更多有趣的地方。希望大家在学习的时候,也一定要知其然并知其所以然,保持热爱!
本篇专栏到此就结束了,希望大家通过读完所有的文章,能更深入地了解 Rematch,从而高效地开发,玩转状态管理。




