1. 程式人生 > 其它 >你不知道的 TypeScript 泛型(萬字長文,建議收藏)

你不知道的 TypeScript 泛型(萬字長文,建議收藏)

你不知道的 TypeScript 泛型(萬字長文,建議收藏)

lucifer

2020-06-16

前端 / TypeScript / 泛型

 

泛型是 TypeScript(以下簡稱 TS) 比較高階的功能之一,理解起來也比較困難。泛型應用場景非常廣泛,很多地方都能看到它的影子。平時我們閱讀開源 TS 專案原始碼,或者在自己的 TS 專案中使用一些第三方庫(比如 React)的時候,經常會看到各種泛型定義。如果你不是特別瞭解泛型,那麼你很可能不僅不會用,不會實現,甚至看不懂這是在幹什麼。

相信大家都經歷過,看到過,或者正在寫一些應用,這些應用充斥著各種重複型別定義, any 型別層出不窮,滑鼠移到變數上面的提示只有 any,不要說型別操作了,型別能寫對都是個問題。我也經歷過這樣的階段,那個時候我對 TS 還比較陌生。

隨著在 TS 方面學習的深入,越來越認識到 真正的 TS 高手都是在玩型別,對型別進行各種運算生成新的型別。這也好理解,畢竟 TS 提供的其實就是型別系統。你去看那些 TS 高手的程式碼,會各種花式使用泛型。 可以說泛型是一道坎,只有真正掌握它,你才知道原來 TS 還可以這麼玩。怪不得面試的時候大家都願意問泛型,儘管面試官很可能也不怎麼懂。

只有理解事物的內在邏輯,才算真正掌握了,不然永遠只是皮毛,不得其法。 本文就帶你走進泛型,帶你從另一個角度看看究竟什麼是泛型,為什麼要有它,它給 TS 帶來了什麼樣的不同。

注意:不同語言泛型略有不同,知識遷移雖然可以,但是不能生搬硬套,本文所講的泛型都指的是 TS 下的泛型。

引言

我總結了一下,學習 TS 有兩個難點。第一個是TS 和 JS 中容易混淆的寫法,第二個是TS中特有的一些東西

  • TS 中容易引起大家的混淆的寫法

比如:

(容易混淆的箭頭函式)

再比如:

(容易混淆的 interface 內的小括號)

  • TS 中特有的一些東西

比如 typeof,keyof, infer 以及本文要講的泛型。

把這些和 JS 中容易混淆的東西分清楚,然後搞懂 TS 特有的東西,尤其是泛型(其他基本上相對簡單),TS 就入門了。

泛型初體驗

在強型別語言中,一般而言需要給變數指定型別才能使用該變數。如下程式碼:

1
2
const name: string = "lucifer";
console.log(name);

我們需要給 name 宣告 string 型別,然後才能在後面使用 name 變數,當我們執行以下操作的時候會報錯。

  • 給 name 賦其他型別的值
  • 使用其他型別值特有的方法(比如 Number 型別特有的 toFixed)
  • 將 name 以引數傳給不支援 string 的函式。 比如 divide(1, name),其中 divide 就是功能就是將第一個數(number 型別)除以第二個數(number 型別),並將結果返回

TS 除了提供一些基本型別(比如上面的 string)供我們直接使用。還:

  • 提供了 inteface 和 type 關鍵字供我們定義自己的型別,之後就能像使用基本型別一樣使用自己定義的型別了。
  • 提供了各種邏輯運算子,比如 &, | 等 ,供我們對型別進行操作,從而生成新的型別。
  • 提供泛型,允許我們在定義的時候不具體指定型別,而是泛泛地說一種型別,並在函式呼叫的時候再指定具體的引數型別。
  • 。。。

也就是說泛型也是一種型別,只不過不同於 string, number 等具體的型別,它是一種抽象的型別,我們不能直接定義一個變數型別為泛型。

簡單來說,區別於平時我們對值進行程式設計,泛型是對型別進行程式設計。這個聽起來比較抽象。之後我們會通過若干例項帶你理解這句話,你先留一個印象就好。

為了明白上面這句話,·首先要區分“值”和“型別”。

值和型別

我們平時寫程式碼基本都是對值程式設計。比如:

1
2
3
4
5
6
7
8
9
if (person.isVIP) {
console.log('VIP')
}
if (cnt > 5) {
// do something
}

const personNames = persons.map(p => p.name)
...

可以看出這都是對具體的值進行程式設計,這符合我們對現實世界的抽象。從集合論的角度上來說, 值的集合就是型別,在 TS 中最簡單的用法是對值限定型別,從根本上來說是限定值的集合。這個集合可以是一個具體的集合,也可以是多個集合通過集合運算(交叉並)生成的新集合。

(值和型別)

再來看一個更具體的例子:

1
2
3
4
function t(name: string) {
return `hello, ${name}`;
}
t("lucifer");

字串 “lucifer” 是 string 型別的一個具體值。 在這裡 “lucifer” 就是值,而 string 就是型別。

TS 明白 “lucifer” 是 string 集合中的一個元素,因此上面程式碼不會有問題,但是如果是這樣就會報錯:

1
t(123);

因為 123 並不是 string 集合中的一個元素。

對於 t(“lucifer”)而言,TS 判斷邏輯的虛擬碼:

1
2
3
4
5
6
v = getValue(); // will return 'lucifer' by ast
if (typeof v === "string") {
// ok
} else {
throw "type error";
}

由於是靜態型別分析工具,因此 TS 並不會執行 JS 程式碼,但並不是說 TS 內部沒有執行邏輯。

簡單來總結一下就是: 值的集合就是型別,平時寫程式碼基本都是對值程式設計,TS 提供了很多型別(也可以自定義)以及很多型別操作幫助我們限定值以及對值的操作。

什麼是泛型

上面已經鋪墊了一番,大家已經知道了值和型別的區別,以及 TS 究竟幫我們做了什麼事情。但是直接理解泛型仍然會比較吃力,接下來我會通過若干例項,慢慢帶大家走進泛型。

首先來思考一個問題:為什麼要有泛型呢?這個原因實際上有很多,在這裡我選擇大家普遍認同的一個切入點來解釋。如果你明白了這個點,其他點相對而言理解起來會比較輕鬆。還是通過一個例子來進行說明。

不容小覷的 id 函式

假如讓你實現一個函式 id,函式的引數可以是任何值,返回值就是將引數原樣返回,並且其只能接受一個引數,你會怎麼做?

你會覺得這很簡單,順手就寫出這樣的程式碼:

1
const id = (arg) => arg;

有的人可能覺得 id 函式沒有什麼實際作用。其實不然, id 函式在函數語言程式設計中應用非常廣泛。

由於其可以接受任意值,也就是說你的函式的入參和返回值都應該可以是任意型別。 現在讓我們給程式碼增加型別宣告:

1
2
3
4
type idBoolean = (arg: boolean) => boolean;
type idNumber = (arg: number) => number;
type idString = (arg: string) => string;
...

一個笨的方法就像上面那樣,也就是說 JS 提供多少種類型,就需要複製多少份程式碼,然後改下型別簽名。這對程式設計師來說是致命的。這種複製貼上增加了出錯的概率,使得程式碼難以維護,牽一髮而動全身。並且將來 JS 新增新的型別,你仍然需要修改程式碼,也就是說你的程式碼對修改開放,這樣不好。還有一種方式是使用 any 這種“萬能語法”。缺點是什麼呢?我舉個例子:

1
2
3
4
id("string").length; // ok
id("string").toFixed(2); // ok
id(null).toString(); // ok
...

如果你使用 any 的話,怎麼寫都是 ok 的, 這就喪失了型別檢查的效果。實際上我知道我傳給你的是 string,返回來的也一定是 string,而 string 上沒有 toFixed 方法,因此需要報錯才是我想要的。也就是說我真正想要的效果是:當我用到id的時候,你根據我傳給你的型別進行推導。比如我傳入的是 string,但是使用了 number 上的方法,你就應該報錯。

為了解決上面的這些問題,我們使用泛型對上面的程式碼進行重構。和我們的定義不同,這裡用了一個 型別 T,這個 T 是一個抽象型別,只有在呼叫的時候才確定它的值,這就不用我們複製貼上無數份程式碼了。

1
2
3
function id<T>(arg: T): T {
return arg;
}

為什麼這樣就可以了? 為什麼要用這種寫法?這個尖括號什麼鬼?萬物必有因果,之所以這麼設計泛型也是有原因的。那麼就讓我來給大家解釋一下,相信很多人都沒有從這個角度思考過這個問題。

泛型就是對型別程式設計

上面提到了一個重要的點 平時我們都是對值進行程式設計,泛型是對型別進行程式設計。上面我沒有給大家解釋這句話。現在鋪墊足夠了,那就讓我們開始吧!

繼續舉一個例子:假如我們定義了一個 Person 類,這個 Person 類有三個屬性,並且都是必填的。這個 Person 類會被用於使用者提交表單的時候限定表單資料。

1
2
3
4
5
6
7
8
9
10
enum Sex {
Man,
Woman,
UnKnow,
}
interface Person {
name: string;
sex: Sex;
age: number;
}

突然有一天,公司運營想搞一個促銷活動,也需要用到 Person 這個 shape,但是這三個屬性都可以選填,同時要求使用者必須填寫手機號以便標記使用者和接受簡訊。一個很笨的方法是重新寫一個新的類:

1
2
3
4
5
6
interface MarketPerson {
name?: string;
sex?: Sex;
age?: number;
phone: string;
}

還記得我開頭講的重複型別定義麼? 這就是!

這明顯不夠優雅。如果 Person 欄位很多呢?這種重複程式碼會異常多,不利於維護。 TS 的設計者當然不允許這麼醜陋的設計存在。那麼是否可以根據已有型別,生成新的型別呢?當然可以!答案就是前面我提到了兩種對型別的操作:一種是集合操作,另一種是今天要講的泛型。

先來看下集合操作:

1
type MarketPerson = Person & { phone: string };

這個時候我們雖然添加了一個必填欄位 phone,但是沒有做到name, sex, age 選填,似乎集合操作做不到這一點呀。我們腦洞一下,假如我們可以像操作函式那樣操作型別,是不是有可能呢?比如我定義了一個函式 Partial,這個函式的功能入參是一個型別,返回值是新的型別,這個型別裡的屬性全部變成可選的。

虛擬碼:

1
2
3
4
5
6
7
8
9
10

function Partial(Type) {
type ans = 空型別
for(k in Type) {
空型別[k] = makeOptional(Type, k)
}
return ans
}

type PartialedPerson = Partial(Person)

可惜的是上面程式碼不能執行,也不可能執行。不可能執行的原因有:

  • 這裡使用函式 Partial 操作型別,可以看出上面的函式我是沒有添加簽名的,我是故意的。如果讓你給這個函式添加簽名你怎麼加?沒辦法加!
  • 這裡使用 JS 的語法對型別進行操作,這是不恰當的。首先這種操作依賴了 JS 執行時,而 TS 是靜態分析工具,不應該依賴 JS 執行時。其次如果要支援這種操作是否意味者 TS 對 JS 妥協,JS 出了新的語法(比如早幾年出的 async await),TS 都要支援其對 TS 進行操作。

因此迫切需要一種不依賴 JS 行為,特別是執行時行為的方式,並且邏輯其實和上面類似的,且不會和現有語法體系衝突的語法。 我們看下 TS 團隊是怎麼做的:

1
2
3
4
// 可以看成是上面的函式定義,可以接受任意型別。由於是這裡的 “Type” 形參,因此理論上你叫什麼名字都是無所謂的,就好像函式定義的形參一樣。
type Partial<Type> = { do something }
// 可以看成是上面的函式呼叫,呼叫的時候傳入了具體的型別 Person
type PartialedPerson = Partial<Person>

先不管功能,我們來看下這兩種寫法有多像:

(定義)

(執行)

再來看下上面泛型的功能。上面程式碼的意思是對 T 進行處理,是返回一個 T 的子集,具體來說就是將 T 的所有屬性變成可選。這時 PartialedPerson 就等於 :

1
2
3
4
5
interface Person {
name?: string;
sex?: Sex;
age?: number;
}

功能和上面新建一個新的 interface 一樣,但是更優雅。

最後來看下泛型 Partial 的具體實現,可以看出其沒有直接使用 JS 的語法,而是自己定義了一套語法,比如這裡的 keyof,至此完全應證了我上面的觀點。

1
type Partial<T> = { [P in keyof T]?: T[P] };

剛才說了“由於是形參,因此起什麼名字無所謂” 。因此這裡就起了 T 而不是 Type,更短了。這也算是一種約定俗稱的規範,大家一般習慣叫 T, U 等表示泛型的形參。

我們來看下完整的泛型和函式有多像!

(定義)

(使用)

  • 從外表看只不過是 function 變成了 type() 變成了 <>而已。

  • 從語法規則上來看, 函式內部對標的是 ES 標準。而泛型對應的是 TS 實現的一套標準。

簡單來說,將型別看成值,然後對型別進行程式設計,這就是泛型的基本思想。泛型類似我們平時使用的函式,只不過其是作用在型別上,思想上和我們平時使用的函式並沒有什麼太多不同,泛型產生的具體型別也支援型別的操作。比如:

1
type ComponentType<P = {}> = ComponentClass<P> | FunctionComponent<P>;

有了上面的知識,我們通過幾個例子來鞏固一下。

1
2
3
function id<T, U>(arg1: T, arg2: U): T {
return arg1;
}

上面定義了泛型 id,其入參分別是 T 和 U,和函式引數一樣,使用逗號分隔。定義了形參就可以在函式體內使用形參了。如上我們在函式的引數列表和返回值中使用了形參 T 和 U。

返回值也可以是複雜型別:

1
2
3
function ids<T, U>(arg1: T, arg2: U): [T, U] {
return [arg1, arg2];
}

(泛型的形參)

和上面類似, 只不過返回值變成了陣列而已。

需要注意的是,思想上我們可以這樣去理解。但是具體的實現過程會有一些細微差別,比如:

1
2
3
4
type P = [number, string, boolean];
type Q = Date;

type R = [Q, ...P]; // A rest element type must be an array type.

再比如:

1
2
3
4
5
6
7
type Lucifer = LeetCode;
type LeetCode<T = {}> = {
name: T;
};

const a: LeetCode<string>; //ok
const a: Lucifer<string>; // Type 'Lucifer' is not generic.

改成這樣是 ok 的:

1
type Lucifer<T> = LeetCode<T>;

泛型為什麼使用尖括號

為什麼泛型要用尖括號(<>),而不是別的? 我猜是因為它和 () 長得最像,且在現在的 JS 中不會有語法歧義。但是,它和 JSX 不相容!比如:

1
2
3
4
5
6
7
function Form() {
// ...

return (
<Select<string> options={targets} value={target} onChange={setTarget} />
);
}

這是因為 TS 發明這個語法的時候,還沒想過有 JSX 這種東西。後來 TS 團隊在 TypeScript 2.9 版本修復了這個問題。也就是說現在你可以直接在 TS 中使用帶有泛型引數的 JSX 啦(比如上面的程式碼)。

泛型的種類

實際上除了上面講到的函式泛型,還有介面泛型和類泛型。不過語法和含義基本同函式泛型一樣:

1
2
3
4
interface id<T, U> {
id1: T;
id2: U;
}

(介面泛型)

1
2
3
class MyComponent extends React.Component<Props, State> {
...
}

(類泛型)

總結下就是: 泛型的寫法就是在標誌符後面新增尖括號(<>),然後在尖括號裡寫形參,並在 body(函式體, 介面體或類體) 裡用這些形參做一些邏輯處理。

泛型的引數型別 - “泛型約束”

正如文章開頭那樣,我們可以對函式的引數進行限定。

1
2
3
4
function t(name: string) {
return `hello, ${name}`;
}
t("lucifer");

如上程式碼對函式的形參進行了型別限定,使得函式僅可以接受 string 型別的值。那麼泛型如何達到類似的效果呢?

1
type MyType = (T: constrain) => { do something };

還是以 id 函式為例,我們給 id 函式增加功能,使其不僅可以返回引數,還會打印出引數。熟悉函數語言程式設計的人可能知道了,這就是 trace 函式,用於除錯程式。

1
2
3
4
function trace<T>(arg: T): T {
console.log(arg);
return arg;
}

假如我想打印出引數的 size 屬性呢?如果完全不進行約束 TS 是會報錯的:

注意:不同 TS 版本可能提示資訊不完全一致,我的版本是 3.9.5。下文的所有測試結果均是使用該版本,不再贅述。

1
2
3
4
function trace<T>(arg: T): T {
console.log(arg.size); // Error: Property 'size doesn't exist on type 'T'
return arg;
}

報錯的原因在於 T 理論上是可以是任何型別的,不同於 any,你不管使用它的什麼屬性或者方法都會報錯(除非這個屬性和方法是所有集合共有的)。那麼直觀的想法是限定傳給 trace 函式的引數型別應該有 size 型別,這樣就不會報錯了。如何去表達這個型別約束的點呢?實現這個需求的關鍵在於使用型別約束。 使用 extends 關鍵字可以做到這一點。簡單來說就是你定義一個型別,然後讓 T 實現這個介面即可。

1
2
3
4
5
6
7
interface Sizeable {
size: number;
}
function trace<T extends Sizeable>(arg: T): T {
console.log(arg.size);
return arg;
}

這個時候 T 就不再是任意型別,而是被實現介面的 shape,當然你也可以繼承多個介面。型別約束是非常常見的操作,大家一定要掌握。

有的人可能說我直接將 Trace 的引數限定為 Sizeable 型別可以麼?如果你這麼做,會有型別丟失的風險,詳情可以參考這篇文章A use case for TypeScript Generics

常見的泛型

集合類

大家平時寫 TS 一定見過類似 Array<String> 這種寫法吧? 這其實是集合類,也是一種泛型。

本質上陣列就是一系列值的集合,這些值可以可以是任意型別,陣列只是一個容器而已。然而平時開發的時候通常陣列的專案型別都是相同的,如果不加約束的話會有很多問題。 比如我應該是一個字串陣列,然是卻不小心用到了 number 的方法,這個時候型別系統應該幫我識別出這種型別問題。

由於陣列理論可以存放任意型別,因此需要使用者動態決定你想儲存的資料型別,並且這些型別只有在被呼叫的時候才能去確定。 Array<String> 就是呼叫,經過這個呼叫會產生一個具體集合,這個集合只能存放 string 型別的值。

不呼叫直接把 Array 是不被允許的:

1
const a: Array = ["1"];

如上程式碼會被錯:Generic type 'Array<T>' requires 1 type argument(s).ts 。 有沒有覺得和函式呼叫沒傳遞引數報錯很像?像就對了。

這個時候你再去看 Set, Promise,是不是很快就知道啥意思了?它們本質上都是包裝型別,並且支援多種引數型別,因此可以用泛型來約束。

React.FC

大家如果開發過 React 的 TS 應用,一定知道 React.FC 這個型別。我們來看下它是如何定義的:

1
2
3
4
5
6
7
8
9
type FC<P = {}> = FunctionComponent<P>;

interface FunctionComponent<P = {}> {
(props: PropsWithChildren<P>, context?: any): ReactElement<any, any> | null;
propTypes?: WeakValidationMap<P>;
contextTypes?: ValidationMap<any>;
defaultProps?: Partial<P>;
displayName?: string;
}

可以看出其大量使用了泛型。你如果不懂泛型怎麼看得懂呢?不管它多複雜,我們從頭一點點分析就行,記住我剛才講的類比方法,將泛型類比到函式進行理解。·

  • 首先定義了一個泛型型別 FC,這個 FC 就是我們平時用的 React.FC。它是通過另外一個泛型 FunctionComponent 產生的。

因此,實際上第一行程式碼的作用就是起了一個別名

  • FunctionComponent 實際上是就是一個介面泛型,它定義了五個屬性,其中四個是可選的,並且是靜態類屬性。
  • displayName 比較簡單,而 propTypes,contextTypes,defaultProps 又是通過其他泛型生成的型別。我們仍然可以採用我的這個分析方法繼續分析。由於篇幅原因,這裡就不一一分析,讀者可以看完我的分析過程之後,自己嘗試分析一波。
  • (props: PropsWithChildren<P>, context?: any): ReactElement<any, any> | null; 的含義是 FunctionComponent 是一個函式,接受兩個引數(props 和 context )返回 ReactElement 或者 null。ReactElement 大家應該比較熟悉了。PropsWithChildren 實際上就是往 props 中插入 children,原始碼也很簡單,程式碼如下:
1
type PropsWithChildren<P> = P & { children?: ReactNode };

這不就是我們上面講的集合操作和 可選屬性麼?至此,React.FC 的全貌我們已經清楚了。讀者可以試著分析別的原始碼檢測下自己的學習效果,比如 React.useState 型別的簽名。

型別推導與預設引數

型別推導和預設引數是 TS 兩個重要功能,其依然可以作用到泛型上,我們來看下。

型別推導

我們一般常見的型別推導是這樣的:

1
2
3
const a = "lucifer"; // 我們沒有給 a 宣告型別, a 被推導為 string
a.toFixed(); // Property 'toFixed' does not exist on type 'string'.
a.includes("1"); // ok

需要注意的是,型別推導是僅僅在初始化的時候進行推導,如下是無法正確推導的:

1
2
3
4
5
let a = "lucifer"; // 我們沒有給 a 宣告型別, a 被推導為string
a.toFixed(); // Property 'toFixed' does not exist on type 'string'.
a.includes("1"); // ok
a = 1;
a.toFixed(); // 依然報錯, a 不會被推導 為 number

而泛型也支援型別推導,以上面的 id 函式為例:

1
2
3
4
5
function id<T>(arg: T): T {
return arg;
}
id<string>("lucifer"); // 這是ok的,也是最完整的寫法
id("lucifer"); // 基於型別推導,我們可以這樣簡寫

這也就是為什麼 useState 有如下兩種寫法的原因。

1
2
const [name, setName] = useState("lucifer");
const [name, setName] = useState<string>("lucifer");

實際的型別推導要更加複雜和智慧。相信隨著時間的推進,TS 的型別推導會更加智慧。

預設引數

型別推導相同的點是,預設引數也可以減少程式碼量,讓你少些程式碼。前提是你要懂,不然伴隨你的永遠是大大的問號。其實你完全可以將其類比到函式的預設引數來理解。

舉個例子:

1
2
3
4
type A<T = string> = Array<T>;
const aa: A = [1]; // type 'number' is not assignable to type 'string'.
const bb: A = ["1"]; // ok
const cc: A<number> = [1]; // ok

上面的 A 型別預設是 string 型別的陣列。你可以不指定,等價於 Array,當然你也可以顯式指定陣列型別。有一點需要注意:在 JS 中,函式也是值的一種,因此:

1
const fn = () => null; // ok

但是泛型這樣是不行的,這是和函式不一樣的地方(設計缺陷?Maybe):

1
type A = Array; // error: Generic type 'Array<T>' requires 1 type argument(s).

其原因在與 Array 的定義是:

1
2
3
interface Array<T> {
...
}

而如果 Array 的型別也支援預設引數的話,比如:

1
2
3
interface Array<T = string> {
...
}

那麼 type A = Array; 就是成立的,如果不指定的話,會預設為 string 型別。

什麼時候用泛型

如果你認真看完本文,相信應該知道什麼時候使用泛型了,我這裡簡單總結一下。

當你的函式,介面或者類:

  • 需要作用到很多型別的時候,比如我們介紹的 id 函式的泛型宣告。
  • 需要被用到很多地方的時候,比如我們介紹的 Partial 泛型。

進階

上面說了泛型和普通的函式有著很多相似的地方。普通的函式可以巢狀其他函式,甚至巢狀自己從而形成遞迴。泛型也是一樣!

泛型支援函式巢狀

比如:

1
type CutTail<Tuple extends any[]> = Reverse<CutHead<Reverse<Tuple>>>;

如上程式碼中, Reverse 是將引數列表反轉,CutHead 是將陣列第一項切掉。因此 CutTail 的意思就是將傳遞進來的引數列表反轉,切掉第一個引數,然後反轉回來。換句話說就是切掉引數列表的最後一項。 比如,一個函式是 function fn (a: string, b: number, c: boolean):boolean {},那麼經過操作type cutTailFn = CutTail<typeof fn>,可以返回(a: string, b:number) => boolean。 具體實現可以參考Typescript 複雜泛型實踐:如何切掉函式引數表的最後一個引數?。 在這裡,你知道泛型支援巢狀就夠了。

泛型支援遞迴

泛型甚至可以巢狀自己從而形成遞迴,比如我們最熟悉的單鏈表的定義就是遞迴的。

1
2
3
4
type ListNode<T> = {
data: T;
next: ListNode<T> | null;
};

(單鏈表)

再比如 HTMLElement 的定義。

1
2
3
4
declare var HTMLElement: {
prototype: HTMLElement;
new(): HTMLElement;
};。

HTMLElement

上面是遞迴宣告,我們再來看一個更復雜一點的遞迴形式 - 遞迴呼叫,這個遞迴呼叫的功能是:遞迴地將型別中所有的屬性都變成可選。類似於深拷貝那樣,只不過這不是拷貝操作,而是變成可選,並且是作用在型別,而不是值。

1
2
3
4
5
6
7
type DeepPartial<T> = T extends Function
? T
: T extends object
? { [P in keyof T]?: DeepPartial<T[P]> }
: T;

type PartialedWindow = DeepPartial<Window>; // 現在window 上所有屬性都變成了可選啦

TS 泛型工具及實現

雖然泛型支援函式的巢狀,甚至遞迴,但是其語法能力肯定和 JS 沒法比, 想要實現一個泛型功能真的不是一件容易的事情。這裡提供幾個例子,看完這幾個例子,相信你至少可以達到比葫蘆畫瓢的水平。這樣多看多練,慢慢水平就上來了。

截止目前(2020-06-21),TS 提供了 16 種工具型別

(官方提供的工具型別)

除了官方的工具型別,還有一些社群的工具型別,比如type-fest,你可以直接用或者去看看原始碼看看高手是怎麼玩型別的。

我挑選幾個工具類,給大家講一下實現原理。

Partial

功能是將型別的屬性變成可選。注意這是淺 Partial,DeepPartial 上面我講過了,只要配合遞迴呼叫使用即可。

1
type Partial<T> = { [P in keyof T]?: T[P] };

Required

功能和Partial 相反,是將型別的屬性變成必填, 這裡的 -指的是去除。 -? 意思就是去除可選,也就是必填啦。

1
type Required<T> = { [P in keyof T]-?: T[P] };

Mutable

功能是將型別的屬性變成可修改,這裡的 -指的是去除。 -readonly 意思就是去除只讀,也就是可修改啦。

1
2
3
type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};

Readonly

功能和Mutable 相反,功能是將型別的屬性變成只讀, 在屬性前面增加 readonly 意思會將其變成只讀。

1
type Readonly<T> = { readonly [P in keyof T]: T[P] };

ReturnType

功能是用來得到一個函式的返回值型別。

1
2
3
4
5
type ReturnType<T extends (...args: any[]) => any> = T extends (
...args: any[]
) => infer R
? R
: any;

下面的示例用 ReturnType 獲取到 Func 的返回值型別為 string,所以,foo 也就只能被賦值為字串了。

1
2
3
type Func = (value: number) => string;

const foo: ReturnType<Func> = "1";

更多參考TS - es5.d.ts 這些泛型可以極大減少大家的冗餘程式碼,大家可以在自己的專案中自定義一些工具類泛型。

Bonus - 介面智慧提示

最後介紹一個實用的小技巧。如下是一個介面的型別定義:

1
2
3
4
5
6
7
8
9
10
11
interface Seal {
name: string;
url: string;
}
interface API {
"/user": { name: string; age: number; phone: string };
"/seals": { seal: Seal[] };
}
const api = <URL extends keyof API>(url: URL): Promise<API[URL]> => {
return fetch(url).then((res) => res.json());
};

我們通過泛型以及泛型約束,實現了智慧提示的功能。使用效果:

(介面名智慧提示)

(介面返回智慧提示)

原理很簡單,當你僅輸入 api 的時候,其會將 API interface 下的所有 key 提示給你,當你輸入某一個 key 的時候,其會根據 key 命中 interface 定義的型別,然後給予型別提示。

總結

學習 Typescript 並不是一件簡單的事情,尤其是沒有其他語言背景的情況。而 TS 中最為困難的內容之一恐怕就是泛型了。

泛型和我們平時使用的函式是很像的,如果將兩者進行橫向對比,會很容易理解,很多函式的都關係可以遷移到泛型,比如函式巢狀,遞迴,預設引數等等。泛型是對型別進行程式設計,引數是型別,返回值是一個新的型別。我們甚至可以對泛型的引數進行約束,就類似於函式的型別約束。

最後通過幾個高階的泛型用法以及若干使用的泛型工具類幫助大家理解和消化上面的知識。要知道真正的 TS 高手都是玩型別的,高手才不會滿足於型別的交叉並操作。 泛型用的好確實可以極大減少程式碼量,提高程式碼維護性。如果用的太深入,也可能會團隊成員面面相覷,一臉茫然。因此抽象層次一定要合理,不僅僅是泛型,整個軟體工程都是如此。

大家也可以關注我的公眾號《腦洞前端》獲取更多更新鮮的前端硬核文章,帶你認識你不知道的前端。