TypeScript 中高級應(yīng)用與完美實(shí)踐
當(dāng)我們討論 TypeScript 時,我們在討論什么?
TypeScript 的定位
- JavaScript 的超集
- 編譯期行為
- 不引入額外開銷
- 不改變運(yùn)行時行為
- 始終與 ESMAScript 語言標(biāo)準(zhǔn)一致 (stage 3 語法)
TypeScript 中的 Decorator 較為特殊,為 Angular 團(tuán)隊(duì)和 TypeScript 團(tuán)隊(duì)交易的結(jié)果,有興趣可自行搜索相關(guān)資料。而且近期 EcmaScript 規(guī)范中的 decorator 提案內(nèi)容發(fā)生了劇烈變動,建議等此語法標(biāo)準(zhǔn)完全穩(wěn)定后再在生產(chǎn)項(xiàng)目中使用。
本文只討論圖中藍(lán)色部分。
類型的本質(zhì)是契約
JSDoc 也能標(biāo)注類型,為什么要用 TypeScript?
- JSDoc 只是注釋,其標(biāo)注沒有約束作用
- TS 有—checkJs 選項(xiàng),但不好用
TS 會自動推斷函數(shù)返回值類型,為什么要多此一舉標(biāo)注出來?
- 契約高于實(shí)現(xiàn)
- 檢查返回值是否寫錯
- 寫 return 時獲得提醒
開始之前
幾組 VSCode 快捷鍵
- 代碼補(bǔ)全
control + 空格
ctrl + 空格
- 快速修復(fù)
command + .
ctrl + .
- 重構(gòu)(重命名)
fn + f2
f2
一個網(wǎng)站
TypeScript Playground
初始化項(xiàng)目
自行配置
- "compilerOptions": {
- "esModuleInterop": true,
- "allowSyntheticDefaultImports": true,
- "noImplicitAny": true,
- "strictNullChecks": true,
- "noImplicitThis": true,
- "moduleResolution": "node"
- }
react 項(xiàng)目運(yùn)行 create-react-app ${項(xiàng)目名} —scripts-version=react-scripts-ts
小試牛刀
&和 | 操作符
雖然在寫法上,這兩個操作符與位運(yùn)算邏輯操作符相同。但在語義上,它們與位運(yùn)算剛好相反。
位運(yùn)算的表現(xiàn):
- 1001 | 1010 = 1011 // 合并1
- 1001 & 1010 = 1000 // 只保留共有1
在 TypeScript 中的表現(xiàn):
- interface IA {
- a: string
- b: number
- }
- type TB = {
- b: number
- c: number[]
- }
- type TC = IA | TB; // TC類型的變量的鍵只需包含ab或bc即可,當(dāng)然也可以abc都有
- type TD = IA & TB; // TD類型的變量的鍵必需包含abc
對于這種表現(xiàn),可以這樣理解: &
表示必須同時滿足多個契約, |
表示滿足任意一個契約即可。
interface 和 type 關(guān)鍵字
interface 和 type 兩個關(guān)鍵字因?yàn)槠涔δ鼙容^接近,常常引起新手的疑問:應(yīng)該在什么時候用 type,什么時候用 interface?
interface 的特點(diǎn)如下:
- 同名 interface 自動聚合,也可以和已有的同名 class 聚合,適合做 polyfill
- 自身只能表示 object/class/function 的類型
建議庫的開發(fā)者所提供的公共 api 應(yīng)該盡量用 interface/class,方便使用者自行擴(kuò)展。舉個例子,我之前在給騰訊云 Cloud Studio 在線編輯器開發(fā)插件時,因?yàn)椴殚喌降?monaco 文檔是 0.15.5 版本(當(dāng)時的最新版本)的,而 Cloud Studio 使用的 monaco 版本為 0.14.3,缺失了一些我需要的 API,所以需要手動 polyfill 一下。
- /**
- * Cloud Studio使用的monaco版本較老0.14.3,和官方文檔相比缺失部分功能
- * 另外vscode有一些特有的功能,必須適配
- * 故在這里手動實(shí)現(xiàn)作為補(bǔ)充
- */
- declare module monaco {
- interface Position {
- delta(deltaLineNumber?: number, deltaColumn?: number): Position
- }
- }
- // monaco 0.15.5
- monaco.Position.prototype.delta = function (this: monaco.Position, deltaLineNumber = 0, deltaColumn = 0) {
- return new monaco.Position(this.lineNumber + deltaLineNumber, this.column + deltaColumn);
- }
與 interface 相比,type 的特點(diǎn)如下:
- 表達(dá)功能更強(qiáng)大,不局限于 object/class/function
- 要擴(kuò)展已有 type 需要創(chuàng)建新 type,不可以重名
- 支持更復(fù)雜的類型操作
- type Tuple = [number, string];
- const a: Tuple = [2, 'sir'];
- type Size = 'small' | 'default' | 'big' | number;
- const b: Size = 24;
基本上所有用 interface 表達(dá)的類型都有其等價的 type 表達(dá)。但我在實(shí)踐的過程中,也發(fā)現(xiàn)了一種類型只能用 interface 表達(dá),無法用 type 表達(dá),那就是往函數(shù)上掛載屬性。
- interface FuncWithAttachment {
- (param: string): boolean;
- someProperty: number;
- }
- const testFunc: FuncWithAttachment = ...;
- const result = testFunc('mike'); // 有類型提醒
- testFunc.someProperty = 3; // 有類型提醒
extends 關(guān)鍵字
extends 本意為 “拓展”,也有人稱其為 “繼承”。在 TypeScript 中,extends 既可當(dāng)作一個動詞來擴(kuò)展已有類型;也可當(dāng)作一個形容詞來對類型進(jìn)行條件限定(例如用在泛型中)。在擴(kuò)展已有類型時,不可以進(jìn)行類型沖突的覆蓋操作。例如,基類型中鍵 a 為 string,在擴(kuò)展出的類型中無法將其改為 number。
- type A = {
- a: number
- }
- interface AB extends A {
- b: string
- }
- // 與上一種等價
- type TAB = A & {
- b: string
- }
泛型
在前文我們已經(jīng)看到類型實(shí)際上可以進(jìn)行一定的運(yùn)算,要想寫出的類型適用范圍更廣,不妨讓它像函數(shù)一樣可以接受參數(shù)。TS 的泛型便是起到這樣的作用,你可以把它當(dāng)作類型的參數(shù)。它和函數(shù)參數(shù)一樣,可以有默認(rèn)值。除此之外,還可以用 extends 對參數(shù)本身需要滿足的條件進(jìn)行限制。
在定義一個函數(shù)、type、interface、class 時,在名稱后面加上<> 表示即接受類型參數(shù)。而在實(shí)際調(diào)用時,不一定需要手動傳入類型參數(shù),TS 往往能自行推斷出來。在 TS 推斷不準(zhǔn)時,再手動傳入?yún)?shù)來糾正。
- // 定義
- class React.Component<P = {}, S = {}, SS = any> { ... }
- interface IShowConfig<P extends IShowProps> { ... }
- // 調(diào)用
- class Modal extends React.Component<IModalProps, IModalState> { ... }
條件類型
除了與、或等基本邏輯,TS 的類型也支持條件運(yùn)算,其語法與三目運(yùn)算符相同,為 T extends U ? X : Y
。這里先舉一個簡單的例子。在后文中我們會看到很多復(fù)雜類型的實(shí)現(xiàn)都需要借助條件類型。
- type IsEqualType<A, B> = A extends B ? (B extends A ? true : false) : false;
- type NumberEqualsToString = IsEqualType<number, string>; // false
- type NumberEqualsToNumber = IsEqualType<number, number>; // true
環(huán)境 Ambient Modules
在實(shí)際應(yīng)用開發(fā)時有一種場景,當(dāng)前作用域下可以訪問某個變量,但這個變量并不由開發(fā)者控制。例如通過 Script 標(biāo)簽直接引入的第三方庫 CDN、一些宿主環(huán)境的 API 等。這個時候可以利用 TS 的環(huán)境聲明功能,來告訴 TS 當(dāng)前作用域可以訪問這些變量,以獲得類型提醒。
具體有兩種方式,declare 和三斜線指令。
- declare const IS_MOBILE = true; // 編譯后此行消失
- const wording = IS_MOBILE ? '移動端' : 'PC端';
用三斜線指令可以一次性引入整個類型聲明文件。
- /// <reference path="../typings/monaco.d.ts" />
- const range = new monaco.Range(2, 3, 6, 7);
深入類型系統(tǒng)
基本類型
基本類型,也可以理解為原子類型。包括 number、boolean、string、null、undefined、function、array、字面量(true,false,1,2,‘a’)等。它們無法再細(xì)分。
復(fù)合類型
TypeScript 的復(fù)合類型可以分為兩類: set 和 map 。set 是指一個無序的、無重復(fù)元素的集合。而 map 則和 JS 中的對象一樣,是一些沒有重復(fù)鍵的鍵值對。
- // set
- type Size = 'small' | 'default' | 'big' | 'large';
- // map
- interface IA {
- a: string
- b: number
- }
復(fù)合類型間的轉(zhuǎn)換
- // map => set
- type IAKeys = keyof IA; // 'a' | 'b'
- type IAValues = IA[keyof IA]; // string | number
- // set => map
- type SizeMap = {
- [k in Size]: number
- }
- // 等價于
- type SizeMap2 = {
- small: number
- default: number
- big: number
- large: number
- }
map 上的操作
- // 索引取值
- type SubA = IA['a']; // string
- // 屬性修飾符
- type Person = {
- age: number
- readonly name: string // 只讀屬性,初始化時必須賦值
- nickname?: string // 可選屬性,相當(dāng)于 | undefined
- }
映射類型和同態(tài)變換
在 TypeScript 中,有以下幾種常見的映射類型。它們的共同點(diǎn)是只接受一個傳入類型,生成的類型中 key 都來自于 keyof 傳入的類型,value 都是傳入類型的 value 的變種。
- type Partial<T> = { [P in keyof T]?: T[P] } // 將一個map所有屬性變?yōu)榭蛇x的
- type Required<T> = { [P in keyof T]-?: T[P] } // 將一個map所有屬性變?yōu)楸剡x的
- type Readonly<T> = { readonly [P in keyof T]: T[P] } // 將一個map所有屬性變?yōu)橹蛔x的
- type Mutable<T> = { -readonly [P in keyof T]: T[P] } // ts標(biāo)準(zhǔn)庫未包含,將一個map所有屬性變?yōu)榭蓪懙?/span>
此類變換,在 TS 中被稱為同態(tài)變換。在進(jìn)行同態(tài)變換時,TS 會先復(fù)制一遍傳入?yún)?shù)的屬性修飾符,再應(yīng)用定義的變換。
- interface Fruit {
- readonly name: string
- size: number
- }
- type PF = Partial<Fruit>; // PF.name既只讀又可選,PF.size只可選
其他常用工具類型
由 set 生成 map
- type Record<K extends keyof any, T> = { [P in K]: T };
- type Size = 'small' | 'default' | 'big';
- /*
- {
- small: number
- default: number
- big: number
- }
- */
- type SizeMap = Record<Size, number>;
保留 map 的一部分
- type Pick<T, K extends keyof T> = { [P in K]: T[P] };
- /*
- {
- default: number
- big: number
- }
- */
- type BiggerSizeMap = Pick<SizeMap, 'default' | 'big'>;
刪除 map 的一部分
- type Omit<T, K> = Pick<T, Exclude<keyof T, K>>;
- /*
- {
- default: number
- }
- */
- type DefaultSizeMap = Omit<BiggerSizeMap, 'big'>;
保留 set 的一部分
- type Extract<T, U> = T extends U ? T : never;
- type Result = 1 | 2 | 3 | 'error' | 'success';
- type StringResult = Extract<Result, string>; // 'error' | 'success
刪除 set 的一部分
- type Exclude<T, U> = T extends U ? never : T;
- type NumericResult = Exclude<Result, string>; // 1 | 2 | 3
獲取函數(shù)返回值的類型。但要注意不要濫用這個工具類型,應(yīng)該盡量多手動標(biāo)注函數(shù)返回值類型。理由開篇時提過, 契約高于實(shí)現(xiàn) 。用 ReturnType 是由實(shí)現(xiàn)反推契約,而實(shí)現(xiàn)往往容易變且容易出錯,契約則相對穩(wěn)定。另一方面,ReturnType 過多也會降低代碼可讀性。
- type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;
- function f() { return { a: 3, b: 2}; }
- /*
- {
- a: number
- b: number
- }
- */
- type FReturn = ReturnType<f>;
以上這些工具類型都已經(jīng)包含在了 TS 標(biāo)準(zhǔn)庫中,在應(yīng)用中直接輸入名字進(jìn)行使用即可。另外,在這些工具類型的實(shí)現(xiàn)中,出現(xiàn)了 infer、never、typeof 等關(guān)鍵字,在后文我會詳細(xì)解釋它們的作用。
類型的遞歸
TS 原生的 Readonly 只會限制一層寫入操作,我們可以利用遞歸來實(shí)現(xiàn)深層次的 Readonly。但要注意,TS 對最大遞歸層數(shù)做了限制,最多遞歸 5 層。
- type DeepReadony<T> = {
- readonly [P in keyof T]: DeepReadony<T[P]>
- }
- interface SomeObject {
- a: {
- b: {
- c: number;
- };
- };
- }
- const obj: Readonly<SomeObject> = { a: { b: { c: 2 } } };
- obj.a.b.c = 3; // TS不會報錯
- const obj2: DeepReadony<SomeObject> = { a: { b: { c: 2 } } };
- obj2.a.b.c = 3; // Cannot assign to 'c' because it is a read-only property.
never infer typeof 關(guān)鍵字
never
是 |
運(yùn)算的幺元,即 x | never = x
。例如之前的 Exclude<Result, string> 運(yùn)算過程如下:
infer
的作用是讓 TypeScript 自己推斷,并將推斷的結(jié)果存儲到一個臨時名字中,并且只能用于 extends 語句中。它與泛型的區(qū)別在于,泛型是聲明一個 “參數(shù)”,而 infer 是聲明一個 “中間變量”。infer 我用得比較少,這里借用一下官方的示例。
- type Unpacked<T> =
- T extends (infer U)[] ? U :
- T extends (...args: any[]) => infer U ? U :
- T extends Promise<infer U> ? U :
- T;
- type T0 = Unpacked<string>; // string
- type T1 = Unpacked<string[]>; // string
- type T2 = Unpacked<() => string>; // string
- type T3 = Unpacked<Promise<string>>; // string
- type T4 = Unpacked<Promise<string>[]>; // Promise<string>
- type T5 = Unpacked<Unpacked<Promise<string>[]>>; // string
typeof
用于獲取一個 “常量” 的類型,這里的 “常量” 是指任何可以在編譯期確定的東西,例如 const、function、class 等。它是從 實(shí)際運(yùn)行代碼 通向 類型系統(tǒng) 的單行道。理論上,任何運(yùn)行時的符號名想要為類型系統(tǒng)所用,都要加上 typeof。但是 class 比較特殊不需要加,因?yàn)?ts 的 class 出現(xiàn)得比 js 早,現(xiàn)有的為兼容性解決方案。
在使用 class 時, class 名
表示實(shí)例類型, typeof class
表示 class 本身類型。沒錯,這個關(guān)鍵字和 js 的 typeof 關(guān)鍵字重名了 :)。
- const config = { width: 2, height: 2 };
- function getLength(str: string) { return str.length; }
- type TConfig = typeof config; // { width: number, height: number }
- type TGetLength = typeof getLength; // (str: string) => number
實(shí)戰(zhàn)演練
我在項(xiàng)目中遇到這樣一種場景,需要獲取一個類型中所有 value 為指定類型的 key。例如,已知某個 React 組件的 props 類型,我需要 “知道”(編程意義上)哪些參數(shù)是 function 類型。
- interface SomeProps {
- a: string
- b: number
- c: (e: MouseEvent) => void
- d: (e: TouchEvent) => void
- }
- // 如何得到 'c' | 'd' ?
分析一下這里的思路,我們需要從一個 map 得到一個 set,而這個 set 是 map 的 key 的 子集,篩選子集的 條件 是 value 的類型。要構(gòu)造 set 的子集,需要用到 never
;要實(shí)現(xiàn)條件判斷,需要用到 extends
;而要實(shí)現(xiàn) key 到 value 的訪問,則需要索引取值。經(jīng)過一些嘗試后,解決方案如下。
- type GetKeyByValueType<T, Condition> = {
- [K in keyof T]: T[K] extends Condition ? K : never
- } [keyof T];
- type FunctionPropNames = GetKeyByValueType<SomeProps, Function>; // 'c' | 'd'
這里的運(yùn)算過程如下:
- // 開始
- {
- a: string
- b: number
- c: (e: MouseEvent) => void
- d: (e: TouchEvent) => void
- }
- // 第一步,條件映射
- {
- a: never
- b: never
- c: 'c'
- d: 'd'
- }
- // 第二步,索引取值
- never | never | 'c' | 'd'
- // never的性質(zhì)
- 'c' | 'd'
編譯提示 Compiler Hints
TypeScript 只發(fā)生在編譯期,因此我們可以在代碼中加入一些符號,來給予編譯器一些提示,使其按我們要求的方式運(yùn)行。
類型轉(zhuǎn)換
類型轉(zhuǎn)換的語法為 < 類型名> xxx
或 xxx as 類型名
。推薦始終用 as 語法,因?yàn)榈谝环N語法無法在 tsx 文件使用,而且容易和泛型混淆。一般只有這幾種場景需要使用類型轉(zhuǎn)換:自動推斷不準(zhǔn);TS 報錯,想不出更好的類型編寫方法,手動抄近路;臨時 “放飛自我”。
在使用類型轉(zhuǎn)換時,應(yīng)該遵守幾個原則:
- 若要放松限制,只可放松到能運(yùn)行的最嚴(yán)格類型上
- 如果不知道一個變量的精確類型,只標(biāo)注到大概類型(例如 any[])也比 any 好
- 任何一段 “放飛自我”(完全沒有類型覆蓋)區(qū)代碼不應(yīng)超過 2 行,應(yīng)在出現(xiàn)第一個可以確定類型的變量時就補(bǔ)上標(biāo)注
在編寫 TS 程序時,我們的目標(biāo)是讓類型覆蓋率無限接近 100%。
! 斷言
!
的作用是斷言某個變量不會是 null / undefined,告訴編譯器停止報錯。這里由用戶確保斷言的正確。它和剛剛進(jìn)入 EcmaScript 語法提案 stage 3 的 Optional Chaining 特性不同。Optional Chaining 特性可以保證訪問的安全性,即使在 undefined 上訪問某個鍵也不會拋出異常。而 !
只是消除編譯器報錯,不會對運(yùn)行時行為造成任何影響。
- // TypeScript
- mightBeUndefined!.a = 2
- // 編譯為
- mightBeUndefined.a = 2
// @ts-ignore
用于忽略下一行的報錯,盡量少用。
其他
我為什么不提 enum
enum 在 TS 中出現(xiàn)的比較早,它引入了 JavaScript 沒有的數(shù)據(jù)結(jié)構(gòu)(編譯成一個雙向 map),入侵了運(yùn)行時,與 TypeScript 宗旨不符。用 string literal union('small' | 'big' | 'large')可以做到相同的事,且在 debug 時可讀性更好。如果很在意條件比較的性能,應(yīng)該用二進(jìn)制 flag 加位運(yùn)算。
- // TypeScript
- enum Size {
- small = 3,
- big,
- large
- }
- const a:Size = Size.large; // 5
- // 編譯為
- var Size;
- (function (Size) {
- Size[Size["small"] = 3] = "small";
- Size[Size["big"] = 4] = "big";
- Size[Size["large"] = 5] = "large";
- })(Size || (Size = {}));
- const a = Size.large; // 5
寫在最后
應(yīng)該以什么心態(tài)來編寫 TypeScript
我們應(yīng)該編寫有類型系統(tǒng)的 JavaScript,而不是能編譯成 JavaScript 的 Java/C#。任何一個 TypeScript 程序,在手動刪去類型部分,將后綴改成 .js 后,都應(yīng)能夠正常運(yùn)行。