TypeScript中高級應用與最佳實踐

原文:TypeScript中高級應用與最佳實踐 | AlloyTeam
做者:TAT.haoyuehtml

TypeScript中高級應用與最佳實踐

當咱們討論TypeScript時,咱們在討論什麼?前端

TypeScript的定位

  • JavaScript的超集
  • 編譯期行爲
  • 不引入額外開銷
  • 不改變運行時行爲
  • 始終與 ESMAScript 語言標準一致 (stage 3語法)

TypeScript中的Decorator較爲特殊,爲Angular團隊和TypeScript團隊交易的結果,有興趣可自行搜索相關資料。並且近期EcmaScript規範中的decorator提案內容發生了劇烈變更,建議等此語法標準徹底穩定後再在生產項目中使用。node

本文只討論圖中藍色部分。react

類型的本質是契約

JSDoc 也能標註類型,爲何要用 TypeScript?typescript

  • JSDoc只是註釋,其標註沒有約束做用
  • TS有—checkJs選項,但很差用

TS會自動推斷函數返回值類型,爲何要畫蛇添足標註出來?編程

  • 契約高於實現
  • 檢查返回值是否寫錯
  • 寫return時得到提醒

開始以前

幾組VSCode快捷鍵

  • 代碼補全 control + 空格 ctrl + 空格
  • 快速修復 command + . ctrl + .
  • 重構(重命名)fn + f2 f2

一個網站

TypeScript Playgroundjson

初始化項目

自行配置api

"compilerOptions": {
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noImplicitThis": true,
    "moduleResolution": "node"
}

複製代碼

react項目運行create-react-app ${項目名} —scripts-version=react-scripts-ts安全

小試牛刀

& 和 | 操做符

雖然在寫法上,這兩個操做符與位運算邏輯操做符相同。但在語義上,它們與位運算恰好相反。bash

位運算的表現:

1001 | 1010 = 1011    // 合併1
1001 & 1010 = 1000    // 只保留共有1
複製代碼

在TypeScript中的表現:

interface IA {
	a: string
	b: number
}

type TB = {
	b: number
	c: number[]
}

type TC = IA | TB;    // TC類型的變量的鍵只需包含ab或bc便可,固然也能夠abc都有
type TD = IA & TB;    // TD類型的變量的鍵必需包含abc
複製代碼

對於這種表現,能夠這樣理解:&表示必須同時知足多個契約,|表示知足任意一個契約便可。

interface 和 type 關鍵字

interface 和 type 兩個關鍵字由於其功能比較接近,經常引發新手的疑問:應該在何時用type,何時用interface? interface 的特色以下:

  • 同名interface自動聚合,也能夠和已有的同名class聚合,適合作polyfill
  • 自身只能表示object/class/function的類型

建議庫的開發者所提供的公共api應該儘可能用interface/class,方便使用者自行擴展。舉個例子,我以前在給騰訊雲 Cloud Studio 在線編輯器開發插件時,由於查閱到的 monaco 文檔是0.15.5版本(當時的最新版本)的,而 Cloud Studio 使用的monaco版本爲0.14.3,缺失了一些我須要的API,因此須要手動polyfill一下。

/** * Cloud Studio使用的monaco版本較老0.14.3,和官方文檔相比缺失部分功能 * 另外vscode有一些特有的功能,必須適配 * 故在這裏手動實現做爲補充 */
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的特色以下:

  • 表達功能更強大,不侷限於object/class/function
  • 要擴展已有type須要建立新type,不能夠重名
  • 支持更復雜的類型操做
type Tuple = [number, string];
const a: Tuple = [2, 'sir'];
type Size = 'small' | 'default' | 'big' | number;
const b: Size = 24;
複製代碼

基本上全部用interface表達的類型都有其等價的type表達。但我在實踐的過程當中,也發現了一種類型只能用interface表達,沒法用type表達,那就是往函數上掛載屬性。

interface FuncWithAttachment {
    (param: string): boolean;
    someProperty: number;
}

const testFunc: FuncWithAttachment = ...;
const result = testFunc('mike');    // 有類型提醒
testFunc.someProperty = 3;    // 有類型提醒
複製代碼

extends 關鍵字

extends本意爲「拓展」,也有人稱其爲「繼承」。在TypeScript中,extends既可看成一個動詞來擴展已有類型;也可看成一個形容詞來對類型進行條件限定(例如用在泛型中)。在擴展已有類型時,不能夠進行類型衝突的覆蓋操做。例如,基類型中鍵a爲string,在擴展出的類型中沒法將其改成number。

type A = {
    a: number
}

interface AB extends A {
    b: string
}
// 與上一種等價
type TAB = A & {
    b: string
}
複製代碼

泛型

在前文咱們已經看到類型實際上能夠進行必定的運算,要想寫出的類型適用範圍更廣,不妨讓它像函數同樣能夠接受參數。TS的泛型即是起到這樣的做用,你能夠把它看成類型的參數。它和函數參數同樣,能夠有默認值。除此以外,還能夠用extends對參數自己須要知足的條件進行限制。

在定義一個函數、type、interface、class時,在名稱後面加上<>表示即接受類型參數。而在實際調用時,不必定須要手動傳入類型參數,TS每每能自行推斷出來。在TS推斷不許時,再手動傳入參數來糾正。

// 定義
class React.Component<P = {}, S = {}, SS = any> { ... }
interface IShowConfig<P extends IShowProps> { ... }
// 調用
class Modal extends React.Component<IModalProps, IModalState> { ... }
複製代碼

條件類型

除了與、或等基本邏輯,TS的類型也支持條件運算,其語法與三目運算符相同,爲T extends U ? X : Y。這裏先舉一個簡單的例子。在後文中咱們會看到不少複雜類型的實現都須要藉助條件類型。

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
複製代碼

環境 Ambient Modules

在實際應用開發時有一種場景,當前做用域下能夠訪問某個變量,但這個變量並不禁開發者控制。例如經過Script標籤直接引入的第三方庫CDN、一些宿主環境的API等。這個時候能夠利用TS的環境聲明功能,來告訴TS當前做用域能夠訪問這些變量,以得到類型提醒。

具體有兩種方式,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);
複製代碼

深刻類型系統

基本類型

基本類型,也能夠理解爲原子類型。包括number、boolean、string、null、undefined、function、array、字面量(true,false,1,2,‘a’)等。它們沒法再細分。

複合類型

TypeScript的複合類型能夠分爲兩類:setmap。set是指一個無序的、無重複元素的集合。而map則和JS中的對象同樣,是一些沒有重複鍵的鍵值對。

// set
type Size = 'small' | 'default' | 'big' | 'large';
// map
interface IA {
    a: string
    b: number
}
複製代碼

複合類型間的轉換

// 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    // 可選屬性,至關於 | undefined
}
複製代碼

映射類型和同態變換

在TypeScript中,有如下幾種常見的映射類型。它們的共同點是隻接受一個傳入類型,生成的類型中key都來自於keyof傳入的類型,value都是傳入類型的value的變種。

type Partial<T> = { [P in keyof T]?: T[P] }    // 將一個map全部屬性變爲可選的
type Required<T> = { [P in keyof T]-?: T[P] }    // 將一個map全部屬性變爲必選的
type Readonly<T> = { readonly [P in keyof T]: T[P] }    // 將一個map全部屬性變爲只讀的
type Mutable<T> = { -readonly [P in keyof T]: T[P] }    // ts標準庫未包含,將一個map全部屬性變爲可寫的
複製代碼

此類變換,在TS中被稱爲同態變換。在進行同態變換時,TS會先複製一遍傳入參數的屬性修飾符,再應用定義的變換。

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
複製代碼

獲取函數返回值的類型。但要注意不要濫用這個工具類型,應該儘可能多手動標註函數返回值類型。理由開篇時提過,契約高於實現。用ReturnType是由實現反推契約,而實現每每容易變且容易出錯,契約則相對穩定。另外一方面,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>;
複製代碼

以上這些工具類型都已經包含在了TS標準庫中,在應用中直接輸入名字進行使用便可。另外,在這些工具類型的實現中,出現了infer、never、typeof等關鍵字,在後文我會詳細解釋它們的做用。

類型的遞歸

TS原生的Readonly只會限制一層寫入操做,咱們能夠利用遞歸來實現深層次的Readonly。但要注意,TS對最大遞歸層數作了限制,最多遞歸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 關鍵字

never| 運算的幺元,即 x | never = x。例如以前的Exclude<Result, string>運算過程以下:

infer 的做用是讓TypeScript本身推斷,並將推斷的結果存儲到一個臨時名字中,而且只能用於extends語句中。它與泛型的區別在於,泛型是聲明一個「參數」,而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等。它是從 實際運行代碼 通向 類型系統 的單行道。理論上,任何運行時的符號名想要爲類型系統所用,都要加上 typeof。可是class 比較特殊不須要加,由於 ts 的 class 出現得比 js 早,現有的爲兼容性解決方案。

在使用class時,class名表示實例類型,typeof class表示 class自己類型。沒錯,這個關鍵字和 js 的 typeof 關鍵字重名了 :)。

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
複製代碼

實戰演練

我在項目中遇到這樣一種場景,須要獲取一個類型中全部value爲指定類型的key。例如,已知某個React組件的props類型,我須要「知道」(編程意義上)哪些參數是function類型。

interface SomeProps {
    a: string
    b: number
    c: (e: MouseEvent) => void
    d: (e: TouchEvent) => void
}
// 如何獲得 'c' | 'd' ? 
複製代碼

分析一下這裏的思路,咱們須要從一個map獲得一個set,而這個set是map的key的子集,篩選子集的條件是value的類型。要構造set的子集,須要用到never;要實現條件判斷,須要用到extends;而要實現key到value的訪問,則須要索引取值。通過一些嘗試後,解決方案以下。

type GetKeyByValueType<T, Condition> = {
    [K in keyof T]: T[K] extends Condition ? K : never
} [keyof T];

type FunctionPropNames =  GetKeyByValueType<SomeProps, Function>;    // 'c' | 'd'
複製代碼

這裏的運算過程以下:

// 開始
{
    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的性質
'c' | 'd'
複製代碼

編譯提示 Compiler Hints

TypeScript只發生在編譯期,所以咱們能夠在代碼中加入一些符號,來給予編譯器一些提示,使其按咱們要求的方式運行。

類型轉換

類型轉換的語法爲 <類型名> xxxxxx as 類型名。推薦始終用as語法,由於第一種語法沒法在tsx文件使用,並且容易和泛型混淆。通常只有這幾種場景須要使用類型轉換:自動推斷不許;TS報錯,想不出更好的類型編寫方法,手動抄近路;臨時「放飛自我」。

在使用類型轉換時,應該遵照幾個原則:

  • 若要放鬆限制,只可放鬆到能運行的最嚴格類型上
  • 若是不知道一個變量的精確類型,只標註到大概類型(例如any[])也比any好
  • 任何一段「放飛自我」(徹底沒有類型覆蓋)區代碼不該超過2行,應在出現第一個能夠肯定類型的變量時就補上標註

在編寫TS程序時,咱們的目標是讓類型覆蓋率無限接近 100%。

! 斷言

!的做用是斷言某個變量不會是null / undefined,告訴編譯器中止報錯。這裏由用戶確保斷言的正確。它和剛剛進入EcmaScript語法提案stage 3的Optional Chaining特性不一樣。Optional Chaining特性能夠保證訪問的安全性,即便在undefined上訪問某個鍵也不會拋出異常。而!只是消除編譯器報錯,不會對運行時行爲形成任何影響。

// TypeScript
mightBeUndefined!.a = 2
// 編譯爲
mightBeUndefined.a = 2
複製代碼

// @ts-ignore

用於忽略下一行的報錯,儘可能少用。

其餘

我爲何不提enum

enum在TS中出現的比較早,它引入了JavaScript沒有的數據結構(編譯成一個雙向map),入侵了運行時,與TypeScript宗旨不符。用 string literal union('small' | 'big' | 'large')能夠作到相同的事,且在debug時可讀性更好。若是很在乎條件比較的性能,應該用二進制flag加位運算。

// 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
複製代碼

寫在最後

應該以什麼心態來編寫TypeScript

咱們應該編寫有類型系統的JavaScript,而不是能編譯成JavaScript的Java/C#。任何一個TypeScript程序,在手動刪去類型部分,將後綴改爲 .js 後,都應可以正常運行。


AlloyTeam 歡迎優秀的小夥伴加入。
簡歷投遞: alloyteam@qq.com
詳情可點擊 騰訊AlloyTeam招募Web前端工程師(社招)

相關文章
相關標籤/搜索