分享一下我自己针对 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 引擎,之后它才能帮我们做类型检查和代码提示。
要点三:
也是最后一步,把 DefaultRootState 和 IAction 都用到 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 的类型,给到你代码提示和类型检查了。