如何让 Redux 和 TypeScript 愉快的玩耍

3,906 阅读3分钟

分享一下我自己针对 React+Redux 项目的 TS 配置。

TS 对 Redux 项目最大的加成是代码提示。运用 discriminated unions 技巧,可以在写 reducer / dispatch 逻辑的时候,获得体贴入微的代码提示体验。

目标效果

针对 reducer,对不同的 action.type,我们希望获得不同的 action.payload 内容的提示。

function reducer(state: DefaultRootState, action: IAction): DefaultRootState {
  switch (action.type) {
      case ACT.SAY_HELLO:
          return { ...state, msg: action.payload.msg }
      case ACT.INC_BY:
          return { ...state, count: state.count + action.payload.count }
      default:
          return state
  }
}

针对 dispatch,希望调用的时候,能够对 payload 做字段类型检查。

dispatch({ type: ACT.SAY_HELLO, payload: { msg: 'foobar' } })
dispatch({ type: ACT.INC_BY, payload: { msg: 'foobar' } })  // ERROR!

如何获得以上效果?配置如下。

TS 配置

1. 创建 src/typings/action.d.ts 文件,内容:

declare const enum ACT {
  SAY_HELLO = "SAY_HELLO",
  INC_BY = "INC_BY",
}

type IActionPayloadMapping = {
  [ACT.SAY_HELLO]: { msg: string };
  [ACT.INC_BY]: { count: number };
};

type IActionPayload<T> = T extends keyof IActionPayloadMapping
  ? IActionPayloadMapping[T]
  : any;

type IAction = {
  [key in keyof typeof ACT]: {
    type: typeof ACT[key];
    payload?: IActionPayload<typeof ACT[key]>;
  };
}[keyof typeof ACT];

首先我们用一个全局的 const enum ACT 来记录 action type,然后用一些技巧动态地构建一个 union type IAction, 它的最终内容是形如:

type IAction = {
    type: ACT.SAY_HELLO,
    payload: {
        msg: string
    }
} | {
    type: ACT.INC_BY,
    payload: {
        count: number
    }
};

这样一个复杂的 type 手写是不现实的,所以才用以上技巧动态的去构建它。我们只需要更新 enum ACT,以及往 IActionPayloadMapping 里加入对应的 payload 就好,这样写起来会舒服很多。

2. 创建 src/typings/state.d.ts 文件,内容:

interface IState {
  count: number;
  msg: string;
}

这里我们声明 redux store state 的字段内容,我自己偏好在单独的一个 .d.ts 文件内声明,因为一般这块的内容比较繁琐庞杂。

3. src/store.ts 集成以上工具类型:

import { createStore } from "redux";
import { DefaultRootState } from "react-redux";

declare module "react-redux" {
  // 要点一:
  interface DefaultRootState extends IState {}
  // 要点二:
  export function useDispatch(): <T extends IAction>(action: T) => T;
}

// 要点三:
function reducer(state: DefaultRootState, action: IAction): DefaultRootState {
  switch (action.type) {
    case ACT.SAY_HELLO:
      return { ...state, msg: action.payload.msg };
    case ACT.INC_BY:
      return { ...state, count: state.count + action.payload.count };
    default:
      return state;
  }
}

const store = createStore(reducer);
export default store;

要点一:

declaration merging 技巧把字段声明注入到 "react-redux" 库内预留的空白 interface DefaultRootState 里头。这个空白的 interface 就是官方推荐如此使用的,可见于源码的 comment.

通过注入的方式,我们把自定义的 IState 的字段类型声明,成功地混入了 DefaultRootState 中。由于 DefaultRootState 在 "react-redux" 库内部被 useSelector<T=DefaultRootState>() 引用为 generic T 的默认值,我们之后调用 useSelector(state => {...}) 的时候,这里的 state 参数的类型会默认采用 DefaultRootState,其实也就是我们自定义的 IState 的类型。

要点二:

同时我们要改写 useDispatch() 函数签名 (function signature). 使得它接受的参数 action 符合 extends IAction 的限制。这里的 IAction 就是前面我们在 action.d.ts 文件内动态构建的那个 union type.

这一步非常重要,因为之后我们在项目内会经常用到 const dispatch = useDispatch(), 我们需要通过改写的方式,把 IAction 的信息喂给 TS 引擎,之后它才能帮我们做类型检查和代码提示。

要点三:

也是最后一步,把 DefaultRootStateIAction 都用到 reducer() 的函数签名上。从此你写 reducer 函数的时候就有 action.payload 内容的代码提示啦。

最终成果

直接上图:

reducer: 每个 switch-case 分支内 action.payload 都是不一样的

dispatch: 故意犯错被抓现形

两者都是运用了 TS 的一个重要语言特性,discriminated unions。原理上,简而言之,IAction = A | B | ... | Z 有多种可能,但是 A-Z 有一个共同的字段是 "type", 你在 case 这里已经声明了一个具体 type 的值,那 TS 就能够以此帮你定位到 A-Z 中某一个具体的可能值,并帮你找出它对应的 action.payload 的类型,给到你代码提示和类型检查了。