TypeScript基礎看膩了?進階實現智慧類型推導的簡化版Vuex,手把手帶你實現。

  • 2020 年 4 月 11 日
  • 筆記

之前幾篇講TypeScript的文章中,我帶來了在React中的一些小實踐

React + TypeScript + Hook 帶你手把手打造類型安全的應用。

React Hook + TypeScript 手把手帶你打造use-watch自定義Hook,實現Vue中的watch功能。

這篇文章我決定更進一步,直接用TypeScript實現一個類型安全的簡易版的Vuex。

這篇文章適合誰:

  1. 已經學習TypeScript基礎,需要一點進階玩法的你。
  2. 自己喜歡寫一些開源的小工具,需要進階學習TypeScript類型推導。(在項目中一般ts運用的比較淺層,大部分情況在寫表面的interface)。
  3. 單純的想要進階學習TypeScript。

通過這篇文章,你可以學到以下特性在實戰中是如何使用的:

  1. ?TypeScript的高級類型(Advanced Type
  2. ?TypeScript中利用泛型進行反向類型推導。(Generics)
  3. ?Mapped types(映射類型)
  4. ?Distributive Conditional Types(條件類型分配)
  5. ?TypeScript中Infer的實戰應用(Vue3源碼里infer的一個很重要的使用

希望通過這篇文章,你可以對TypeScript的高級類型實戰應用得心應手,對於未來想學習Vue3源碼的小夥伴來說,類型推斷和infer的用法也是必須熟悉的。

寫在前面:

本文實現的Vuex只有很簡單的stateactionsubscribeAction功能,因為Vuex當前的組織模式非常不適合類型推導(Vuex官方的type庫目前推斷的也很簡陋),所以本文中會有一些和官方不一致的地方,這些是刻意的為了類型安全而做的,本文的主要目標是學習TypeScript,而不是學習Vuex,所以請小夥伴們不要嫌棄它程式碼啰嗦或者和Vuex不一致。 ?

vuex骨架

首先定義我們Vuex的骨架。

export default class Vuex<S, A> {    state: S      action: Actions<S, A>      constructor({ state, action }: { state: S; action: Actions<S, A> }) {      this.state = state;      this.action = action;    }      dispatch(action: any) {    }  }  複製程式碼

首先這個Vuex構造函數定了兩個泛型SA,這是因為我們需要推出stateaction的類型,定義action對象的時候需要用到state的類型,而調用store.dispatch時需要用到action的key的類型(比如dispatch({type: "ADD"})中的type需要由對應 actions: { ADD() {} })的key值推斷。

然後在構造函數中,把S和state對應,把Actions<S, A>和傳入的action對應。

constructor({ state, action }: { state: S; action: Actions<S, A> }) {    this.state = state;    this.action = action;  }  複製程式碼

Actions這裡用到了映射類型,它等於是遍歷了傳入的A的key值,然後定義每一項實際上的結構,

export type Actions<S, A> = {    [K in keyof A]: (state: S, payload: any) => Promise<any>;  };  複製程式碼

看看我們傳入的actions

const store = new Vuex({    state: {      count: 0,      message: '',    },    action: {      async ADD(state, payload) {        state.count += payload;      },      async CHAT(state, message) {        state.message = message;      },    },  });  複製程式碼

是不是類型正好對應上了?此時ADD函數的形參里的state就有了類型推斷,它就是我們傳入的state的類型。

這是因為我們給Vuex的構造函數傳入state的時候,S就被反向推導為了state的類型,也就是{count: number, message: string},這時S又被傳給了Actions<S, A>, 自然也可以在action里獲得state的類型了。

現在有個問題,我們現在的寫法里沒有任何地方能體現出payload的類型,(這也是Vuex設計所帶來的一些缺陷)所以我們也只能寫成any,但是我們本文的目標是類型安全。

dispatch的類型安全

下面先想點辦法實現store.dispatch的類型安全:

  1. type需要自動提示。
  2. type填寫了以後,需要提示對應的payload的type。

所以參考redux的玩法,我們手動定義一個Action Types的聯合類型。

const ADD = 'ADD';  const CHAT = 'CHAT';    type AddType = typeof ADD;  type ChatType = typeof CHAT;    type ActionTypes =    | {        type: AddType;        payload: number;      }    | {        type: ChatType;        payload: string;      };    複製程式碼

Vuex中,我們新增一個輔助Ts推斷的方法,這個方法原封不動的返回dispatch函數,但是用了as關鍵字改寫它的類型,我們需要把ActionTypes作為泛型傳入:

export default class Vuex<S, A> {    ...      createDispatch<A>() {      return this.dispatch.bind(this) as Dispatch<A>;    }  }  複製程式碼

Dispatch類型的實現相當簡單,直接把泛型A交給第一個形參action就好了,由於ActionTypes是聯合類型,Ts會嚴格限制我們填寫的action的類型必須是AddType或者ChatType中的一種,並且填寫了AddType後,payload的類型也必須是number了。

export interface Dispatch<A> {    (action: A): any;  }  複製程式碼

然後使用它構造dispatch

// for TypeScript support  const dispatch = store.createDispatch<ActionTypes>();  複製程式碼

目標達成:

action形參中payload的類型安全

此時雖然store.diaptch完全做到了類型安全,但是在聲明action傳入vuex構造函數的時候,我不想像這樣手動聲明,

const store = new Vuex({    state: {      count: 0,      message: '',    },    action: {      async [ADD](state, payload: number) {        state.count += payload;      },      async [CHAT](state, message: string) {        state.message = message;      },    },  });  複製程式碼

因為這個類型在剛剛定義的ActionTypes中已經有了,秉著DRY的原則,我們繼續折騰吧。

首先現在我們有這些佐料:

const ADD = 'ADD';  const CHAT = 'CHAT';    type AddType = typeof ADD;  type ChatType = typeof CHAT;    type ActionTypes =    | {        type: AddType;        payload: number;      }    | {        type: ChatType;        payload: string;      };    複製程式碼

所以我想通過一個類型工具,能夠傳入AddType給我返回number,傳入ChatType給我返回string

它大概是這個樣子的:

type AddPayload = PickPayload<ActionTypes, AddType> // number  type ChatPayload = PickPayload<ActionTypes, ChatType> // string  複製程式碼

為了實現它,我們需要用到distributive-conditional-types,不熟悉的同學可以好好看看這篇文章。

簡單的來說,如果我們把一個聯合類型

string | number  複製程式碼

傳遞給一個用了extends關鍵字的類型工具:

type PickString<T> = T extends string ? T: never    type T1 = PickString<string | number> // string  複製程式碼

它並不是像我們想像中的直接去用string | number直接匹配是否extends,而是把聯合類型拆分開來,一個個去匹配。

type PickString<T> =  | string extends string ? T: never  | number extends string ? T: never  複製程式碼

所以返回的類型是string | never,由由於never在聯合類型中沒什麼意義,所以就被過濾成string

藉由這個特性,我們就有思路了,這裡用到了infer這個關鍵字,Vue3中也有很多推斷是藉助它實現的,它只能用在extends的後面,代表一個還未出現的類型,關於infer的玩法,詳細可以看這篇文章:巧用 TypeScript(五)—- infer

export type PickPayload<Types, Type> = Types extends {    type: Type;    payload: infer P;  }    ? P    : never;  複製程式碼

我們用Type這個字元串類型,讓ActionTypes中的每一個類型一個個去過濾匹配,比如傳入的是AddType:

PickPayload<ActionTypes, AddType>  複製程式碼

則會被分布成:

type A =    | { type: AddType;payload: number;} extends { type: AddType; payload: infer P }    ? P    : never    |    { type: ChatType; payload: string } extends { type: AddType; payload: infer P }    ? P    : never;  複製程式碼

注意infer P的位置,被放在了payload的位置上,所以第一項的type在命中後, P也被自動推斷為了number,而三元運算符的 ? 後,我們正是返回了P,也就推斷出了number這個類型。

這時候就可以完成我們之前的目標了,也就是根據AddType這個類型推斷出payload參數的類型,PickPayload這個工具類型應該定位成vuex官方倉庫里提供的輔助工具,而在項目中,由於ActionType已經確定,所以我們可以進一步的提前固定參數。(有點類似於函數柯里化)

type PickStorePayload<T> = PickPayload<ActionTypes, T>;  複製程式碼

此時,我們定義一個類型安全的Vuex實例所需要的所有輔助類型都定義完畢:

const ADD = 'ADD';  const CHAT = 'CHAT';    type AddType = typeof ADD;  type ChatType = typeof CHAT;    type ActionTypes =    | {        type: AddType;        payload: number;      }    | {        type: ChatType;        payload: string;      };    type PickStorePayload<T> = PickPayload<ActionTypes, T>;  複製程式碼

使用起來就很簡單了:

const store = new Vuex({    state: {      count: 0,      message: '',    },    action: {      async [ADD](state, payload: PickStorePayload<AddType>) {        state.count += payload;      },      async [CHAT](state, message: PickStorePayload<ChatType>) {        state.message = message;      },    },  });    // for TypeScript support  const dispatch = store.createDispatch<ActionTypes>();    dispatch({    type: ADD,    payload: 3,  });    dispatch({    type: CHAT,    payload: 'Hello World',  });  複製程式碼

總結

本文的所有程式碼都在 github.com/sl1673495/t… 倉庫里,裡面還加上了getters的實現和類型推導。

通過本文的學習,相信你會對高級類型的用法有進一步的理解,也會對TypeScript的強大更加嘆服,本文有很多例子都是為了教學而刻意深究,複雜化的,請不要罵我(XD)。

在實際的項目運用中,首先我們應該避免Vuex這種集中化的類型定義,而盡量去擁抱函數(函數對於TypeScript是天然支援),這也是Vue3往函數化api方向走的原因之一。

參考文章

React + Typescript 工程化治理實踐(螞蟻金服的大佬實踐總結總是這麼靠譜) juejin.im/post/5dccc9…

TS 學習總結:編譯選項 && 類型相關技巧 zxc0328.github.io/diary/2019/…

Conditional types in TypeScript(據說比Ts官網講的好) mariusschulz.com/blog/condit…

Conditional Types in TypeScript(文風幽默,程式碼非常硬核) artsy.github.io/blog/2018/1…