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 有如下特性:
- 在一個函數中調用了返回 never 的函數後,之後的代碼都會變成
deadcode
function test() {
foo(); // 這裏的foo指上面返回never的函數
console.log(111); // Error: 編譯器報錯,此行代碼永遠不會執行到
}
- 無法把其他類型賦給 never。
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[] { /***/ }
- 組件個性化類型,直接定義在 ts(x) 文件中
如 AntD 組件設計,每個單獨組件的 Props、State 等專門定義了類型並 export 出去。
// Table.tsx
export type TableProps = { /***/ }
export type ColumnProps = { /***/ }
export default function Table() { /***/ }
這樣使用者如果需要這些類型可以通過 import type 的方式引入來使用。
- 範圍 / 全局數據,定義在. d.ts 文件中
全局類型數據,這個大家毫無異議,一般根目錄下有個 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