你所不知道的Typescript與Redux型別優化

奇陽黑科技發表於2018-07-18

自從 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 技巧。

理論基礎

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> = {
  [key in keyof T]: number;
}

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 優化

原來的定義:

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> = {
  [key in keyof FullState]: Reducer<FullState[key]>;
}

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 解決方案。讀者可以用這些方法論建立適合自己專案的型別系統。

經團隊同學提醒,為了讀者有更好的型別體感,筆者建立了一個 repo 供讀者體驗:

https://github.com/jasonHzq/redux-ts-helper

讀者可以 clone 下來在 vscode 中進行體驗。

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

FP 就像一匹脫韁的野馬,請用型別拴住它。


相關文章