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

Rematch 源码解读系列的第 6️⃣ 篇,也是最后一篇,关于 Rematch 的 TS 类型系统。

系列的最后一篇,让我们来聊聊 Rematch 背后的类型系统,这是我在 Rematch 团队的主要贡献,重构它的时候遇到了不少问题,有一些得到了解决,有一些权衡之后采取了”独特“的设计,有一些是因为 TS 的语言限制,还有一些目前我未能解决。在这篇文章中,我会把上面的问题抛出来和大家一起探讨。

由于相关代码较多,下面不一定全贴出来,点此查看全部代码

Rematch 中有一个很重要的概念叫做 Model,在深入 Rematch 类型系统之前,我们需要先了解它,下面是其定义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
export interface Model<
  TModels extends Models<TModels>,
  TState = any,
  TBaseState = TState
> {
  name?: string;
  state: TState;
  reducers?: ModelReducers<TState>;
  baseReducer?: ReduxReducer<TBaseState>;
  effects?: ModelEffects<TModels> | ModelEffectsCreator<TModels>;
}

export interface Models<TModels extends Models<TModels>> {
  [key: string]: Model<TModels>;
}

我们主要关注的是 statereducerseffects 三个。state 比较简单,来看看后两个。

1
2
3
4
5
6
7
8
9
export type ModelReducers<TState = any> = {
  [key: string]: Reducer<TState>;
};

export type Reducer<TState = any> = (
  state: TState,
  payload?: any,
  meta?: any
) => TState;

ModelReducers 中要注意的是 state 类型信息的传递,因为 Reducer 函数的第一个参数为 state

effects 支持两种类型,分别是纯对象定义 ModelEffects 和 函数 ModelEffectsCreator,我们先看第一个:

ModelEffects

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
export interface ModelEffects<TModels extends Models<TModels>> {
  [key: string]: ModelEffect<TModels>;
}

export type ModelEffect<TModels extends Models<TModels>> = (
  this: ModelEffectThisTyped,
  payload: any,
  rootState: RematchRootState<TModels>,
  meta: any
) => any;

export type ModelEffectThisTyped = {
  [key: string]: (payload?: any, meta?: any) => Action<any, any>;
};

ModelEffectsModelReducers 类似,区别是前者接收的泛型为 TModels,其包含了所有 Model 的信息,我们需要借助 RematchRootState 这个类型从中提取全局的 rootState,作为 ModelEffect 的第二个参数。而后者仅仅需要对应的 model state 信息即可。我们下面会具体讲 RematchRootState

此外,ModelEffect 还将上下文的 this 绑定到了 dispatch[currentModel],因此可以使用 this[reducerName | effectName] 的形式来派发 action。不过这部分的类型推断存在问题,我会在最后的问题汇总部分说明。

ModelEffectsCreator

1
2
3
export type ModelEffectsCreator<TModels extends Models<TModels>> = (
  dispatch: RematchDispatch<TModels>
) => ModelEffects<TModels>;

除了 ModelEffects 这种方式,effects 还可以定义为一个函数,参数为 dispatch,返回值为 ModelEffects。这样一来,在 effects 中不仅可以使用 上下文 this 来派发当前 model 的 actions,还可以使用 dispatch 派发所有 model 的 actions。关于 RematchDispatch,也放在下面讲。

了解 Redux 的都知道其核心就两个部分,一个是负责派发 action 的 dispatch,另外一个则是全局 RootState。而我们在前面学习 Rematch Model 时,出现了两个类型,RematchRootStateRematchDispatch,这一部分我们来详细说说。

先看看 RematchRootState 的类型定义:

1
2
3
4
5
6
7
8
export type RematchRootState<
  TModels extends Models<TModels>,
  TExtraModels extends Models<TModels> = Record<string, never>
> = {
  [modelKey in keyof TModels]: TModels[modelKey]["state"];
} & {
  [modelKey in keyof TExtraModels]: TExtraModels[modelKey]["state"];
};

简单来说就是获取各个 Model 的 state,以 modelName 作为 key,合并起来。其中,model 包含了用户自定义的 model,也包含了使用的插件导出的 model(例如使用 loading 插件就导出了 loading Model)。

细心的同学也许发现上面的很多类型都使用了两个泛型参数,分别是 TModelsTExtraModels,在 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

再来看看 RematchDispatch

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
export type RematchDispatch<TModels extends Models<TModels>> = ReduxDispatch & {
  [modelKey in keyof TModels]: MergeExclusive<
    ExtractRematchDispatchersFromEffects<TModel["effects"], TModels>,
    ExtractRematchDispatchersFromReducers<
      TModel["state"],
      TModel["reducers"],
      TModels
    >
  >;
};

export interface ReduxDispatch<A extends Action = AnyAction> {
  <T extends A>(action: T): T;
}

RematchDispatch 是 Rematch 的核心,整个类型推断较为复杂,因此这里我做了一些简化。首先,Rematch 的 dispatch 是基于 Redux dispatch 的一个复合类型。因此使用了 ReduxDispatch & ...

其次,它需要从 effectsreducers 配置中分别提取对应的 actions。由于 reducer 的第一个参数为 model state,因此将 TModel['state'] 信息传入。

最后,使用 MergeExclusive 来合并 reducerActions 和 effectActions。一开始 Rematch 直接使用 & 操作符合并,但由于 Rematch 内置的每一个 action 都会被附带上 { isEffect: boolean } 这样的信息,因此如果 reducer 和 effect 同名,则会出现类型不兼容的问题(因为没有类型能同时兼容 { isEffect: true }{ isEffect: false }),举个简单例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
export interface Action<TPayload = any, TMeta = any>
  extends ReduxAction<string> {
  payload?: TPayload;
  meta?: TMeta;
}

export interface ReduxAction<T = any> {
  type: T;
}

type ReducerActions = {
  increment: ((payload: number) => void) & {
    isEffect: false;
  };
};

type EffectActions = {
  increment: ((payload: number) => void) & {
    isEffect: true;
  };
};

type Actions = ReducerActions & EffectActions;

declare const actions: Actions;

// This expression is not callable.
//   Type 'never' has no call signatures.(2349)
actions.increment(1);

其实 effect 和 reducer 即使同名,Rematch 代码层面也是支持的,这里先简单说下类型兼容的问题,后面关于这部分我还会继续和大家讨论。

除了上述核心类型,在 Rematch 中我还设计了一个工具函数 createModel,这个函数没有什么实际作用,只用来完善类型,以此减少用户手动添加,下面是相关代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
export const createModel: ModelCreator =
  () =>
  (mo): any => {
    const { reducers = {}, effects = {} } = mo;

    return {
      ...mo,
      reducers,
      effects,
    };
  };

export interface ModelCreator {
  <RM extends Models<RM>>(): <
    R extends ModelReducers<S>,
    BR extends ReduxReducer<BS>,
    E extends ModelEffects<RM> | ModelEffectsCreator<RM>,
    S,
    BS = S
  >(mo: {
    name?: string;
    state: S;
    reducers?: R;
    baseReducer?: BR;
    effects?: E;
  }) => {
    name?: string;
    state: S;
    reducers: R;
    baseReducer: BR;
    effects: E;
  };
}

// 使用
export const players = createModel<RootModel>()({
  state: {
    players: [],
  } as PlayersState,
  reducers: {
    SET_PLAYERS: (state, players: PlayerModel[]) => {
      return {
        ...state,
        players,
      };
    },
  },
  effects: (dispatch) => {
    const { players } = dispatch;
    return {
      async getPlayers(payload: string, rootState): Promise<any> {
        const response = await fetch(
          "https://www.balldontlie.io/api/v1/players"
        );
        const { data }: { data: PlayerModel[] } = await response.json();
        players.SET_PLAYERS(data);
      },
    };
  },
});

这个函数参数为空,调用后的返回值也是一个函数,此函数参数为 Model 对象,并返回该 Model 对象自身,整个保持 Model 的属性类型不变。主要功能如下:

  • 通过定义 state 的类型,打通 reducers 中单个 reducer 第一个参数的类型,无需重复定义
  • 通过传入 RootModel 泛型参数,自动推断出 effects 中第一个参数 dispatch 类型
  • 通过传入 RootModel 泛型参数,自动推断出 effects 中单个 effect 的第二个参数 rootState 类型

最开始我的 ModelCreator 是这样的:

1
2
3
interface ModelCreator {
  <RM extends Models<RM>>(): <M extends Model<RM>>(mo: M) => M;
}

虽然简洁了很多,但是上面第一点无法满足,因为 state 类型并没打通。然后我改成了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
export interface ModelCreator {
  <RM extends Models<RM>>(): <M extends Model<RM, S>, S>(mo: {
    name?: M["name"];
    state: S;
    reducers?: M["reducers"];
    baseReducer?: M["baseReducer"];
    effects?: M["effects"];
  }) => M;
}

// or
export interface ModelCreator {
  <RM extends Models<RM>>(): <M extends Model<RM, S>, S>(mo: {
    name?: M["name"];
    state: S;
    reducers?: M["reducers"];
    baseReducer?: M["baseReducer"];
    effects?: M["effects"];
  }) => {
    name?: M["name"];
    state: S;
    reducers: M["reducers"];
    baseReducer: M["baseReducer"];
    effects: M["effects"];
  };
}

但是上面这两种都无法保证返回值类型被推断为用户定义的 Model 类型,第一个的返回值类型 M 直接推断成 Model<RootModel, ModelState>。第二种虽然属性罗列出来了,但是单个属性比如 reducers 类型就是 ModelReducers<ModelState> 。也就是说,具体的单个 reducer 例如上面例子中的 SET_PLAYERS 类型没有没推导出来,上下文类型丢失。

所以最后我才采取了全展开的方式,这样一来,所有功能均可实现。

虽然功能实现了,乍一看这个函数,可能有同学会觉得奇怪:为什么使用了两个函数?

其实这是因为目前 TS 还不支持部分类型参数推断,也就是说,如果函数指定了多个泛型参数,在调用这个函数时,要么全部由用户传参,要么参数全交给 TS 自动推断。

所以我才设计了两个函数的方案,第一个函数提供给用户传递指定泛型参数,第二个函数用户则无需指定,交给 TS 自动推断。有趣的是,设计之初我并不知道什么是「部分类型参数推断」,也并不知道这种“双函数”的设计有助于解决这个问题,后来才发现这个 PR,甚至回复里面还专门有人提到了这个设计

最后,可能有人会问为什么不单独定义一个函数类型,而是使用了 ModelCreator 这个 interface 定义。这是为了支持不同模块下的函数重载,将函数使用 interface 表示后,便可以使用 Module Augmentation 了,后面会单独讲讲这个方案。

前面提到 Model 类型的时候,大家有没有注意到一个细节:

1
2
3
export interface Models<TModels extends Models<TModels>> {
  [key: string]: Model<TModels>;
}

仔细看上面的 Models 泛型参数 TModels 的约束条件为 Models<TModels>。是不是有点迷惑,其实这也是我无意之中设计的,最开始的时候,我是直接使用了默认参数:

1
2
3
export interface Models<TModels extends Models = Record<string, any>> {
  [key: string]: Model<TModels>;
}

但上面毕竟使用了 any,作为一个严格要求自己的”体操队员“,我当然要减少 any 出现的情况,因此我打算将其改为 neverunknown。但是我很快又发现了问题:

如果 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 信息:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/**
 * Extracts a dispatcher for each effect that is defined for a model.
 */
export type ExtractRematchDispatchersFromEffectsObject<
  TEffects extends ModelEffects<TModels>,
  TModels extends Models
> = {
  [effectKey in keyof TEffects]: ExtractRematchDispatcherFromEffect<
    TEffects[effectKey],
    TModels
  >;
};

而由于此时的 Modelnever,那么 effects 也会是 never,因此上面的索引 effectKey 也会为 never。而我们知道,never 无法兼容除了其自身的任何类型,也就是说除了它自身的任何类型都无法赋值给它,因此上面的 RematchDispatch<TModels> 就无法赋值给 RematchDispatch<Record<string, never>>。所以最终会报索引签名的兼容性错误,下面是错误栈:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Type 'Model<Record<string, never>, any, any>' is not assignable to type 'Model<TModels, any, any>'.
//  Types of property 'effects' are incompatible.
//    Type 'ModelEffects<Record<string, never>> | ModelEffectsCreator<Record<string, never>> | undefined' is not assignable to type 'ModelEffects<TModels> | ModelEffectsCreator<TModels> | undefined'.
//      Type 'ModelEffectsCreator<Record<string, never>>' is not assignable to type 'ModelEffects<TModels> | ModelEffectsCreator<TModels> | undefined'.
//        Type 'ModelEffectsCreator<Record<string, never>>' is not assignable to type 'ModelEffectsCreator<TModels>'.
//          Types of parameters 'dispatch' and 'dispatch' are incompatible.
//            Type 'RematchDispatch<TModels>' is not assignable to type 'RematchDispatch<Record<string, never>>'.
//              Type 'RematchDispatch<TModels>' is not assignable to type 'ExtractRematchDispatchersFromModels<Record<string, never>>'.
//                Index signatures are incompatible.
//                  Type 'any' is not assignable to type 'never'.ts(2344)

既然 never 行不通,换成 unknown 呢?

1
2
3
export interface Models<TModels extends Models = Record<string, unknown>> {
  [key: string]: Model<TModels>;
}

显然这里使用 unknown 甚至无法满足 extends Models 的约束,因为 unknown 是肯定不能赋值给 Model 的。

最后,由于我始终认为用户既然使用 Rematch,肯定会定义 TModels,因此这个泛型参数其实是不需要有默认值的。所以我删除了默认值,但此时没有了默认值,extends Models 肯定是不行了,我就碰巧改成了 extends Models<TModels>,最终就变成了下面这种看起来有些奇怪的循环引用模样:

1
2
3
export interface Models<TModels extends Models<TModels>> {
  [key: string]: Model<TModels>;
}

虽然一切都正常了,但我其实并不了解这种写法,只知道它可以解决我当下的问题。说实话,如果不是写这篇文章,这个地方我可能不会深入研究,但通过写作可以倒逼我去了解这种写法。搜索的过程中,还发现了一个不错的解释

回到我的问题上来,为什么我会使用这样的方法?首要原因是我需要创建约束,前面提到,Model 需要获取所有的 models 信息,而每一个单独的 Model 又作为 Models 的一个属性,因此为了打通类型,我需要给 Models 也加上 TModels 的泛型参数,而且 TModels 需要满足约束,因为它肯定也是 Models 子集,它代表着用户自定义的全局 models 类型。所以最终 TModels 的约束就为 extends Models<TModels>。可能还是有点绕口,下面我举一个实际例子

1
2
3
4
5
6
7
8
9
export interface RootModel extends Models<RootModel> {
  players: typeof players;
  cart: typeof cart;
  settings: typeof settings;
}

export interface Models<TModels extends Models<TModels>> {
  [key: string]: Model<TModels>;
}

TModels 换成 RootModel 代入,是不是好理解很多。这里我其实就是想保证 TModelsModels 的子集(也就是用户实际创建的 RootModel),而不是 Models 自身。如果还难以理解,可以直接查看该回答,回答中的例子应该更恰当一些。

答主还提到,TS 支持多态 this,因此我这里的定义其实可以绕开繁琐且难以理解的“循环”,改为更优雅的方式:

1
2
3
export interface Models {
  [key: string]: Model<this>;
}

这里的 this 就也能表示用户自定义的 models 了。值得注意的是这与直接替换成 Models 不同,如果使用 Model<Models>,而此时的 Models 无法表示用户自定义的 models,它的 key 都是 string 类型,这样传入给 Model 是没有实际意义的。

当然,上面这种方法我还没实践,后期我会找个时间提出一个 PR,来继续重构这部分代码。

TS 里有一个功能叫做声明合并,针对第三方的模块和全局环境,分别有 Module Augmentation 和 Global Augmentation 两种合并模式,Augmentation 有「增强,增加」的意思,表示扩展模块或全局的功能(类型层面)。

Global Augmentation 这里简单提一下,如果在模块中(文件包含 import, export 关键字),需要将额外的声明放入 declare global {} 下面:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// observable.ts
export class Observable<T> {
  // ... still no implementation ...
}
declare global {
  interface Array<T> {
    toObservable(): Observable<T>;
  }
}
Array.prototype.toObservable = function () {
  // ...
};

而如果本来就是全局脚本文件,则无需添加 declare global {} 块。

这里主要讲讲 Module Augmentation,Rematch 有 3 个地方使用了这种模式,用来消除类型不兼容。

首先是 declare module '@rematch/core' {},在 select 和 typed-state 插件中均有使用。先来看看 select 插件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
declare module "@rematch/core" {
  // Add overloads for store to add select
  interface RematchStore<
    TModels extends Models<TModels>,
    TExtraModels extends Models<TModels>
  > extends ReduxStore<RematchRootState<TModels, TExtraModels>, Action> {
    select: RematchSelect<
      TModels,
      TExtraModels,
      RematchRootState<TModels, TExtraModels>
    >;
  }

  // add overloads for Model here.
  interface Model<TModels extends Models<TModels>, TState = any> {
    selectors?: ModelSelectorsConfig<TModels, TState>;
  }

  // add overloads for ModelCreator here.
  interface ModelCreator {
    <RM extends Models<RM>>(): <
      R extends ModelReducers<S>,
      BR extends ReduxReducer<BS>,
      E extends ModelEffects<RM> | ModelEffectsCreator<RM>,
      SE extends ModelSelectorsConfig<RM, S>,
      S,
      BS = S
    >(mo: {
      name?: string;
      state: S;
      selectors?: SE;
      reducers?: R;
      baseReducer?: BR;
      effects?: E;
    }) => {
      name?: string;
      state: S;
      selectors: SE;
      reducers: R;
      baseReducer: BR;
      effects: E;
    };
  }
}

select 插件在 Model 中增加了一个 selectors 属性,同时导出了一个 select 函数,挂在 RematchStore 上。

在前面讲解 createModel helper 时,提到了 ModelCreator 为什么定义成了一个 interface。由于 select 插件是支持在 model 里定义 selectors 的,所以这里可以方便地利用 Module Augmentation 进行函数重载。

typed-state 插件主要是提供给以纯 JS 使用 Rematch 的开发者,通过 typings 配置,方便在开发环境中发现一些错误定义的类型,它也使用了 Module Augmentation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
declare module "@rematch/core" {
  interface Model<
    TModels extends Models<TModels> = Record<string, any>,
    TState = any
  > {
    typings?: Record<string, any>;
  }

  // add overloads for ModelCreator here.
  interface ModelCreator {
    <RM extends Models<RM>>(): <
      R extends ModelReducers<S>,
      BR extends Reducer<BS>,
      E extends ModelEffects<RM> | ModelEffectsCreator<RM>,
      S,
      BS = S
    >(mo: {
      name?: string;
      state: S;
      reducers?: R;
      baseReducer?: BR;
      effects?: E;
      typings?: Record<string, any>;
    }) => {
      name?: string;
      state: S;
      typings?: Record<string, any>;
      reducers: R;
      baseReducer: BR;
      effects: E;
    };
  }
}

@rematch/core 中针对 redux 也使用了 Module Augmentation:

1
2
3
4
5
declare module "redux" {
  export interface Dispatch<A extends Action = AnyAction> {
    [modelName: string]: any;
  }
}

前面提到 RematchDispatch 时,讲到它其实是一个复合类型,由 Rematch 自己的 dispatcher 联合上 ReduxDispatch(& ReduxDispatch),下面是 ReduxDispatch

1
2
3
export interface ReduxDispatch<A extends Action = AnyAction> {
  <T extends A>(action: T): T;
}

而增加上面的定义,并使用 any,可以消除很多源码中的类型报错。对于使用 Rematch 的开发者来说,models 信息都是提前定义好的,可是在源码中只能使用泛型 TModels 表达,这里面有不少错误,有部分我也没发现原因,只是用 any 来避免它们,可以说这部分也算是一个残留的问题,感兴趣的同学可以去看看源码,如果能解决,欢迎 PR。

可能有人会问为什么还要额外定义一个 RematchDispatch,直接使用 Module Augmentation 不也可以吗?这是因为 TS 限制同名的声明需要使用相同的泛型参数,而 RematchDispatch 还需要 TModels 信息,无法和 ReduxDispatch 保持一致。

针对 react-reduxconnect 方法,Rematch 也做了兼容,由于 connect 只能识别 ReduxDispatch,所以需要对它进行重载,使其也可以支持 RematchDispatch

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
declare module "react-redux" {
  interface Connect {
    <RM extends Models<RM>, State, TStateProps, TDispatchProps, TOwnProps>(
      mapStateToProps: MapStateToPropsParam<TStateProps, TOwnProps, State>,
      mapDispatchToProps: MapRematchDispatchToPropsNonObject<
        TDispatchProps,
        TOwnProps,
        RM
      >
    ): InferableComponentEnhancerWithProps<
      TStateProps & TDispatchProps,
      TOwnProps
    >;
  }

  type MapRematchDispatchToPropsNonObject<
    TDispatchProps,
    TOwnProps,
    RM extends Models<RM>
  > =
    | MapRematchDispatchToPropsFactory<TDispatchProps, TOwnProps, RM>
    | MapRematchDispatchToPropsFunction<TDispatchProps, TOwnProps, RM>;

  type MapRematchDispatchToPropsFactory<
    TDispatchProps,
    TOwnProps,
    RM extends Models<RM>
  > = (
    dispatch: RematchDispatch<RM>,
    ownProps: TOwnProps
  ) => MapRematchDispatchToPropsFunction<TDispatchProps, TOwnProps, RM>;

  type MapRematchDispatchToPropsFunction<
    TDispatchProps,
    TOwnProps,
    RM extends Models<RM>
  > = (dispatch: RematchDispatch<RM>, ownProps: TOwnProps) => TDispatchProps;
}

通过重载,我们引入了 RM 泛型,它就是 TModels ,这样一来,dispatch 参数便可以兼容了,从而 connect 也兼容了。

这里多提一句,在 Redux 的 StyleGuide 中,更建议使用 hooks,也就是 useSelectoruseDispatch 来替代 connect,对于 Rematch 也是一样的,Redux 官方也认为 connect 的类型定义实在过于复杂,不易使用,过多的函数重载,可选参数,还需要合并 props 等等,感兴趣可以点击上面的链接去看看。

注意:前面提到同名的声明需要使用相同的泛型参数,不过如果这个声明用于函数重载,里面的重载函数的泛型参数是可以不同的。

最后的部分,我挑了几个典型的问题,有一些已经得到了解决,也有部分暂时未能解决。这些问题我都搜索了大量资料,其中还发现了一些 TS 的设计限制(design limitation),它们都十分有趣,抛出来和大家探讨。

原始的 PR 请点我查看

前面提到过 RematchDispatch,这个其实是 Rematch 的一个核心,而且类型的推导也比较复杂。比如我们在 model 中定义的 reducer 有三个参数,分别是 modelStatepayloadmeta,但是 dispatch 调用时只要传递 payloadmeta 即可。在 effect 中也是三个参数,分别是 payloadrootStatemeta,在调用时传递 payloadmeta

提取参数并生成新的函数定义这一过程看似简单,实则也踩了不少坑。

先看看 reducer 的提取:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
export type ExtractRematchDispatcherFromReducer<TState, TReducer> =
  TReducer extends () => any
    ? RematchDispatcher
    : TReducer extends (state: TState, ...args: infer TRest) => TState
    ? TRest extends []
      ? RematchDispatcher
      : RematchDispatcher<TRest[0], TRest[1]>
    : never;

export type RematchDispatcher<TPayload = void, TMeta = void> = [
  TPayload,
  TMeta
] extends [void, void]
  ? (() => Action<void, void>) & { isEffect: false }
  : [TMeta] extends [void]
  ? undefined extends TPayload
    ? ((payload?: TPayload) => Action<TPayload, void>) & {
        isEffect: false;
      }
    : ((payload: TPayload) => Action<TPayload, void>) & {
        isEffect: false;
      }
  : [undefined, undefined] extends [TPayload, TMeta]
  ? ((payload?: TPayload, meta?: TMeta) => Action<TPayload, TMeta>) & {
      isEffect: false;
    }
  : undefined extends TMeta
  ? ((payload: TPayload, meta?: TMeta) => Action<TPayload, TMeta>) & {
      isEffect: false;
    }
  : ((payload: TPayload, meta: TMeta) => Action<TPayload, TMeta>) & {
      isEffect: false;
    };

注意这里的 RematchDispatcher 使用了 void 作为泛型的默认参数。直到写这篇文章,我才发现一个 bug。因为 void 除了其自身,只能赋值给 anyunknown,但是反过来的行为却很怪异:

1
2
3
4
5
6
7
8
9
// What types are compatible with `void`?
type case1 = [any] extends [void] ? 1 : 0; // 1
type case2 = any extends void ? 1 : 0; // 0 | 1
type case3 = unknown extends void ? 1 : 0; // 0
type case4 = [unknown] extends [void] ? 1 : 0; // 0

// What types are `void` compatible with?
type case5 = void extends any ? 1 : 0; // 1
type case6 = void extends unknown ? 1 : 0; // 1

由于上面的 case1 成立,当 reducer 的第二个参数 payload 被用户自己定义成 any 时,生成的 dispatch 函数会没有参数,这显然不对。

其实,void 类型的兼容性比 never 好,never 只能兼容其自身,但对于 any 则和 void 表现一样,不过使用 [] 以后则不一样:

1
2
3
// What types are compatible with `never`?
type case1 = [any] extends [never] ? 1 : 0; // 0
type case2 = any extends never ? 1 : 0; // 0 | 1

利用这一点,我后面会提一个 PR 来修复这个问题。

回到正题,推导的思路如下:

  • 如果用户没有定义参数,或者只使用了 state -> 参数为空的 dispatch

  • 否则提取第一个参数 payload 和 第二个参数 meta

    • 如果未定义 meta,即 [TMeta] extends [void]
      • payload 可选,即 undefined extends TPayload -> payload 为可选参数的 dispatch
      • 否则 -> payload 为必选参数的 dispatch
    • 否则,提取 TMeta,并判断:
      • 如果 metapayload 均可选,即 [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 则复杂一些,主要体现在两个方面:

  1. effect 可以为一个对象,也就是上面提到的 ModelEffects,还可以为一个函数 ModelEffectsCreator
  2. effect 的 rootState 参数位于第二个,payload 位于第一个,而 meta 在最后

关于第二点,有人可能会问为什么不统一。因为最初设计的时候,是考虑了实际使用情况的,在 reducer 中,一般都需要拿到当前 modelState,所以把它放在了第一个参数,而在 effect 中,大多时候是只需要 payload 的,因此把 rootState 挪到了中间,而最不常使用的 meta 则都放到最后。

那我们来看看 effect 的推导:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
export type ExtractRematchDispatchersFromEffects<
  TEffects extends Model<TModels>["effects"],
  TModels extends Models<TModels>
> = TEffects extends (...args: any[]) => infer R
  ? R extends ModelEffects<TModels>
    ? ExtractRematchDispatchersFromEffectsObject<R, TModels>
    : never
  : TEffects extends ModelEffects<TModels>
  ? ExtractRematchDispatchersFromEffectsObject<TEffects, TModels>
  : never;

export type ExtractRematchDispatchersFromEffectsObject<
  TEffects extends ModelEffects<TModels>,
  TModels extends Models<TModels>
> = {
  [effectKey in keyof TEffects]: TEffect extends (
    ...args: infer TRest
  ) => infer TReturn
    ? TRest[1] extends undefined
      ? EffectRematchDispatcher<TReturn, TRest[0]>
      : RematchRootState<TModels> extends TRest[1]
      ? EffectRematchDispatcher<TReturn, TRest[0], TRest[2]>
      : never
    : never;
};

首先是使用 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 不一样。

原始的 issue 请点我查看

我们先来看一个代码片段

1
2
3
4
5
6
7
8
const obj: { prop: string | null } = { prop: "hello" };

if (typeof obj.prop === "string") obj.prop.length; //OK

if (typeof obj["prop"] === "string") obj["prop"].length; //OK

const key = "prop" as const;
if (typeof obj[key] === "string") obj[key].length; //error - object is possibly 'null'

上面的 error 确实是 TS 的一个问题,且现在仍然存在。一个解决方案是:

1
2
3
4
5
const key = "prop" as const;
const configWorks = config[key];
if (typeof configWorks !== "boolean") {
  configWorks.prop = "test"; // ok
}

但是我当时简化出来的例子更复杂,而且使用了上面的解决方案也没用。虽然这个问题后来在 TS v4.3.x 中修复了,但当时我是怎么做的呢?

由于传递的参数我使用了 NonNullable,既然 if 语句判断后无法构造出一个 NonNullable 类型,那么便自己手动构造:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// working method 1
if (typeof hook !== "undefined") {
  fn(hook!);
}

// working method 2
const hoook = hook ?? undefined;
if (typeof hoook !== "undefined") {
  fn(hoook);
}

当时发现使用 Non-null assertion 操作符Nullish coalescing 操作符都可以达到效果。前者是 TS 的语法,感觉是直接给你构造了一个 NonNullable 类型,而后者是 JS 语法,这样操作以后,hoook 的值变成了 undefined | NonNullable<hook>,然后再经过 if,则把 undefined 过滤了。

特别是最后一个方法,感觉就很神奇,本来自身是可能为 undefined 的(我称之为隐式的),现在这个“隐式 undefined”被转化成了显式的 undefined,就可以被过滤了。当然,这只是我的一个描述,具体原因也没明白,而且后面这个问题被 TS 修复了,就没有再去深究。

原始的 issue 请点我查看

这个问题其实是和上面提到的 dispatcher inference 有关,而且应该是在我重写这部分之前提出来的。那个时候我并不知道什么是 distributive conditional type,且前面也提到过,Dispatcher 的类型推导实在过于复杂(前面是我后来优化的,之前的判断更复杂),看着大段的条件分支,也不知道错误是从哪个分支开始出现的。

记得当时我用的都是很蠢的方法,就是人工把条件拆开,一步一步判断,才终于发现了蹊跷,但由于就是不知道 Distributive conditional types 这个概念,搜索也费了很大劲。最后终于发现还有这个概念,突然豁然开朗。下面是一个简单的例子:

1
2
3
4
5
6
7
8
9
type NakedExample<T> = T extends void ? "true" : "false";

type OptionalNumber = number | undefined;

// 'true' | 'false'
type Foo = NakedExample<OptionalNumber>;

// 'false'
type Bar = OptionalNumber extends void ? "true" : "false";

官方说明如下:

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.

大概意思就是这确实是一个很好的设计,而且这个设计是很符合我们的常规思考的(这也是为什么我之前没发现什么蹊跷),而且这种分发泛型的设计可以用来开发出很多工具类型:

1
2
3
4
5
6
type Diff<T, U> = T extends U ? never : T; // Remove types from T that are assignable to U
type Filter<T, U> = T extends U ? T : never; // Remove types from T that are not assignable to U

type NonNullable<T> = Diff<T, null | undefined>; // Remove null and undefined from T

type T34 = NonNullable<string | number | undefined>; // string | number

我们熟知的 Pick, Exclude 等等类型都是基于这个设计。


讲了这么多已解决的问题,其实未解决的问题也有很多,部分是我目前的能力所限,部分也是 TS 的限制。如果大家读完以后可以帮助参与 Rematch 的贡献,真是感激不尽!接下来一起看看那些未解决的问题吧。


原始的 issue 请点我查看

前面提到过 Circularly Reference,主要针对 Models 这个类型。而这里还有一种循环引用的情况

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
type Extract<Root extends Record<string, any>> = {
  [key in keyof Root]: Root[key];
};

type Effects<T> = (p: Extract<T>) => void;

export const create: <Root extends Record<string, any>>() => <
  E extends Effects<Root>
>(mo: {
  effectsA?: E;
  effectsB?: E;
}) => {
  effectsA: E;
  effectsB?: E;
} =
  () =>
  (p): any =>
    p;

interface Root {
  foo: typeof foo;
}

const foo = create<Root>()({
  effectsA: (dispatch) => {
    // worked without destructing dispatch
    console.log(dispatch.foo);
  },
  effectsB: ({ foo }) => {
    // failed with destructing
    console.log(foo);
  },
});

可以看到,在 effectsB 这种写法里面,TS 是会报循环引用错误的,但是这在代码层面其实是 OK 的,因为 effectsBeffectsA 的写法几乎一致,唯一不同的是 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']
          • 回到第一个

这个问题一直没有得到解决,而且还存在几个类似的问题,有一些也比较奇怪,感兴趣可以参考下面的一些评论:

原始的 issue 请点我查看

这个 issue 其实涉及到两个问题,其中的一个和 createModel 工具函数那个部分说的一样,涉及到部分类型参数推断,而另外一个,则是我想得简单了,比如下面这个简化的例子

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
export declare type ModelReducers = {
  [key: string]: Reducer;
};

export declare type Reducer = (payload: any) => void;

declare const createModel: <R extends ModelReducers>(mo: {
  reducers: R;
}) => void;

declare const createModelWithoutReducerStrictions: <R>(mo: {
  reducers: R;
}) => void;

// payload params in `setSomething` can not be infered as number
const foo = createModel({
  reducers: {
    // (method) setSomething(payload?: any): void
    setSomething(payload = 1) {},
  },
});

// payload params in `setSomething` can be infered as number
const bar = createModelWithoutReducerStrictions({
  reducers: {
    // (method) setSomething(payload?: number): void
    setSomething(payload = 1) {},
  },
});

// payload params in `setSomething` can be infered as number with explicit type annotation
const baz = createModel({
  reducers: {
    // (method) setSomething(payload?: number): void
    setSomething(payload: number = 1) {},
  },
});

在上面的例子的 foo 中,我本以为在 setSomething 中使用默认参数 payload = 1 就可以将 payload 推断为 number。后来想明白了,由于我们对 createModel 中的 reducers 做了类型约束,不管是使用参数默认赋值,还是显式声明参数类型,都需要确保这个类型和约束的类型是兼容的。但默认赋值并不能改变推断的类型。

其实,在 Rematch 代码中,Reducer 的类型是这样的:

1
2
3
4
export declare type Reducer<TState = any> = (
  state: TState,
  payload?: any
) => TState;

前面提到过,由于无法做部分类型推断(也就是这里的 TState 使用用户定义的泛型,而 payload 使用推断),所以我们把 payload 定义为了一个可选的 any 类型,这样一来,由于任何类型都兼容 any,所以用户在实际定义 payload 时可以缩小它的类型。

注意:上面的 payload 可选和必选效果是一样的,因为 undefined | any 等于 any

前面提到,在 Rematch 中 reducer 可以和 effect 使用相同命名。而且调用时,reducer 会先执行,其次是 effect。我也不太清楚最初为什么这么设计,而且这样的行为对用户来说是隐藏的。

除此之外,这也对类型的设计造成了很大挑战,甚至说根本无法做到。下面是 effect 中间件的代码片段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
function createEffectsMiddleware<
  TModels extends Models<TModels>,
  TExtraModels extends Models<TModels>
>(bag: RematchBag<TModels, TExtraModels>): Middleware {
  return (store) =>
    (next) =>
    (action: Action): any => {
      if (action.type in bag.effects) {
        // first run reducer action if exists
        next(action);

        // then run the effect and return its result
        return (bag.effects as any)[action.type](
          action.payload,
          store.getState(),
          action.meta
        );
      }

      return next(action);
    };
}

上述代码会先判断 action.type 是否存在于 effects 中,如果有,则先调用 reducernext(action) 表示调用下一个中间件,最后为执行 reducer)。

那么,类型层面,该怎么考虑?使用 union 提示两种类型?或者是考虑只提供 reducer 类型?我觉得两个方案都不太合理,虽然现在 Sergio 采用的是方案 2,这个方案在上面讲 RematchDispatch 的时候提到过,这里再来回顾下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import { MergeExclusive } from "type-fest";

type ReducerActions = {
  increment: ((payload: number) => void) & {
    isEffect: false;
  };
  decrement: ((payload: string) => void) & {
    isEffect: false;
  };
  incrementCopy: ((payload: number | string) => void) & {
    isEffect: false;
  };
};

type EffectActions = {
  increment: ((payload: number | string) => number) & {
    isEffect: true;
  };
  decrement: ((payload: number) => number) & {
    isEffect: true;
  };
  incrementCopy: ((payload: number) => number) & {
    isEffect: true;
  };
};

type ActionsBefore = ReducerActions & EffectActions;
type ActionsAfter = MergeExclusive<ReducerActions, EffectActions>;

declare const actionsBefore: ActionsBefore;
declare const actionsAfter: ActionsAfter;

// This expression is not callable.
//   Type 'never' has no call signatures.(2349)
actionsBefore.increment(1);

// (property) increment: (payload: number) => number | void
actionsAfter.increment(1);

// Argument of type 'number' is not assignable to parameter of type 'never'.(2345)
actionsAfter.decrement(1);

// Argument of type 'string' is not assignable to parameter of type 'number'.(2345)
actionsAfter.incrementCopy("1");

由于 isEffect: boolean 这个设置的影响,同名 reducer 和 effect 会导致 never 类型的出现,函数无法调用。所以我们换成了 MergeExclusive 这个工具类型来做这件事,这个工具类型也比较好理解。比如有两个类型 AB,其最终是使用联合来替代交叉,但是在联合前它对 AB 分别做了两个处理:

  • 对于 A,将其与 B 不同的属性全设置为 ?: never(由于可选也相当于 undefined,这里实际上就是 undefined | never,也就是 undefined
  • 再与 B 相交

同样,对于 B 再做一遍,然后将它们联合。

回到前面,这个方法的优缺点分别是什么呢?优点就是当 reducer 和 effect 同名,但是 payload 完全不同时,由于使用了联合类型,会导致 payloadnever 从而无法调用,参考上面代码中的 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 确实很奇怪,我们应该避免这种情况。

原始的 issue 请点我查看

在本专栏的第三篇文章,讲 Rematch 的核心插件时,提到了 effect 函数的上下文 this 被绑定到了 dispatch[modelName]。这样做可以方便地在 effect 中使用 this 来派发当前 model 的所有 actions。但是,这也给 TS 层面的类型兼容带来了挑战。目前的 effect 类型定义如下:

1
2
3
4
5
6
export type ModelEffect<TModels extends Models<TModels>> = (
  this: ModelEffectThisTyped,
  payload: Action["payload"],
  rootState: RematchRootState<TModels>,
  meta: Action["meta"]
) => any;

而如果把它改为:

1
2
3
4
5
6
export type ModelEffect<TModel, TModels extends Models<TModels>> = (
  this: ModelDispatcher<TModel>,
  payload: Action["payload"],
  rootState: RematchRootState<TModels>,
  meta: Action["meta"]
) => any;

这样的话,几乎所有类型都要增加 TModel 泛型参数,而且也会造成 Model 变成下面这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
export interface Model<
  TModel extends Model<TModel, TModels>,
  TModels extends Models<TModel, TModels>,
  TState = any,
  TBaseState = TState
> {
  name?: string;
  state: TState;
  reducers?: ModelReducers<TState>;
  baseReducer?: ReduxReducer<TBaseState>;
  effects?:
    | ModelEffects<TModel, TModels>
    | ModelEffectsCreator<TModel, TModels>;
}

这样一来,Model 也循环引用自身了,前面的 Circularly Reference 部分中的 Models 也是一样,且我提到也许可以使用 this 来表示类型中的自身。如果可行,我觉得对于 Model 也可以使用这种方法。目前我还没有太多时间,但我会持续关注这两个问题。

最后一个未解决的问题,便是如何完善 select 插件的类型,这个插件原作者的代码写得比较复杂,甚至我看了 reselect 源码,都觉得比这个简单,我没办法完全理解,因此该插件的类型定义也就是稍微完善了一下,很多地方其实没走通。如果有感兴趣的同学,可以了解一下,顺便能修复就更不错了。

其实在重构 Rematch 类型系统的初期,我的”体操“水平是相当不足的,所以你会看到我的很多设计都是碰巧、偶然实现的,只是发现这样可行,感到很神奇。但是通过这篇文章,我溯源了很多所谓”奇怪“的设计,并发现了 TS 更多有趣的地方。希望大家在学习的时候,也一定要知其然并知其所以然,保持热爱!

本篇专栏到此就结束了,希望大家通过读完所有的文章,能更深入地了解 Rematch,从而高效地开发,玩转状态管理。

相关内容