TypeScript 高級用法

本文主要介紹 TypeScript 的高級用法,適用於對 TypeScript 已經有所瞭解或者已經實際用過一段時間的同學,分別從類型、運算符、操作符、泛型的角度來系統介紹常見的 TypeScript 文章沒有好好講解的功能點,最後再分享一下自己的實踐經歷。

一、 類型

unknown

unknown 指的是不可預先定義的類型,在很多場景下,它可以替代 any 的功能同時保留靜態檢查的能力。

const num: number = 10;
(num as unknown as string).split('');   // 注意,這裏和any一樣完全可以通過靜態檢查

這個時候 unknown 的作用就跟 any 高度類似了,你可以把它轉化成任何類型,不同的地方是,在靜態編譯的時候,unknown 不能調用任何方法,而 any 可以。

const foo: unknown = 'string';
foo.substr(1);    // Error: 靜態檢查不通過報錯
const bar: any = 10;
any.substr(1);  // Pass: any類型相當於放棄了靜態檢查

unknown 的一個使用場景是,避免使用 any 作爲函數的參數類型而導致的靜態類型檢查 bug:

function test(input: unknown): number {
  if (Array.isArray(input)) {
    return input.length;    // Pass: 這個代碼塊中,類型守衛已經將input識別爲array類型
  }
  return input.length;      // Error: 這裏的input還是unknown類型,靜態檢查報錯。如果入參是any,則會放棄檢查直接成功,帶來報錯風險
}

void

在 TS 中,void 和 undefined 功能高度類似,可以在邏輯上避免不小心使用了空指針導致的錯誤。

function foo() {}   // 這個空函數沒有返回任何值,返回類型缺省爲void
const a = foo(); // 此時a的類型定義爲void,你也不能調用a的任何屬性方法

void 和 undefined 類型最大的區別是,你可以理解爲 undefined 是 void 的一個子集,當你對函數返回值並不在意時,使用 void 而不是 undefined。舉一個 React 中的實際的例子。

// Parent.tsx
function Parent(): JSX.Element {
  const getValue = ()number ={ return 2 };    /* 這裏函數返回的是number類型 */
  // const getValue = ()string ={ return 'str' }; /* 這裏函數返回的string類型,同樣可以傳給子屬性 */
  return <Child getValue={getValue} />
}
// Child.tsx
type Props = {
  getValue: () => void;  // 這裏的void表示邏輯上不關注具體的返回值類型,number、string、undefined等都可以
}
function Child({ getValue }: Props) => <div>{getValue()}</div>

never

never 是指沒法正常結束返回的類型,一個必定會報錯或者死循環的函數會返回這樣的類型。

function foo(): never { throw new Error('error message') }  // throw error 返回值是never
function foo(): never { while(true){} }  // 這個死循環的也會無法正常退出
function foo(): never { let count = 1; while(count){ count ++; } }  // Error: 這個無法將返回值定義爲never,因爲無法在靜態編譯階段直接識別出

還有就是永遠沒有相交的類型。

type human = 'boy' & 'girl' // 這兩個單獨的字符串類型並不可能相交,故human爲never類型

不過任何類型聯合上 never 類型,還是原來的類型。

type language = 'ts' | never   // language的類型還是'ts'類型

關於 never 有如下特性:

function test() {
  foo();    // 這裏的foo指上面返回never的函數
  console.log(111);  // Error: 編譯器報錯,此行代碼永遠不會執行到
}
let n: never;
let o: any = {};
n = o;  // Error: 不能把一個非never類型賦值給never類型,包括any

關於 never 的這個特性有一些很 hack 的用法和討論,比如這個知乎下的尤雨溪的回答:https://www.zhihu.com/question/354601204/answer/888551021。

二、運算符

非空斷言運算符 !

這個運算符可以用在變量名或者函數名之後,用來強調對應的元素是非 null|undefined 的。

function onClick(callback?: () => void) {
  callback!();  // 參數是可選入參,加了這個感嘆號!之後,TS編譯不報錯
}

你可以查看編譯後的 ES5 代碼,居然沒有做任何防空判斷。

function onClick(callback) {
  callback();
}

這個符號的場景,特別適用於我們已經明確知道不會返回空值的場景,從而減少冗餘的代碼判斷,如 React 的 Ref。

function Demo(): JSX.Elememt {
  const divRef = useRef<HTMLDivElement>();
  useEffect(() ={
    divRef.current!.scrollIntoView();  // 當組件Mount後纔會觸發useEffect,故current一定是有值的
  }[]);
  return <div ref={divRef}>Demo</div>
}

可選鏈運算符 ?.

相比上面! 作用於編譯階段的非空判斷,?.這個是開發者最需要的運行時 (當然編譯時也有效) 的非空判斷。

obj?.prop    obj?.[index]    func?.(args)

?. 用來判斷左側的表達式是否是 null | undefined,如果是則會停止表達式運行,可以減少我們大量的 && 運算。

比如我們寫出a?.b時,編譯器會自動生成如下代碼

a === null || a === void 0 ? void 0 : a.b;

這裏涉及到一個小知識點:undefined這個值在非嚴格模式下會被重新賦值,使用void 0必定返回真正的 undefined。

空值合併運算符 ??

?? 與 || 的功能是相似的,區別在於**?? 在左側表達式結果爲 null 或者 undefined 時,纔會返回右側表達式。**

比如我們書寫了let b = a ?? 10,生成的代碼如下:

let b = a !== null && a !== void 0 ? a : 10;

而 || 表達式,大家知道的,則對 false、''、NaN、0 等邏輯空值也會生效,不適於我們做對參數的合併。

數字分隔符_

let num:number = 1_2_345.6_78_9

_可以用來對長數字做任意的分隔,主要設計是爲了便於數字的閱讀,編譯出來的代碼是沒有下劃線的,請放心食用。

三、操作符

鍵值獲取 keyof

keyof 可以獲取一個類型所有鍵值,返回一個聯合類型,如下:

type Person = {
  name: string;
  age: number;
}
type PersonKey = keyof Person;  // PersonKey得到的類型爲 'name' | 'age'

keyof 的一個典型用途是限制訪問對象的 key 合法化,因爲 any 做索引是不被接受的。

function getValue (p: Person, k: keyof Person) {
  return p[k];  // 如果k不如此定義,則無法以p[k]的代碼格式通過編譯
}

總結起來 keyof 的語法格式如下:

類型 = keyof 類型

實例類型獲取 typeof

typeof 是獲取一個對象 / 實例的類型,如下:

const me: Person = { name: 'gzx', age: 16 };
type P = typeof me;  // { name: string, age: number | undefined }
const you: typeof me = { name: 'mabaoguo', age: 69 }  // 可以通過編譯

typeof 只能用在具體的對象上,這與 js 中的 typeof 是一致的,並且它會根據左側值自動決定應該執行哪種行爲。

const typestr = typeof me;   // typestr的值爲"object"

typeof 可以和 keyof 一起使用 (因爲 typeof 是返回一個類型嘛),如下:

type PersonKey = keyof typeof me;   // 'name' | 'age'

總結起來 typeof 的語法格式如下:

類型 = typeof 實例對象

遍歷屬性 in

in 只能用在類型的定義中,可以對枚舉類型進行遍歷,如下:

// 這個類型可以將任何類型的鍵值轉化成number類型
type TypeToNumber<T> = {
  [key in keyof T]: number
}

keyof返回泛型 T 的所有鍵枚舉類型,key是自定義的任何變量名,中間用in鏈接,外圍用[]包裹起來 (這個是固定搭配),冒號右側number將所有的key定義爲number類型。

於是可以這樣使用了:

const obj: TypeToNumber<Person> = { name: 10, age: 10 }

總結起來 in 的語法格式如下:

[ 自定義變量名 in 枚舉類型 ]: 類型

四、泛型

泛型在 TS 中可以說是一個非常重要的屬性,它承載了從靜態定義到動態調用的橋樑,同時也是 TS 對自己類型定義的元編程。泛型可以說是 TS 類型工具的精髓所在,也是整個 TS 最難學習的部分,這裏專門分兩章總結一下。

基本使用

泛型可以用在普通類型定義,類定義、函數定義上,如下:

// 普通類型定義
type Dog<T> = { name: string, type: T }
// 普通類型使用
const dog: Dog<number> = { name: 'ww', type: 20 }

// 類定義
class Cat<T> {
  private type: T;
  constructor(type: T) { this.type = type; }
}
// 類使用
const cat: Cat<number> = new Cat<number>(20); // 或簡寫 const cat = new Cat(20)

// 函數定義
function swipe<T, U>(value: [T, U])[U, T] {
  return [value[1], value[0]];
}
// 函數使用
swipe<Cat<number>, Dog<number>>([cat, dog])  // 或簡寫 swipe([cat, dog])

注意,如果對一個類型名定義了泛型,那麼使用此類型名的時候一定要把泛型類型也寫上去。

而對於變量來說,它的類型可以在調用時推斷出來的話,就可以省略泛型書寫。

泛型的語法格式簡單總結如下:

類型名<泛型列表> 具體類型定義

泛型推導與默認值

上面提到了,我們可以簡化對泛型類型定義的書寫,因爲 TS 會自動根據變量定義時的類型推導出變量類型,這一般是發生在函數調用的場合的。

type Dog<T> = { name: string, type: T }

function adopt<T>(dog: Dog<T>) { return dog };

const dog = { name: 'ww', type: 'hsq' };  // 這裏按照Dog類型的定義一個type爲string的對象
adopt(dog);  // Pass: 函數會根據入參類型推斷出type爲string

若不適用函數泛型推導,我們若需要定義變量類型則必須指定泛型類型。

const dog: Dog<string> = { name: 'ww', type: 'hsq' }  // 不可省略<string>這部分

如果我們想不指定,可以使用泛型默認值的方案。

type Dog<T = any> = { name: string, type: T }
const dog: Dog = { name: 'ww', type: 'hsq' }
dog.type = 123;    // 不過這樣type類型就是any了,無法自動推導出來,失去了泛型的意義

泛型默認值的語法格式簡單總結如下:

泛型名 = 默認類型

泛型約束

有的時候,我們可以不用關注泛型具體的類型,如:

function fill<T>(length: number, value: T): T[] {
  return new Array(length).fill(value);
}

這個函數接受一個長度參數和默認值,結果就是生成使用默認值填充好對應個數的數組。我們不用對傳入的參數做判斷,直接填充就行了,但是有時候,我們需要限定類型,這時候使用extends關鍵字即可。

function sum<T extends number>(value: T[]): number {
  let count = 0;
  value.forEach(v =count += v);
  return count;
}

這樣你就可以以sum([1,2,3])這種方式調用求和函數,而像sum(['1', '2'])這種是無法通過編譯的。

泛型約束也可以用在多個泛型參數的情況

function pick<T, U extends keyof T>(){};

這裏的意思是限制了 U 一定是 T 的 key 類型中的子集,這種用法常常出現在一些泛型工具庫中。

extends 的語法格式簡單總結如下,注意下面的類型既可以是一般意義上的類型也可以是泛型。

泛型名 extends 類型

泛型條件

上面提到 extends,其實也可以當做一個三元運算符,如下:

T extends U? X: Y

這裏便不限制 T 一定要是 U 的子類型,如果是 U 子類型,則將 T 定義爲 X 類型,否則定義爲 Y 類型。

注意,生成的結果是分配式的

舉個例子,如果我們把 X 換成 T,如此形式:T extends U? T: never

此時返回的 T,是滿足原來的 T 中包含 U 的部分,可以理解爲 T 和 U 的交集。

所以,extends 的語法格式可以擴展爲:

泛型名A extends 類型B ? 類型C: 類型D

泛型推斷 infer

infer 的中文是 “推斷” 的意思,一般是搭配上面的泛型條件語句使用的,所謂推斷,就是你不用預先指定在泛型列表中,在運行時會自動判斷,不過你得先預定義好整體的結構。舉個例子:

type Foo<T> = T extends {t: infer Test} ? Test: string

首選看 extends 後面的內容,{t: infer Test}可以看成是一個包含t屬性類型定義,這個t屬性的 value 類型通過infer進行推斷後會賦值給Test類型,如果泛型實際參數符合{t: infer Test}的定義那麼返回的就是Test類型,否則默認給缺省的string類型。舉個例子加深下理解:

type One = Foo<number>  // string,因爲number不是一個包含t的對象類型
type Two = Foo<{t: boolean}>  // boolean,因爲泛型參數匹配上了,使用了infer對應的type
type Three = Foo<{a: number, t: () => void}> // () => void,泛型定義是參數的子集,同樣適配

infer用來對滿足的泛型類型進行子類型的抽取,有很多高級的泛型工具也巧妙的使用了這個方法。

五、泛型工具

Partical

此工具的作用就是將泛型中全部屬性變爲可選的:

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

舉個例子,這個類型定義在下面也會用到:

type Animal = {
  name: string,
  category: string,
  age: number,
  eat: () => number
}

使用 Partical 包裹一下:

type PartOfAnimal = Partical<Animal>;
const ww: PartOfAnimal = { name: 'ww' }; // 屬性全部可選後,可以只賦值部分屬性了

Record<K, T>

此工具的作用是將 K 中所有屬性值轉化爲 T 類型,我們常用它來申明一個普通 object 對象。

type Record<K extends keyof any,T> = {
  [key in K]: T
}

這裏特別說明一下,keyof any對應的類型爲number | string | symbol,也就是可以做對象鍵 (專業說法叫索引 index) 的類型集合。

舉個例子:

const obj: Record<string, string> = { 'name''mbg''tag''年輕人不講武德' }

Pick<T, K>

此工具的作用是將 T 類型中的 K 鍵列表提取出來,生成新的子鍵值對類型。

type Pick<T, K extends keyof T> = {
  [P in K]: T[P]
}

我們還是用上面的Animal定義,看一下 Pick 如何使用。

const bird: Pick<Animal, "name" | "age"= { name: 'bird', age: 1 }

Exclude<T, U>

此工具是在 T 類型中,去除 T 類型和 U 類型的交集,返回剩餘的部分。

type Exclude<T, U> = T extends U ? never : T

注意這裏的 extends 返回的 T 是原來的 T 中和 U 無交集的屬性,而任何屬性聯合 never 都是自身,具體可在上文查閱。

舉個例子:

type T1 = Exclude<"a" | "b" | "c""a" | "b">;   // "c"
type T2 = Exclude<string | number | (() => void), Function>; // string | number

Omit<T, K>

此工具可認爲是適用於鍵值對對象的 Exclude,它會去除類型 T 中包含 K 的鍵值對。

type Omit = Pick<T, Exclude<keyof T, K>>

在定義中,第一步先從 T 的 key 中去掉與 K 重疊的 key,接着使用 Pick 把 T 類型和剩餘的 key 組合起來即可。

還是用上面的 Animal 舉個例子

const OmitAnimal:Omit<Animal, 'name'|'age'= { category: 'lion', eat: () ={ console.log('eat') } }

可以發現,Omit 與 Pick 得到的結果完全相反,一個是取非結果,一個取交結果。

ReturnType

此工具就是獲取 T 類型 (函數) 對應的返回值類型。

type ReturnType<T extends (...args: any) => any>
  = T extends (...args: any) => infer R ? R : any;

看源碼其實有點多,其實可以稍微簡化成下面的樣子。

type ReturnType<T extends func> = T extends () => infer R ? R: any;

通過使用 infer 推斷返回值類型,然後返回此類型,如果你徹底理解了 infer 的含義,那這段就很好理解。

舉個例子:

function foo(x: string | number): string | number { /*..*/ }
type FooType = ReturnType<foo>;  // string | number

Required

此工具可以將類型 T 中所有的屬性變爲必選項。

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

這裏有一個很有意思的語法-?,你可以理解爲就是 TS 中把? 可選屬性減去的意思。

除了這些以外,還有很多的內置的類型工具,可以參考 TypeScript Handbook[1] 獲得更詳細的信息,同時 Github 上也有很多第三方類型輔助工具,如 utility-types[2] 等。

六、項目實戰

這裏分享一些我個人的想法,可能也許會比較片面甚至錯誤,歡迎大家積極留言討論。

Q: 偏好使用 interface 還是 type 來定義類型?

A: 從用法上來說兩者本質上沒有區別,大家使用 React 項目做業務開發的話,主要就是用來定義 Props 以及接口數據類型。

但是從擴展的角度來說,type 比 interface 更方便拓展一些,假如有以下兩個定義:

type Name = { name: string };
interface IName { name: string };

想要做類型的擴展的話,type 只需要一個&,而 interface 要多寫不少代碼。

type Person = Name & { age: number };
interface IPerson extends IName { age: number };

另外 type 有一些 interface 做不到的事情,比如使用|進行枚舉類型的組合,使用typeof獲取定義的類型等等。

不過 interface 有一個比較強大的地方就是可以重複定義添加屬性,比如我們需要給window對象添加一個自定義的屬性或者方法,那麼我們直接基於其 Interface 新增屬性就可以了。

declare global {
    interface Window { MyNamespace: any; }
}

總體來說,大家知道 TS 是類型兼容而不是類型名稱匹配的,所以一般不需用面向對象的場景或者不需要修改全局類型的場合,我一般都是用 type 來定義類型。

Q: 是否允許 any 類型的出現

A: 說實話,剛開始使用 TS 的時候還是挺喜歡用 any 的,畢竟大家都是從 JS 過渡過來的,對這種影響效率的代碼開發方式並不能完全接受,因此不管是出於偷懶還是找不到合適定義的情況,使用 any 的情況都比較多。

隨着使用時間的增加和對 TS 學習理解的加深,逐步離不開了 TS 帶來的類型定義紅利,不希望代碼中出現 any,所有類型都必須要一個一個找到對應的定義,甚至已經喪失了裸寫 JS 的勇氣。

這是一個目前沒有正確答案的問題,總是要在效率和時間等等因素中找一個最適合自己的平衡。不過我還是推薦使用 TS,隨着前端工程化演進和地位的提高,強類型語言一定是多人協作和代碼健壯最可靠的保障之一,多用 TS,少用 any,也是前端界的一個普遍共識。

Q: 類型定義文件 (.d.ts) 如何放置

A: 這個好像業界也沒有特別統一的規範,我的想法如下:

如自己寫了一個組件內部的 Helper,函數的入參和出參只供內部使用也不存在複用的可能,可以直接在定義函數的時候就在後面定義。

function format(input: {k: string}[]): number[] { /***/ }

如 AntD 組件設計,每個單獨組件的 Props、State 等專門定義了類型並 export 出去。

// Table.tsx
export type TableProps = { /***/ }
export type ColumnProps = { /***/ }
export default function Table() { /***/ }

這樣使用者如果需要這些類型可以通過 import type 的方式引入來使用。

全局類型數據,這個大家毫無異議,一般根目錄下有個 typings 文件夾,裏面會存放一些全局類型定義。

假如我們使用了 css module,那麼我們需要讓 TS 識別. less 文件 (或者. scss) 引入後是一個對象,可以如此定義:

declare module '*.less' {
  const resource: { [key: string]: string };
  export = resource;
}

而對於一些全局的數據類型,如後端返回的通用的數據類型,我也習慣將其放在 typings 文件夾下,使用 Namespace 的方式來避免名字衝突,如此可以節省組件 import 類型定義的語句。

declare namespace EdgeApi {
  interface Department {
    description: string;
    gmt_create: string;
    gmt_modify: string;
    id: number;
    name: string;
  }
}

這樣,每次使用的時候,只需要const department: EdgeApi.Department即可,節省了不少導入的精力。開發者只要能約定規範,避免命名衝突即可。

關於 TS 用法的總結就結束到這裏,感謝大家的觀看~

參考資料

[1] TypeScript Handbook: 

https://www.typescriptlang.org/docs/handbook/utility-types.html

[2] utility-types: 

https://github.com/piotrwitek/utility-types

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ivv2DCPTMt0koz5_2Q2Yow