你所不知道的 Typescript 与 Redux 类型优化

你所不知道的 Typescript 与 Redux 类型优化

自从 Redux 诞生后,函数式编程在前端一直很热;去年7月,Typescript 发布 2.0,OOP 数据流框架也开始火热,社区更倾向于类型友好、没有 Redux 那么冗长烦琐的 Mobx 和 dob

然而静态类型并没有绑定 OOP。随着 Redux 社区对 TS 的拥抱以及 TS 自身的发展,TS 对 FP 的表达能力势必也会越来越强。Redux 社区也需要群策群力,为 TS 和 FP 的伟大结合做贡献。

本文主要介绍 Typescript 一些有意思的高级特性;并用这些特性对 Redux 做了类型优化,例如:推导全局的 Redux State 类型、Reducer 每个 case 下拿到不同的 payload 类型;Redux 去形式化与 Typescript 的结合;最后介绍了一些 React 中常用的 Typescript 技巧。

本文相关相关经验已经整理到 iron-redux 库,一个帮助 Redux 在 Typescript 中去形式化及类型完美的库。这篇文章之后发布了 Typescript 2.8、2.9 版本,其相关特性也整合到了 iron-redux。读者可以 clone 下来,体验其中 demo。

理论基础

Mapped Types

Javascript 中,字面量对象和数组是非常强大灵活。引进类型后,如何避免因为类型的约束而使字面量对象和数组死气沉沉,Typescript 灵活的 interface 是一个伟大的发明。

下面介绍的 Mapped Types 让 interface 更加强大。大家在 js 中都用过 map 运算。在 TS 中,interface 也能做 map 运算。

// 将每个属性变成可选的。
type Optional<T> = {
 [key in keyof T]?: T[key];
}
 

从字面量对象值推导出 interface 类型,并做 map 运算:

type NumberMap<T> = {

}

function toNumber<T>(obj: T): NumberMap<T> {
  return Object.keys(obj).reduce((result, key) => {
    return {
      ...result,
      [key]: Number(result[key]),
    };
  }, {}) as any;
}

const obj2 = toNumber({
  a: '32',
  b: '64',
});

在 interface map 运算的支持下,obj2 能推导出精准的类型。


获取函数返回值类型

在 TS 中,有些类型是一个类型集,比如 interface,function。TS 能够通过一些方式获取类型集的子类型。比如:

interface Person {
  name: string;
}

// 获取子类型
const personName: Person['name'];

然而,对于函数子类型,TS 暂时没有直接的支持。不过江湖上有一种类型推断的方法,可以获取返回值类型。

虽然该方法可以说又绕又不够优雅,但是函数返回值类型的推导,能够更好地支持函数式编程,收益远大于成本。

type Reverse<T> = (arg: any) => T;

function returnResultType<T>(arg: Reverse<T>): T {
  return {} as any as T;
}

// result 类型是 number
const result = returnResultType((arg: any) => 3);
type ResultType = typeof result;

举个例子,当我们在写 React-redux connect 的时候,返回结构极有可能与 state 结构不尽相同。而通过推导函数返回类型的方法,可以拿到准确的返回值类型:

type MapProps<NewState> = (state?: GlobalState, ownProps?: any) => NewState;
function returnType<NewState>(mapStateToProps: MapProps<NewState>) {
  return {} as any as NewState;
}

使用方法:

function mapStateToProps(state?: GlobalState, ownProp?: any) {
  return {
    ...state.dataSrc,
    a: '',
  };
};

const mockNewState = returnType(mapStateToProps);
type NewState = typeof mockNewState;

可辨识联合(Discriminated Unions)

关于 Discriminated Unions ,官方文档已有详细讲解,本文不再赘述。链接如下:

查看英文文档

查看中文文档


可辨识联合是什么,我只引用官方文档代码片段做快速介绍:

interface Square {
    kind: "square";
    size: number;
}
interface Rectangle {
    kind: "rectangle";
    width: number;
    height: number;
}

type Shape = Square | Rectangle;

function area(s: Shape) {
    switch (s.kind) {
        // 在此 case 中,变量 s 的类型为 Square
        case "square": return s.size * s.size;
        // 在此 case 中,变量 s 的类型为 Rectangle
        case "rectangle": return s.height * s.width;
    }
}

在不同的 case 下,变量 s 能够拥有不同的类型。我想读者一下子就联想到 Reducer 函数了吧。注意 interface 中定义的 kind 属性的类型,它是一个字符串字面量类型。


redux 类型优化

combineReducer 优化

redux中原来的定义:

type Reducer<S> = (state: S, action: any) => S;

function combineReducers<S>(reducers: ReducersMapObject): Reducer<S>;

粗看这个定义,好似没有问题。但熟悉 Redux 的读者都知道,该定义忽略了 ReducersMapObject 和 S 的逻辑关系,S 的结构是由 ReducersMapObject 的结构决定的。

如下所示,先用 Mapped Types 拿到 ReducersMapObject 的结构,然后用获取函数返回值类型的方法拿到子 State 的类型,最后拼成一个大 State 类型。

type Reducer<S> = (state: S, action: any) => S;

type ReducersMap<FullState> = {

}

function combineReducers<FullState>(reducersMap: ReducersMap<FullState>): Reducer<FullState>;

使用新的 combineReducers 类型覆盖原先的类型定义后,经过 combineReducers 的层层递归,最终可以通过 RootReducer 推导出 Redux 全局 State 的类型!这样在 Redux Thunk 中和 connect 中,可以享受全局 State 类型,再也不需要害怕写错局部 state 路径了!

拿到全局 State 类型:

function returnType<FullState>(reducersMap: ReducersMap<FullState>): FullState {
  return ({} as any) as FullState;
}

const mockGlobalState = returnType(RootReducer);

type GlobalState = typeof mockGlobalState;
type GetState = () => GlobalState;

去形式化 & 类型推导

Redux 社区一直有很多去形式化的工具。但是现在风口不一样了,去形式化多了一项重大任务,做好类型支持!

关于类型和去形式化,由于 Redux ActionCreator 的型别取决于实际项目使用的 Redux 异步中间件。因此本文抛开笔者自身业务场景,只谈方法论,只做最简单的 ActionCreator 解决方案。读者可以用这些方法论创建适合自己项目的类型系统。

Redux Type

enum 来声明 Redux Type ,可以说是最精简的了。

enum BasicTypes {
  changeInputValue,
  toggleDialogVisible,
}

const Types = createTypes(prefix, BasicTypes);

然后用 createTypes 函数修正 enum 的类型和值。

createTypes 的定义如下所示,一方面用 Proxy 对属性值进行修正。另一方面用 Mapped Types 对类型进行修正。

type ReturnTypes<EnumTypes> = {
    [key in keyof EnumTypes]: key;
}

function createTypes<EnumTypes>(prefix, enumTypes: EnumTypes): ReturnTypes<EnumTypes> {
    return new Proxy(enumTypes as any, {
        get(target, property: any) {
            return prefix + '/' + property;
        }
    })
}

读者请注意,ReturnTypes 中,Redux Type 类型被修正为一个字符串字面量类型(key)!以为创造一个可辨识联合做准备。

Redux Action 类型优化

市面上有很多 Redux 的去形式化工具,因此本文不再赘述 Redux Action 的去形式化,只说 Redux Action 的类型优化。

笔者总结如下3点:

  • 1、要有一个整体 ActionCreators 的 interface 类型。

例如,可以定义定一个字面量对象来存储 actionCreators。

const actions = {
  /** 加 */
  add: ...
  /** 乘以 */
  multiply: ...
}

一方面其它模块引用起来会很方便,一方面可以对字面量做批量类型推导。并且其中的注释,只有在这种字面量下,才能够在 vscode 中解析,以在其它模块引用时可以提高辨识度,提高开发体验。

  • 2、每一个 actionCreator 需要定义 payload 类型。

如下代码所示,无论 actionCreator 是如何创建的,其 payload 类型必须明确指定。以便在 Reducer 中享用 payload 类型。

const actions = {
  /** 加 */
  add() {
    return { type: Types.add, payload: 3 };
  },
  /** 乘以 */
  multiply: createAction<{ num: number }>(Types.multiply)
}
  • 3、推导出可辨识联合类型。

最后,还要能够通过 actions 推导出可辨识联合类型。如此才能在 Reducer 不同 case 下享用不同的 payload 类型。

需要推导出的 ActionType 结构如下:

type ActionType = { type: 'add', payload: number }
  | { type: 'multiply', payload: { num: number } };

推导过程如下:

type ActionCreatorMap<ActionMap> = {
  [key in keyof ActionMap]: (payload?, arg2?, arg3?, arg4?) => ActionMap[key]
};
type ValueOf<ActionMap> = ActionMap[keyof ActionMap];

function returnType<ActionMap>(actions: ActionCreatorMap<ActionMap>) {
  type Action = ValueOf<ActionMap>;

  return {} as any as Action;
}

const mockAction = returnType(actions);
type ActionType = typeof mockAction;

function reducer(state: State, action: ActionType): State {
  switch (action.type) {
    case Types.add: { return ... }
    case Types.muliple: { return ... }
  }
}

前端类型优化

常用的React类型

  • Event

React 中 Event 参数很常见,因此 React 提供了丰富的关于 Event 的类型。比如最常用的 React.ChangeEvent:

// HTMLInputElement 为触发 Event 的元素类型
handleChange(e: React.ChangeEvent<HTMLInputElement>) {
  // e.target.value
  // e.stopPropagation
}

笔者更喜欢把 Event 转换成对应的 value

function pipeEvent<Element = HTMLInputElement>(func: any) {
  return (event: React.ChangeEvent<HTMLInputElement>) => {
    return func(event.target.value, event);
  };
}

<input onChange={pipeEvent(actions.changeValue)}>
  • RouteComponentProps

ReactRoute 提供了 RouteComponentProps 类型,提供了 location、params 的类型定义

type Props = OriginProps & RouteComponentProps<Params, {}>

自动产生接口类型

一般来说,前后端之间会用一个 API 约定平台或者接口约定文档,来做前后端解耦,比如 rap、 swagger。笔者在团队中做了一个把接口约定转换成 Typescript 类型定义代码的。经过笔者团队的实践,这种工具对开发效率、维护性都有很大的提高。

接口类型定义对开发的帮助:





在可维护性上。例如,一旦接口约定进行更改,API 的类型定义代码会重新生成,Typescript 能够检测到字段的不匹配,前端便能快速修正代码。最重要的是,由于前端代码与接口约定的绑定关系,保证了接口约定文档具有百分百的可靠性。我们得以通过接口约定来构建一个可靠的测试系统,进行自动化的联调与测试。

常用的默认类型

  • Partial

把 interface 所有属性变成可选:

interface Obj {
  a: number;
  b: string;
}

type OptionalObj = Partial<Obj>

// interface OptionalObj {
//   a?: number;
//   b?: string;
// }
  • Readonly

把 interface 所有属性变成 readonly:

interface Obj {
  a: number;
  b: string;
}

type ReadonlyObj = Readonly<Obj>

// interface ReadonlyObj {
//   readonly a: number;
//   readonly b: string;
// }
  • Pick
interface T {
  a: string;
  b: number;
  c: boolean;
}

type OnlyAB = Pick<T, 'a' | 'b'>;

// interface OnlyAB {
//   a: string;
//   b: number;
// }

总结

在 FP 中,函数就像一个个管道,在管道的连接处的数据块的类型总是不尽相同。下一层管道使用类型往往需要重新定义。

但是如果有一个确定的推导函数返回值类型的方法,那么只需要知道管道最开始的数据块类型,那么所有管道连接处的类型都可以推导出来。

当前 TS 版本尚不支持直接获取函数返回值类型,虽然本文介绍的间接方法也能解决问题,但最好还是希望 TS 早日直接支持:issue

JS 中的 FP 就像一匹脱缰的野马,请用类型拴住它。


最后!欢迎投递简历 zongquan.hzq@alibaba-inc.com,岗位不限。

文章被以下专栏收录