「1.8W字」一份不可多得的 TS 學習指南

阿寶哥發表於2020-09-15

阿寶哥第一次使用 TypeScript 是在 Angular 2.x 專案中,那時候 TypeScript 還沒有進入大眾的視野。然而現在學習 TypeScript 的小夥伴越來越多了,本文阿寶哥將從 16 個方面入手,帶你一步步學習 TypeScript,感興趣的小夥伴不要錯過。

image

一、TypeScript 是什麼

TypeScript 是一種由微軟開發的自由和開源的程式語言。它是 JavaScript 的一個超集,而且本質上向這個語言新增了可選的靜態型別和基於類的物件導向程式設計。

TypeScript 提供最新的和不斷髮展的 JavaScript 特性,包括那些來自 2015 年的 ECMAScript 和未來的提案中的特性,比如非同步功能和 Decorators,以幫助建立健壯的元件。下圖顯示了 TypeScript 與 ES5、ES2015 和 ES2016 之間的關係:

1.1 TypeScript 與 JavaScript 的區別

TypeScriptJavaScript
JavaScript 的超集用於解決大型專案的程式碼複雜性一種指令碼語言,用於建立動態網頁
可以在編譯期間發現並糾正錯誤作為一種解釋型語言,只能在執行時發現錯誤
強型別,支援靜態和動態型別弱型別,沒有靜態型別選項
最終被編譯成 JavaScript 程式碼,使瀏覽器可以理解可以直接在瀏覽器中使用
支援模組、泛型和介面不支援模組,泛型或介面
社群的支援仍在增長,而且還不是很大大量的社群支援以及大量文件和解決問題的支援

1.2 獲取 TypeScript

命令列的 TypeScript 編譯器可以使用 npm 包管理器來安裝。

1.安裝 TypeScript
$ npm install -g typescript
2.驗證 TypeScript
$ tsc -v 
# Version 4.0.2
3.編譯 TypeScript 檔案
$ tsc helloworld.ts
# helloworld.ts => helloworld.js

當然,對剛入門 TypeScript 的小夥伴來說,也可以不用安裝 typescript,而是直接使用線上的 TypeScript Playground 來學習新的語法或新特性。通過配置 TS Config 的 Target,可以設定不同的編譯目標,從而編譯生成不同的目的碼。

下圖示例中所設定的編譯目標是 ES5:

(圖片來源:https://www.typescriptlang.or...

1.3 典型 TypeScript 工作流程

如你所見,在上圖中包含 3 個 ts 檔案:a.ts、b.ts 和 c.ts。這些檔案將被 TypeScript 編譯器,根據配置的編譯選項編譯成 3 個 js 檔案,即 a.js、b.js 和 c.js。對於大多數使用 TypeScript 開發的 Web 專案,我們還會對編譯生成的 js 檔案進行打包處理,然後在進行部署。

1.4 TypeScript 初體驗

新建一個 hello.ts 檔案,並輸入以下內容:

function greet(person: string) {
  return 'Hello, ' + person;
}

console.log(greet("TypeScript"));

然後執行 tsc hello.ts 命令,之後會生成一個編譯好的檔案 hello.js

"use strict";
function greet(person) {
  return 'Hello, ' + person;
}
console.log(greet("TypeScript"));

觀察以上編譯後的輸出結果,我們發現 person 引數的型別資訊在編譯後被擦除了。TypeScript 只會在編譯階段對型別進行靜態檢查,如果發現有錯誤,編譯時就會報錯。而在執行時,編譯生成的 JS 與普通的 JavaScript 檔案一樣,並不會進行型別檢查。

二、TypeScript 基礎型別

2.1 Boolean 型別

let isDone: boolean = false;
// ES5:var isDone = false;

2.2 Number 型別

let count: number = 10;
// ES5:var count = 10;

2.3 String 型別

let name: string = "semliker";
// ES5:var name = 'semlinker';

2.4 Symbol 型別

const sym = Symbol();
let obj = {
  [sym]: "semlinker",
};

console.log(obj[sym]); // semlinker 

2.5 Array 型別

let list: number[] = [1, 2, 3];
// ES5:var list = [1,2,3];

let list: Array<number> = [1, 2, 3]; // Array<number>泛型語法
// ES5:var list = [1,2,3];

2.6 Enum 型別

使用列舉我們可以定義一些帶名字的常量。 使用列舉可以清晰地表達意圖或建立一組有區別的用例。 TypeScript 支援數字的和基於字串的列舉。

1.數字列舉
enum Direction {
  NORTH,
  SOUTH,
  EAST,
  WEST,
}

let dir: Direction = Direction.NORTH;

預設情況下,NORTH 的初始值為 0,其餘的成員會從 1 開始自動增長。換句話說,Direction.SOUTH 的值為 1,Direction.EAST 的值為 2,Direction.WEST 的值為 3。

以上的列舉示例經編譯後,對應的 ES5 程式碼如下:

"use strict";
var Direction;
(function (Direction) {
  Direction[(Direction["NORTH"] = 0)] = "NORTH";
  Direction[(Direction["SOUTH"] = 1)] = "SOUTH";
  Direction[(Direction["EAST"] = 2)] = "EAST";
  Direction[(Direction["WEST"] = 3)] = "WEST";
})(Direction || (Direction = {}));
var dir = Direction.NORTH;

當然我們也可以設定 NORTH 的初始值,比如:

enum Direction {
  NORTH = 3,
  SOUTH,
  EAST,
  WEST,
}
2.字串列舉

在 TypeScript 2.4 版本,允許我們使用字串列舉。在一個字串列舉裡,每個成員都必須用字串字面量,或另外一個字串列舉成員進行初始化。

enum Direction {
  NORTH = "NORTH",
  SOUTH = "SOUTH",
  EAST = "EAST",
  WEST = "WEST",
}

以上程式碼對應的 ES5 程式碼如下:

"use strict";
var Direction;
(function (Direction) {
    Direction["NORTH"] = "NORTH";
    Direction["SOUTH"] = "SOUTH";
    Direction["EAST"] = "EAST";
    Direction["WEST"] = "WEST";
})(Direction || (Direction = {}));

通過觀察數字列舉和字串列舉的編譯結果,我們可以知道數字列舉除了支援 從成員名稱到成員值 的普通對映之外,它還支援 從成員值到成員名稱 的反向對映:

enum Direction {
  NORTH,
  SOUTH,
  EAST,
  WEST,
}

let dirName = Direction[0]; // NORTH
let dirVal = Direction["NORTH"]; // 0

另外,對於純字串列舉,我們不能省略任何初始化程式。而數字列舉如果沒有顯式設定值時,則會使用預設規則進行初始化。

3.常量列舉

除了數字列舉和字串列舉之外,還有一種特殊的列舉 —— 常量列舉。它是使用 const 關鍵字修飾的列舉,常量列舉會使用內聯語法,不會為列舉型別編譯生成任何 JavaScript。為了更好地理解這句話,我們來看一個具體的例子:

const enum Direction {
  NORTH,
  SOUTH,
  EAST,
  WEST,
}

let dir: Direction = Direction.NORTH;

以上程式碼對應的 ES5 程式碼如下:

"use strict";
var dir = 0 /* NORTH */;
4.異構列舉

異構列舉的成員值是數字和字串的混合:

enum Enum {
  A,
  B,
  C = "C",
  D = "D",
  E = 8,
  F,
}

以上程式碼對於的 ES5 程式碼如下:

"use strict";
var Enum;
(function (Enum) {
    Enum[Enum["A"] = 0] = "A";
    Enum[Enum["B"] = 1] = "B";
    Enum["C"] = "C";
    Enum["D"] = "D";
    Enum[Enum["E"] = 8] = "E";
    Enum[Enum["F"] = 9] = "F";
})(Enum || (Enum = {}));

通過觀察上述生成的 ES5 程式碼,我們可以發現數字列舉相對字串列舉多了 “反向對映”:

console.log(Enum.A) //輸出:0
console.log(Enum[0]) // 輸出:A

2.7 Any 型別

在 TypeScript 中,任何型別都可以被歸為 any 型別。這讓 any 型別成為了型別系統的頂級型別(也被稱作全域性超級型別)。

let notSure: any = 666;
notSure = "semlinker";
notSure = false;

any 型別本質上是型別系統的一個逃逸艙。作為開發者,這給了我們很大的自由:TypeScript 允許我們對 any 型別的值執行任何操作,而無需事先執行任何形式的檢查。比如:

let value: any;

value.foo.bar; // OK
value.trim(); // OK
value(); // OK
new value(); // OK
value[0][1]; // OK

在許多場景下,這太寬鬆了。使用 any 型別,可以很容易地編寫型別正確但在執行時有問題的程式碼。如果我們使用 any 型別,就無法使用 TypeScript 提供的大量的保護機制。為了解決 any 帶來的問題,TypeScript 3.0 引入了 unknown 型別。

2.8 Unknown 型別

就像所有型別都可以賦值給 any,所有型別也都可以賦值給 unknown。這使得 unknown 成為 TypeScript 型別系統的另一種頂級型別(另一種是 any)。下面我們來看一下 unknown 型別的使用示例:

let value: unknown;

value = true; // OK
value = 42; // OK
value = "Hello World"; // OK
value = []; // OK
value = {}; // OK
value = Math.random; // OK
value = null; // OK
value = undefined; // OK
value = new TypeError(); // OK
value = Symbol("type"); // OK

value 變數的所有賦值都被認為是型別正確的。但是,當我們嘗試將型別為 unknown 的值賦值給其他型別的變數時會發生什麼?

let value: unknown;

let value1: unknown = value; // OK
let value2: any = value; // OK
let value3: boolean = value; // Error
let value4: number = value; // Error
let value5: string = value; // Error
let value6: object = value; // Error
let value7: any[] = value; // Error
let value8: Function = value; // Error

unknown 型別只能被賦值給 any 型別和 unknown 型別本身。直觀地說,這是有道理的:只有能夠儲存任意型別值的容器才能儲存 unknown 型別的值。畢竟我們不知道變數 value 中儲存了什麼型別的值。

現在讓我們看看當我們嘗試對型別為 unknown 的值執行操作時會發生什麼。以下是我們在之前 any 章節看過的相同操作:

let value: unknown;

value.foo.bar; // Error
value.trim(); // Error
value(); // Error
new value(); // Error
value[0][1]; // Error

value 變數型別設定為 unknown 後,這些操作都不再被認為是型別正確的。通過將 any 型別改變為 unknown 型別,我們已將允許所有更改的預設設定,更改為禁止任何更改。

2.9 Tuple 型別

眾所周知,陣列一般由同種型別的值組成,但有時我們需要在單個變數中儲存不同型別的值,這時候我們就可以使用元組。在 JavaScript 中是沒有元組的,元組是 TypeScript 中特有的型別,其工作方式類似於陣列。

元組可用於定義具有有限數量的未命名屬性的型別。每個屬性都有一個關聯的型別。使用元組時,必須提供每個屬性的值。為了更直觀地理解元組的概念,我們來看一個具體的例子:

let tupleType: [string, boolean];
tupleType = ["semlinker", true];

在上面程式碼中,我們定義了一個名為 tupleType 的變數,它的型別是一個型別陣列 [string, boolean],然後我們按照正確的型別依次初始化 tupleType 變數。與陣列一樣,我們可以通過下標來訪問元組中的元素:

console.log(tupleType[0]); // semlinker
console.log(tupleType[1]); // true

在元組初始化的時候,如果出現型別不匹配的話,比如:

tupleType = [true, "semlinker"];

此時,TypeScript 編譯器會提示以下錯誤資訊:

[0]: Type 'true' is not assignable to type 'string'.
[1]: Type 'string' is not assignable to type 'boolean'.

很明顯是因為型別不匹配導致的。在元組初始化的時候,我們還必須提供每個屬性的值,不然也會出現錯誤,比如:

tupleType = ["semlinker"];

此時,TypeScript 編譯器會提示以下錯誤資訊:

Property '1' is missing in type '[string]' but required in type '[string, boolean]'.

2.10 Void 型別

某種程度上來說,void 型別像是與 any 型別相反,它表示沒有任何型別。當一個函式沒有返回值時,你通常會見到其返回值型別是 void:

// 宣告函式返回值為void
function warnUser(): void {
  console.log("This is my warning message");
}

以上程式碼編譯生成的 ES5 程式碼如下:

"use strict";
function warnUser() {
  console.log("This is my warning message");
}

需要注意的是,宣告一個 void 型別的變數沒有什麼作用,因為它的值只能為 undefinednull

let unusable: void = undefined;

2.11 Null 和 Undefined 型別

TypeScript 裡,undefinednull 兩者有各自的型別分別為 undefinednull

let u: undefined = undefined;
let n: null = null;

預設情況下 nullundefined 是所有型別的子型別。 就是說你可以把 nullundefined 賦值給 number 型別的變數。然而,如果你指定了--strictNullChecks 標記,nullundefined 只能賦值給 void 和它們各自的型別。

2.12 object, Object 和 {} 型別

1.object 型別

object 型別是:TypeScript 2.2 引入的新型別,它用於表示非原始型別。

// node_modules/typescript/lib/lib.es5.d.ts
interface ObjectConstructor {
  create(o: object | null): any;
  // ...
}

const proto = {};

Object.create(proto);     // OK
Object.create(null);      // OK
Object.create(undefined); // Error
Object.create(1337);      // Error
Object.create(true);      // Error
Object.create("oops");    // Error
2.Object 型別

Object 型別:它是所有 Object 類的例項的型別,它由以下兩個介面來定義:

  • Object 介面定義了 Object.prototype 原型物件上的屬性;
// node_modules/typescript/lib/lib.es5.d.ts
interface Object {
  constructor: Function;
  toString(): string;
  toLocaleString(): string;
  valueOf(): Object;
  hasOwnProperty(v: PropertyKey): boolean;
  isPrototypeOf(v: Object): boolean;
  propertyIsEnumerable(v: PropertyKey): boolean;
}
  • ObjectConstructor 介面定義了 Object 類的屬性。
// node_modules/typescript/lib/lib.es5.d.ts
interface ObjectConstructor {
  /** Invocation via `new` */
  new(value?: any): Object;
  /** Invocation via function calls */
  (value?: any): any;
  readonly prototype: Object;
  getPrototypeOf(o: any): any;
  // ···
}

declare var Object: ObjectConstructor;

Object 類的所有例項都繼承了 Object 介面中的所有屬性。

3.{} 型別

{} 型別描述了一個沒有成員的物件。當你試圖訪問這樣一個物件的任意屬性時,TypeScript 會產生一個編譯時錯誤。

// Type {}
const obj = {};

// Error: Property 'prop' does not exist on type '{}'.
obj.prop = "semlinker";

但是,你仍然可以使用在 Object 型別上定義的所有屬性和方法,這些屬性和方法可通過 JavaScript 的原型鏈隱式地使用:

// Type {}
const obj = {};

// "[object Object]"
obj.toString();

2.13 Never 型別

never 型別表示的是那些永不存在的值的型別。 例如,never 型別是那些總是會丟擲異常或根本就不會有返回值的函式表示式或箭頭函式表示式的返回值型別。

// 返回never的函式必須存在無法達到的終點
function error(message: string): never {
  throw new Error(message);
}

function infiniteLoop(): never {
  while (true) {}
}

在 TypeScript 中,可以利用 never 型別的特性來實現全面性檢查,具體示例如下:

type Foo = string | number;

function controlFlowAnalysisWithNever(foo: Foo) {
  if (typeof foo === "string") {
    // 這裡 foo 被收窄為 string 型別
  } else if (typeof foo === "number") {
    // 這裡 foo 被收窄為 number 型別
  } else {
    // foo 在這裡是 never
    const check: never = foo;
  }
}

注意在 else 分支裡面,我們把收窄為 never 的 foo 賦值給一個顯示宣告的 never 變數。如果一切邏輯正確,那麼這裡應該能夠編譯通過。但是假如後來有一天你的同事修改了 Foo 的型別:

type Foo = string | number | boolean;

然而他忘記同時修改 controlFlowAnalysisWithNever 方法中的控制流程,這時候 else 分支的 foo 型別會被收窄為 boolean 型別,導致無法賦值給 never 型別,這時就會產生一個編譯錯誤。通過這個方式,我們可以確保

controlFlowAnalysisWithNever 方法總是窮盡了 Foo 的所有可能型別。 通過這個示例,我們可以得出一個結論:使用 never 避免出現新增了聯合型別沒有對應的實現,目的就是寫出型別絕對安全的程式碼。

三、TypeScript 斷言

3.1 型別斷言

有時候你會遇到這樣的情況,你會比 TypeScript 更瞭解某個值的詳細資訊。通常這會發生在你清楚地知道一個實體具有比它現有型別更確切的型別。

通過型別斷言這種方式可以告訴編譯器,“相信我,我知道自己在幹什麼”。型別斷言好比其他語言裡的型別轉換,但是不進行特殊的資料檢查和解構。它沒有執行時的影響,只是在編譯階段起作用。

型別斷言有兩種形式:

1.“尖括號” 語法
let someValue: any = "this is a string";
let strLength: number = (<string>someValue).length;
2.as 語法
let someValue: any = "this is a string";
let strLength: number = (someValue as string).length;

3.2 非空斷言

在上下文中當型別檢查器無法斷定型別時,一個新的字尾表示式操作符 ! 可以用於斷言操作物件是非 null 和非 undefined 型別。具體而言,x! 將從 x 值域中排除 null 和 undefined 。

那麼非空斷言操作符到底有什麼用呢?下面我們先來看一下非空斷言操作符的一些使用場景。

1.忽略 undefined 和 null 型別
function myFunc(maybeString: string | undefined | null) {
  // Type 'string | null | undefined' is not assignable to type 'string'.
  // Type 'undefined' is not assignable to type 'string'. 
  const onlyString: string = maybeString; // Error
  const ignoreUndefinedAndNull: string = maybeString!; // Ok
}
2.呼叫函式時忽略 undefined 型別
type NumGenerator = () => number;

function myFunc(numGenerator: NumGenerator | undefined) {
  // Object is possibly 'undefined'.(2532)
  // Cannot invoke an object which is possibly 'undefined'.(2722)
  const num1 = numGenerator(); // Error
  const num2 = numGenerator!(); //OK
}

因為 ! 非空斷言操作符會從編譯生成的 JavaScript 程式碼中移除,所以在實際使用的過程中,要特別注意。比如下面這個例子:

const a: number | undefined = undefined;
const b: number = a!;
console.log(b); 

以上 TS 程式碼會編譯生成以下 ES5 程式碼:

"use strict";
const a = undefined;
const b = a;
console.log(b);

雖然在 TS 程式碼中,我們使用了非空斷言,使得 const b: number = a!; 語句可以通過 TypeScript 型別檢查器的檢查。但在生成的 ES5 程式碼中,! 非空斷言操作符被移除了,所以在瀏覽器中執行以上程式碼,在控制檯會輸出 undefined

3.3 確定賦值斷言

在 TypeScript 2.7 版本中引入了確定賦值斷言,即允許在例項屬性和變數宣告後面放置一個 ! 號,從而告訴 TypeScript 該屬性會被明確地賦值。為了更好地理解它的作用,我們來看個具體的例子:

let x: number;
initialize();
// Variable 'x' is used before being assigned.(2454)
console.log(2 * x); // Error

function initialize() {
  x = 10;
}

很明顯該異常資訊是說變數 x 在賦值前被使用了,要解決該問題,我們可以使用確定賦值斷言:

let x!: number;
initialize();
console.log(2 * x); // Ok

function initialize() {
  x = 10;
}

通過 let x!: number; 確定賦值斷言,TypeScript 編譯器就會知道該屬性會被明確地賦值。

四、型別守衛

型別保護是可執行執行時檢查的一種表示式,用於確保該型別在一定的範圍內。 換句話說,型別保護可以保證一個字串是一個字串,儘管它的值也可以是一個數值。型別保護與特性檢測並不是完全不同,其主要思想是嘗試檢測屬性、方法或原型,以確定如何處理值。目前主要有四種的方式來實現型別保護:

4.1 in 關鍵字

interface Admin {
  name: string;
  privileges: string[];
}

interface Employee {
  name: string;
  startDate: Date;
}

type UnknownEmployee = Employee | Admin;

function printEmployeeInformation(emp: UnknownEmployee) {
  console.log("Name: " + emp.name);
  if ("privileges" in emp) {
    console.log("Privileges: " + emp.privileges);
  }
  if ("startDate" in emp) {
    console.log("Start Date: " + emp.startDate);
  }
}

4.2 typeof 關鍵字

function padLeft(value: string, padding: string | number) {
  if (typeof padding === "number") {
      return Array(padding + 1).join(" ") + value;
  }
  if (typeof padding === "string") {
      return padding + value;
  }
  throw new Error(`Expected string or number, got '${padding}'.`);
}

typeof 型別保護只支援兩種形式:typeof v === "typename"typeof v !== typename"typename" 必須是 "number""string""boolean""symbol"。 但是 TypeScript 並不會阻止你與其它字串比較,語言不會把那些表示式識別為型別保護。

4.3 instanceof 關鍵字

interface Padder {
  getPaddingString(): string;
}

class SpaceRepeatingPadder implements Padder {
  constructor(private numSpaces: number) {}
  getPaddingString() {
    return Array(this.numSpaces + 1).join(" ");
  }
}

class StringPadder implements Padder {
  constructor(private value: string) {}
  getPaddingString() {
    return this.value;
  }
}

let padder: Padder = new SpaceRepeatingPadder(6);

if (padder instanceof SpaceRepeatingPadder) {
  // padder的型別收窄為 'SpaceRepeatingPadder'
}

4.4 自定義型別保護的型別謂詞

function isNumber(x: any): x is number {
  return typeof x === "number";
}

function isString(x: any): x is string {
  return typeof x === "string";
}

五、聯合型別和型別別名

5.1 聯合型別

聯合型別通常與 nullundefined 一起使用:

const sayHello = (name: string | undefined) => {
  /* ... */
};

例如,這裡 name 的型別是 string | undefined 意味著可以將 stringundefined 的值傳遞給sayHello 函式。

sayHello("semlinker");
sayHello(undefined);

通過這個示例,你可以憑直覺知道型別 A 和型別 B 聯合後的型別是同時接受 A 和 B 值的型別。此外,對於聯合型別來說,你可能會遇到以下的用法:

let num: 1 | 2 = 1;
type EventNames = 'click' | 'scroll' | 'mousemove';

以上示例中的 12'click' 被稱為字面量型別,用來約束取值只能是某幾個值中的一個。

5.2 可辨識聯合

TypeScript 可辨識聯合(Discriminated Unions)型別,也稱為代數資料型別或標籤聯合型別。它包含 3 個要點:可辨識、聯合型別和型別守衛。

這種型別的本質是結合聯合型別和字面量型別的一種型別保護方法。如果一個型別是多個型別的聯合型別,且多個型別含有一個公共屬性,那麼就可以利用這個公共屬性,來建立不同的型別保護區塊。

1.可辨識

可辨識要求聯合型別中的每個元素都含有一個單例型別屬性,比如:

enum CarTransmission {
  Automatic = 200,
  Manual = 300
}

interface Motorcycle {
  vType: "motorcycle"; // discriminant
  make: number; // year
}

interface Car {
  vType: "car"; // discriminant
  transmission: CarTransmission
}

interface Truck {
  vType: "truck"; // discriminant
  capacity: number; // in tons
}

在上述程式碼中,我們分別定義了 MotorcycleCarTruck 三個介面,在這些介面中都包含一個 vType 屬性,該屬性被稱為可辨識的屬性,而其它的屬性只跟特性的介面相關。

2.聯合型別

基於前面定義了三個介面,我們可以建立一個 Vehicle 聯合型別:

type Vehicle = Motorcycle | Car | Truck;

現在我們就可以開始使用 Vehicle 聯合型別,對於 Vehicle 型別的變數,它可以表示不同型別的車輛。

3.型別守衛

下面我們來定義一個 evaluatePrice 方法,該方法用於根據車輛的型別、容量和評估因子來計算價格,具體實現如下:

const EVALUATION_FACTOR = Math.PI; 

function evaluatePrice(vehicle: Vehicle) {
  return vehicle.capacity * EVALUATION_FACTOR;
}

const myTruck: Truck = { vType: "truck", capacity: 9.5 };
evaluatePrice(myTruck);

對於以上程式碼,TypeScript 編譯器將會提示以下錯誤資訊:

Property 'capacity' does not exist on type 'Vehicle'.
Property 'capacity' does not exist on type 'Motorcycle'.

原因是在 Motorcycle 介面中,並不存在 capacity 屬性,而對於 Car 介面來說,它也不存在 capacity 屬性。那麼,現在我們應該如何解決以上問題呢?這時,我們可以使用型別守衛。下面我們來重構一下前面定義的 evaluatePrice 方法,重構後的程式碼如下:

function evaluatePrice(vehicle: Vehicle) {
  switch(vehicle.vType) {
    case "car":
      return vehicle.transmission * EVALUATION_FACTOR;
    case "truck":
      return vehicle.capacity * EVALUATION_FACTOR;
    case "motorcycle":
      return vehicle.make * EVALUATION_FACTOR;
  }
}

在以上程式碼中,我們使用 switchcase 運算子來實現型別守衛,從而確保在 evaluatePrice 方法中,我們可以安全地訪問 vehicle 物件中的所包含的屬性,來正確的計算該車輛型別所對應的價格。

5.3 型別別名

型別別名用來給一個型別起個新名字。

type Message = string | string[];

let greet = (message: Message) => {
  // ...
};

六、交叉型別

在 TypeScript 中交叉型別是將多個型別合併為一個型別。通過 & 運算子可以將現有的多種型別疊加到一起成為一種型別,它包含了所需的所有型別的特性。

type PartialPointX = { x: number; };
type Point = PartialPointX & { y: number; };

let point: Point = {
  x: 1,
  y: 1
}

在上面程式碼中我們先定義了 PartialPointX 型別,接著使用 & 運算子建立一個新的 Point 型別,表示一個含有 x 和 y 座標的點,然後定義了一個 Point 型別的變數並初始化。

6.1 同名基礎型別屬性的合併

那麼現在問題來了,假設在合併多個型別的過程中,剛好出現某些型別存在相同的成員,但對應的型別又不一致,比如:

interface X {
  c: string;
  d: string;
}

interface Y {
  c: number;
  e: string
}

type XY = X & Y;
type YX = Y & X;

let p: XY;
let q: YX;

在上面的程式碼中,介面 X 和介面 Y 都含有一個相同的成員 c,但它們的型別不一致。對於這種情況,此時 XY 型別或 YX 型別中成員 c 的型別是不是可以是 stringnumber 型別呢?比如下面的例子:

p = { c: 6, d: "d", e: "e" }; 

q = { c: "c", d: "d", e: "e" }; 

為什麼介面 X 和介面 Y 混入後,成員 c 的型別會變成 never 呢?這是因為混入後成員 c 的型別為 string & number,即成員 c 的型別既可以是 string 型別又可以是 number 型別。很明顯這種型別是不存在的,所以混入後成員 c 的型別為 never

6.2 同名非基礎型別屬性的合併

在上面示例中,剛好介面 X 和介面 Y 中內部成員 c 的型別都是基本資料型別,那麼如果是非基本資料型別的話,又會是什麼情形。我們來看個具體的例子:

interface D { d: boolean; }
interface E { e: string; }
interface F { f: number; }

interface A { x: D; }
interface B { x: E; }
interface C { x: F; }

type ABC = A & B & C;

let abc: ABC = {
  x: {
    d: true,
    e: 'semlinker',
    f: 666
  }
};

console.log('abc:', abc);

以上程式碼成功執行後,控制檯會輸出以下結果:

由上圖可知,在混入多個型別時,若存在相同的成員,且成員型別為非基本資料型別,那麼是可以成功合併。

七、TypeScript 函式

7.1 TypeScript 函式與 JavaScript 函式的區別

TypeScriptJavaScript
含有型別無型別
箭頭函式箭頭函式(ES2015)
函式型別無函式型別
必填和可選引數所有引數都是可選的
預設引數預設引數
剩餘引數剩餘引數
函式過載無函式過載

7.2 箭頭函式

1.常見語法
myBooks.forEach(() => console.log('reading'));

myBooks.forEach(title => console.log(title));

myBooks.forEach((title, idx, arr) =>
  console.log(idx + '-' + title);
);

myBooks.forEach((title, idx, arr) => {
  console.log(idx + '-' + title);
});
2.使用示例
// 未使用箭頭函式
function Book() {
  let self = this;
  self.publishDate = 2016;
  setInterval(function () {
    console.log(self.publishDate);
  }, 1000);
}

// 使用箭頭函式
function Book() {
  this.publishDate = 2016;
  setInterval(() => {
    console.log(this.publishDate);
  }, 1000);
}

7.3 引數型別和返回型別

function createUserId(name: string, id: number): string {
  return name + id;
}

7.4 函式型別

let IdGenerator: (chars: string, nums: number) => string;

function createUserId(name: string, id: number): string {
  return name + id;
}

IdGenerator = createUserId;

7.5 可選引數及預設引數

// 可選引數
function createUserId(name: string, id: number, age?: number): string {
  return name + id;
}

// 預設引數
function createUserId(
  name: string = "semlinker",
  id: number,
  age?: number
): string {
  return name + id;
}

在宣告函式時,可以通過 ? 號來定義可選引數,比如 age?: number 這種形式。在實際使用時,需要注意的是可選引數要放在普通引數的後面,不然會導致編譯錯誤

7.6 剩餘引數

function push(array, ...items) {
  items.forEach(function (item) {
    array.push(item);
  });
}

let a = [];
push(a, 1, 2, 3);

7.7 函式過載

函式過載或方法過載是使用相同名稱和不同引數數量或型別建立多個方法的一種能力。

function add(a: number, b: number): number;
function add(a: string, b: string): string;
function add(a: string, b: number): string;
function add(a: number, b: string): string;
function add(a: Combinable, b: Combinable) {
  // type Combinable = string | number;
  if (typeof a === 'string' || typeof b === 'string') {
    return a.toString() + b.toString();
  }
  return a + b;
}

在以上程式碼中,我們為 add 函式提供了多個函式型別定義,從而實現函式的過載。在 TypeScript 中除了可以過載普通函式之外,我們還可以過載類中的成員方法。

方法過載是指在同一個類中方法同名,引數不同(引數型別不同、引數個數不同或引數個數相同時引數的先後順序不同),呼叫時根據實參的形式,選擇與它匹配的方法執行操作的一種技術。所以類中成員方法滿足過載的條件是:在同一個類中,方法名相同且引數列表不同。下面我們來舉一個成員方法過載的例子:

class Calculator {
  add(a: number, b: number): number;
  add(a: string, b: string): string;
  add(a: string, b: number): string;
  add(a: number, b: string): string;
  add(a: Combinable, b: Combinable) {
  if (typeof a === 'string' || typeof b === 'string') {
    return a.toString() + b.toString();
  }
    return a + b;
  }
}

const calculator = new Calculator();
const result = calculator.add('Semlinker', ' Kakuqo');

這裡需要注意的是,當 TypeScript 編譯器處理函式過載時,它會查詢過載列表,嘗試使用第一個過載定義。 如果匹配的話就使用這個。 因此,在定義過載的時候,一定要把最精確的定義放在最前面。另外在 Calculator 類中,add(a: Combinable, b: Combinable){ } 並不是過載列表的一部分,因此對於 add 成員方法來說,我們只定義了四個過載方法。

八、TypeScript 陣列

8.1 陣列解構

let x: number; let y: number; let z: number;
let five_array = [0,1,2,3,4];
[x,y,z] = five_array;

8.2 陣列展開運算子

let two_array = [0, 1];
let five_array = [...two_array, 2, 3, 4];

8.3 陣列遍歷

let colors: string[] = ["red", "green", "blue"];
for (let i of colors) {
  console.log(i);
}

九、TypeScript 物件

9.1 物件解構

let person = {
  name: "Semlinker",
  gender: "Male",
};

let { name, gender } = person;

9.2 物件展開運算子

let person = {
  name: "Semlinker",
  gender: "Male",
  address: "Xiamen",
};

// 組裝物件
let personWithAge = { ...person, age: 33 };

// 獲取除了某些項外的其它項
let { name, ...rest } = person;

十、TypeScript 介面

在面嚮物件語言中,介面是一個很重要的概念,它是對行為的抽象,而具體如何行動需要由類去實現。

TypeScript 中的介面是一個非常靈活的概念,除了可用於對類的一部分行為進行抽象以外,也常用於對「物件的形狀(Shape)」進行描述。

10.1 物件的形狀

interface Person {
  name: string;
  age: number;
}

let semlinker: Person = {
  name: "semlinker",
  age: 33,
};

10.2 可選 | 只讀屬性

interface Person {
  readonly name: string;
  age?: number;
}

只讀屬性用於限制只能在物件剛剛建立的時候修改其值。此外 TypeScript 還提供了 ReadonlyArray<T> 型別,它與 Array<T> 相似,只是把所有可變方法去掉了,因此可以確保陣列建立後再也不能被修改。

let a: number[] = [1, 2, 3, 4];
let ro: ReadonlyArray<number> = a;
ro[0] = 12; // error!
ro.push(5); // error!
ro.length = 100; // error!
a = ro; // error!

10.3 任意屬性

有時候我們希望一個介面中除了包含必選和可選屬性之外,還允許有其他的任意屬性,這時我們可以使用 索引簽名 的形式來滿足上述要求。

interface Person {
  name: string;
  age?: number;
  [propName: string]: any;
}

const p1 = { name: "semlinker" };
const p2 = { name: "lolo", age: 5 };
const p3 = { name: "kakuqo", sex: 1 }

10.4 介面與型別別名的區別

1.Objects/Functions

介面和型別別名都可以用來描述物件的形狀或函式簽名:

介面

interface Point {
  x: number;
  y: number;
}

interface SetPoint {
  (x: number, y: number): void;
}

型別別名

type Point = {
  x: number;
  y: number;
};

type SetPoint = (x: number, y: number) => void;
2.Other Types

與介面型別不一樣,型別別名可以用於一些其他型別,比如原始型別、聯合型別和元組:

// primitive
type Name = string;

// object
type PartialPointX = { x: number; };
type PartialPointY = { y: number; };

// union
type PartialPoint = PartialPointX | PartialPointY;

// tuple
type Data = [number, string];
3.Extend

介面和型別別名都能夠被擴充套件,但語法有所不同。此外,介面和型別別名不是互斥的。介面可以擴充套件型別別名,而反過來是不行的。

Interface extends interface

interface PartialPointX { x: number; }
interface Point extends PartialPointX { 
  y: number; 
}

Type alias extends type alias

type PartialPointX = { x: number; };
type Point = PartialPointX & { y: number; };

Interface extends type alias

type PartialPointX = { x: number; };
interface Point extends PartialPointX { y: number; }

Type alias extends interface

interface PartialPointX { x: number; }
type Point = PartialPointX & { y: number; };
4.Implements

類可以以相同的方式實現介面或型別別名,但類不能實現使用型別別名定義的聯合型別:

interface Point {
  x: number;
  y: number;
}

class SomePoint implements Point {
  x = 1;
  y = 2;
}

type Point2 = {
  x: number;
  y: number;
};

class SomePoint2 implements Point2 {
  x = 1;
  y = 2;
}

type PartialPoint = { x: number; } | { y: number; };

// A class can only implement an object type or 
// intersection of object types with statically known members.
class SomePartialPoint implements PartialPoint { // Error
  x = 1;
  y = 2;
}
5.Declaration merging

與型別別名不同,介面可以定義多次,會被自動合併為單個介面。

interface Point { x: number; }
interface Point { y: number; }

const point: Point = { x: 1, y: 2 };

十一、TypeScript 類

11.1 類的屬性與方法

在面嚮物件語言中,類是一種物件導向計算機程式語言的構造,是建立物件的藍圖,描述了所建立的物件共同的屬性和方法。

在 TypeScript 中,我們可以通過 Class 關鍵字來定義一個類:

class Greeter {
  // 靜態屬性
  static cname: string = "Greeter";
  // 成員屬性
  greeting: string;

  // 建構函式 - 執行初始化操作
  constructor(message: string) {
    this.greeting = message;
  }

  // 靜態方法
  static getClassName() {
    return "Class name is Greeter";
  }

  // 成員方法
  greet() {
    return "Hello, " + this.greeting;
  }
}

let greeter = new Greeter("world");

那麼成員屬性與靜態屬性,成員方法與靜態方法有什麼區別呢?這裡無需過多解釋,我們直接看一下編譯生成的 ES5 程式碼:

"use strict";
var Greeter = /** @class */ (function () {
    // 建構函式 - 執行初始化操作
    function Greeter(message) {
      this.greeting = message;
    }
    // 靜態方法
    Greeter.getClassName = function () {
      return "Class name is Greeter";
    };
    // 成員方法
    Greeter.prototype.greet = function () {
      return "Hello, " + this.greeting;
    };
    // 靜態屬性
    Greeter.cname = "Greeter";
    return Greeter;
}());
var greeter = new Greeter("world");

11.2 ECMAScript 私有欄位

在 TypeScript 3.8 版本就開始支援ECMAScript 私有欄位,使用方式如下:

class Person {
  #name: string;

  constructor(name: string) {
    this.#name = name;
  }

  greet() {
    console.log(`Hello, my name is ${this.#name}!`);
  }
}

let semlinker = new Person("Semlinker");

semlinker.#name;
//     ~~~~~
// Property '#name' is not accessible outside class 'Person'
// because it has a private identifier.

與常規屬性(甚至使用 private 修飾符宣告的屬性)不同,私有欄位要牢記以下規則:

  • 私有欄位以 # 字元開頭,有時我們稱之為私有名稱;
  • 每個私有欄位名稱都唯一地限定於其包含的類;
  • 不能在私有欄位上使用 TypeScript 可訪問性修飾符(如 public 或 private);
  • 私有欄位不能在包含的類之外訪問,甚至不能被檢測到。

11.3 訪問器

在 TypeScript 中,我們可以通過 gettersetter 方法來實現資料的封裝和有效性校驗,防止出現異常資料。

let passcode = "Hello TypeScript";

class Employee {
  private _fullName: string;

  get fullName(): string {
    return this._fullName;
  }

  set fullName(newName: string) {
    if (passcode && passcode == "Hello TypeScript") {
      this._fullName = newName;
    } else {
      console.log("Error: Unauthorized update of employee!");
    }
  }
}

let employee = new Employee();
employee.fullName = "Semlinker";
if (employee.fullName) {
  console.log(employee.fullName);
}

11.4 類的繼承

繼承(Inheritance)是一種聯結類與類的層次模型。指的是一個類(稱為子類、子介面)繼承另外的一個類(稱為父類、父介面)的功能,並可以增加它自己的新功能的能力,繼承是類與類或者介面與介面之間最常見的關係。

繼承是一種 is-a 關係:

在 TypeScript 中,我們可以通過 extends 關鍵字來實現繼承:

class Animal {
  name: string;
  
  constructor(theName: string) {
    this.name = theName;
  }
  
  move(distanceInMeters: number = 0) {
    console.log(`${this.name} moved ${distanceInMeters}m.`);
  }
}

class Snake extends Animal {
  constructor(name: string) {
    super(name); // 呼叫父類的建構函式
  }
  
  move(distanceInMeters = 5) {
    console.log("Slithering...");
    super.move(distanceInMeters);
  }
}

let sam = new Snake("Sammy the Python");
sam.move();

11.5 抽象類

使用 abstract 關鍵字宣告的類,我們稱之為抽象類。抽象類不能被例項化,因為它裡面包含一個或多個抽象方法。所謂的抽象方法,是指不包含具體實現的方法:

abstract class Person {
  constructor(public name: string){}

  abstract say(words: string) :void;
}

// Cannot create an instance of an abstract class.(2511)
const lolo = new Person(); // Error

抽象類不能被直接例項化,我們只能例項化實現了所有抽象方法的子類。具體如下所示:

abstract class Person {
  constructor(public name: string){}

  // 抽象方法
  abstract say(words: string) :void;
}

class Developer extends Person {
  constructor(name: string) {
    super(name);
  }
  
  say(words: string): void {
    console.log(`${this.name} says ${words}`);
  }
}

const lolo = new Developer("lolo");
lolo.say("I love ts!"); // lolo says I love ts!

11.6 類方法過載

在前面的章節,我們已經介紹了函式過載。對於類的方法來說,它也支援過載。比如,在以下示例中我們過載了 ProductService 類的 getProducts 成員方法:

class ProductService {
    getProducts(): void;
    getProducts(id: number): void;
    getProducts(id?: number) {
      if(typeof id === 'number') {
          console.log(`獲取id為 ${id} 的產品資訊`);
      } else {
          console.log(`獲取所有的產品資訊`);
      }  
    }
}

const productService = new ProductService();
productService.getProducts(666); // 獲取id為 666 的產品資訊
productService.getProducts(); // 獲取所有的產品資訊 

十二、TypeScript 泛型

軟體工程中,我們不僅要建立一致的定義良好的 API,同時也要考慮可重用性。 元件不僅能夠支援當前的資料型別,同時也能支援未來的資料型別,這在建立大型系統時為你提供了十分靈活的功能。

在像 C# 和 Java 這樣的語言中,可以使用泛型來建立可重用的元件,一個元件可以支援多種型別的資料。 這樣使用者就可以以自己的資料型別來使用元件。

設計泛型的關鍵目的是在成員之間提供有意義的約束,這些成員可以是:類的例項成員、類的方法、函式引數和函式返回值。

泛型(Generics)是允許同一個函式接受不同型別引數的一種模板。相比於使用 any 型別,使用泛型來建立可複用的元件要更好,因為泛型會保留引數型別。

12.1 泛型語法

對於剛接觸 TypeScript 泛型的讀者來說,首次看到 <T> 語法會感到陌生。其實它沒有什麼特別,就像傳遞引數一樣,我們傳遞了我們想要用於特定函式呼叫的型別。

參考上面的圖片,當我們呼叫 identity<Number>(1)Number 型別就像引數 1 一樣,它將在出現 T 的任何位置填充該型別。圖中 <T> 內部的 T 被稱為型別變數,它是我們希望傳遞給 identity 函式的型別佔位符,同時它被分配給 value 引數用來代替它的型別:此時 T 充當的是型別,而不是特定的 Number 型別。

其中 T 代表 Type,在定義泛型時通常用作第一個型別變數名稱。但實際上 T 可以用任何有效名稱代替。除了 T 之外,以下是常見泛型變數代表的意思:

  • K(Key):表示物件中的鍵型別;
  • V(Value):表示物件中的值型別;
  • E(Element):表示元素型別。

其實並不是只能定義一個型別變數,我們可以引入希望定義的任何數量的型別變數。比如我們引入一個新的型別變數 U,用於擴充套件我們定義的 identity 函式:

function identity <T, U>(value: T, message: U) : T {
  console.log(message);
  return value;
}

console.log(identity<Number, string>(68, "Semlinker"));

除了為型別變數顯式設定值之外,一種更常見的做法是使編譯器自動選擇這些型別,從而使程式碼更簡潔。我們可以完全省略尖括號,比如:

function identity <T, U>(value: T, message: U) : T {
  console.log(message);
  return value;
}

console.log(identity(68, "Semlinker"));

對於上述程式碼,編譯器足夠聰明,能夠知道我們的引數型別,並將它們賦值給 T 和 U,而不需要開發人員顯式指定它們。

12.2 泛型介面

interface GenericIdentityFn<T> {
  (arg: T): T;
}

12.3 泛型類

class GenericNumber<T> {
  zeroValue: T;
  add: (x: T, y: T) => T;
}

let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function (x, y) {
  return x + y;
};

12.4 泛型工具型別

為了方便開發者 TypeScript 內建了一些常用的工具型別,比如 Partial、Required、Readonly、Record 和 ReturnType 等。出於篇幅考慮,這裡我們只簡單介紹 Partial 工具型別。不過在具體介紹之前,我們得先介紹一些相關的基礎知識,方便讀者自行學習其它的工具型別。

1.typeof

在 TypeScript 中,typeof 操作符可以用來獲取一個變數宣告或物件的型別。

interface Person {
  name: string;
  age: number;
}

const sem: Person = { name: 'semlinker', age: 33 };
type Sem= typeof sem; // -> Person

function toArray(x: number): Array<number> {
  return [x];
}

type Func = typeof toArray; // -> (x: number) => number[]
2.keyof

keyof 操作符是在 TypeScript 2.1 版本引入的,該操作符可以用於獲取某種型別的所有鍵,其返回型別是聯合型別。

interface Person {
  name: string;
  age: number;
}

type K1 = keyof Person; // "name" | "age"
type K2 = keyof Person[]; // "length" | "toString" | "pop" | "push" | "concat" | "join" 
type K3 = keyof { [x: string]: Person };  // string | number

在 TypeScript 中支援兩種索引簽名,數字索引和字串索引:

interface StringArray {
  // 字串索引 -> keyof StringArray => string | number
  [index: string]: string; 
}

interface StringArray1 {
  // 數字索引 -> keyof StringArray1 => number
  [index: number]: string;
}

為了同時支援兩種索引型別,就得要求數字索引的返回值必須是字串索引返回值的子類。其中的原因就是當使用數值索引時,JavaScript 在執行索引操作時,會先把數值索引先轉換為字串索引。所以 keyof { [x: string]: Person } 的結果會返回 string | number

3.in

in 用來遍歷列舉型別:

type Keys = "a" | "b" | "c"

type Obj =  {
  [p in Keys]: any
} // -> { a: any, b: any, c: any }
4.infer

在條件型別語句中,可以用 infer 宣告一個型別變數並且對它進行使用。

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

以上程式碼中 infer R 就是宣告一個變數來承載傳入函式簽名的返回值型別,簡單說就是用它取到函式返回值的型別方便之後使用。

5.extends

有時候我們定義的泛型不想過於靈活或者說想繼承某些類等,可以通過 extends 關鍵字新增泛型約束。

interface Lengthwise {
  length: number;
}

function loggingIdentity<T extends Lengthwise>(arg: T): T {
  console.log(arg.length);
  return arg;
}

現在這個泛型函式被定義了約束,因此它不再是適用於任意型別:

loggingIdentity(3);  // Error, number doesn't have a .length property

這時我們需要傳入符合約束型別的值,必須包含必須的屬性:

loggingIdentity({length: 10, value: 3});
6.Partial

Partial<T> 的作用就是將某個型別裡的屬性全部變為可選項 ?

定義:

/**
 * node_modules/typescript/lib/lib.es5.d.ts
 * Make all properties in T optional
 */
type Partial<T> = {
  [P in keyof T]?: T[P];
};

在以上程式碼中,首先通過 keyof T 拿到 T 的所有屬性名,然後使用 in 進行遍歷,將值賦給 P,最後通過 T[P] 取得相應的屬性值。中間的 ? 號,用於將所有屬性變為可選。

示例:

interface Todo {
  title: string;
  description: string;
}

function updateTodo(todo: Todo, fieldsToUpdate: Partial<Todo>) {
  return { ...todo, ...fieldsToUpdate };
}

const todo1 = {
  title: "Learn TS",
  description: "Learn TypeScript",
};

const todo2 = updateTodo(todo1, {
  description: "Learn TypeScript Enum",
});

在上面的 updateTodo 方法中,我們利用 Partial<T> 工具型別,定義 fieldsToUpdate 的型別為 Partial<Todo>,即:

{
   title?: string | undefined;
   description?: string | undefined;
}

十三、TypeScript 裝飾器

13.1 裝飾器是什麼

  • 它是一個表示式
  • 該表示式被執行後,返回一個函式
  • 函式的入參分別為 target、name 和 descriptor
  • 執行該函式後,可能返回 descriptor 物件,用於配置 target 物件

13.2 裝飾器的分類

  • 類裝飾器(Class decorators)
  • 屬性裝飾器(Property decorators)
  • 方法裝飾器(Method decorators)
  • 引數裝飾器(Parameter decorators)

需要注意的是,若要啟用實驗性的裝飾器特性,你必須在命令列或 tsconfig.json 裡啟用 experimentalDecorators 編譯器選項:

命令列

tsc --target ES5 --experimentalDecorators

tsconfig.json

{
  "compilerOptions": {
     "target": "ES5",
     "experimentalDecorators": true
   }
}

13.3 類裝飾器

類裝飾器宣告:

declare type ClassDecorator = <TFunction extends Function>(
  target: TFunction
) => TFunction | void;

類裝飾器顧名思義,就是用來裝飾類的。它接收一個引數:

  • target: TFunction - 被裝飾的類

看完第一眼後,是不是感覺都不好了。沒事,我們馬上來個例子:

function Greeter(target: Function): void {
  target.prototype.greet = function (): void {
    console.log("Hello Semlinker!");
  };
}

@Greeter
class Greeting {
  constructor() {
    // 內部實現
  }
}

let myGreeting = new Greeting();
myGreeting.greet(); // console output: 'Hello Semlinker!';

上面的例子中,我們定義了 Greeter 類裝飾器,同時我們使用了 @Greeter 語法糖,來使用裝飾器。

友情提示:讀者可以直接複製上面的程式碼,在 TypeScript Playground 中執行檢視結果。

有的讀者可能想問,例子中總是輸出 Hello Semlinker! ,能自定義輸出的問候語麼 ?這個問題很好,答案是可以的。

具體實現如下:

function Greeter(greeting: string) {
  return function (target: Function) {
    target.prototype.greet = function (): void {
      console.log(greeting);
    };
  };
}

@Greeter("Hello TS!")
class Greeting {
  constructor() {
    // 內部實現
  }
}

let myGreeting = new Greeting();
myGreeting.greet(); // console output: 'Hello TS!';

13.4 屬性裝飾器

屬性裝飾器宣告:

declare type PropertyDecorator = (target:Object, 
  propertyKey: string | symbol ) => void;

屬性裝飾器顧名思義,用來裝飾類的屬性。它接收兩個引數:

  • target: Object - 被裝飾的類
  • propertyKey: string | symbol - 被裝飾類的屬性名

趁熱打鐵,馬上來個例子熱熱身:

function logProperty(target: any, key: string) {
  delete target[key];

  const backingField = "_" + key;

  Object.defineProperty(target, backingField, {
    writable: true,
    enumerable: true,
    configurable: true
  });

  // property getter
  const getter = function (this: any) {
    const currVal = this[backingField];
    console.log(`Get: ${key} => ${currVal}`);
    return currVal;
  };

  // property setter
  const setter = function (this: any, newVal: any) {
    console.log(`Set: ${key} => ${newVal}`);
    this[backingField] = newVal;
  };

  // Create new property with getter and setter
  Object.defineProperty(target, key, {
    get: getter,
    set: setter,
    enumerable: true,
    configurable: true
  });
}

class Person { 
  @logProperty
  public name: string;

  constructor(name : string) { 
    this.name = name;
  }
}

const p1 = new Person("semlinker");
p1.name = "kakuqo";

以上程式碼我們定義了一個 logProperty 函式,來跟蹤使用者對屬性的操作,當程式碼成功執行後,在控制檯會輸出以下結果:

Set: name => semlinker
Set: name => kakuqo

13.5 方法裝飾器

方法裝飾器宣告:

declare type MethodDecorator = <T>(target:Object, propertyKey: string | symbol,          
  descriptor: TypePropertyDescript<T>) => TypedPropertyDescriptor<T> | void;

方法裝飾器顧名思義,用來裝飾類的方法。它接收三個引數:

  • target: Object - 被裝飾的類
  • propertyKey: string | symbol - 方法名
  • descriptor: TypePropertyDescript - 屬性描述符

廢話不多說,直接上例子:

function LogOutput(tarage: Function, key: string, descriptor: any) {
  let originalMethod = descriptor.value;
  let newMethod = function(...args: any[]): any {
    let result: any = originalMethod.apply(this, args);
    if(!this.loggedOutput) {
      this.loggedOutput = new Array<any>();
    }
    this.loggedOutput.push({
      method: key,
      parameters: args,
      output: result,
      timestamp: new Date()
    });
    return result;
  };
  descriptor.value = newMethod;
}

class Calculator {
  @LogOutput
  double (num: number): number {
    return num * 2;
  }
}

let calc = new Calculator();
calc.double(11);
// console ouput: [{method: "double", output: 22, ...}]
console.log(calc.loggedOutput); 

下面我們來介紹一下引數裝飾器。

13.6 引數裝飾器

引數裝飾器宣告:

declare type ParameterDecorator = (target: Object, propertyKey: string | symbol, 
  parameterIndex: number ) => void

引數裝飾器顧名思義,是用來裝飾函式引數,它接收三個引數:

  • target: Object - 被裝飾的類
  • propertyKey: string | symbol - 方法名
  • parameterIndex: number - 方法中引數的索引值
function Log(target: Function, key: string, parameterIndex: number) {
  let functionLogged = key || target.prototype.constructor.name;
  console.log(`The parameter in position ${parameterIndex} at ${functionLogged} has
    been decorated`);
}

class Greeter {
  greeting: string;
  constructor(@Log phrase: string) {
    this.greeting = phrase; 
  }
}

// console output: The parameter in position 0 
// at Greeter has been decorated

十四、TypeScript 4.0 新特性

TypeScript 4.0 帶來了很多新的特性,這裡我們只簡單介紹其中的兩個新特性。

14.1 建構函式的類屬性推斷

noImplicitAny 配置屬性被啟用之後,TypeScript 4.0 就可以使用控制流分析來確認類中的屬性型別:

class Person {
  fullName; // (property) Person.fullName: string
  firstName; // (property) Person.firstName: string
  lastName; // (property) Person.lastName: string

  constructor(fullName: string) {
    this.fullName = fullName;
    this.firstName = fullName.split(" ")[0];
    this.lastName =   fullName.split(" ")[1];
  }  
}

然而對於以上的程式碼,如果在 TypeScript 4.0 以前的版本,比如在 3.9.2 版本下,編譯器會提示以下錯誤資訊:

class Person {
  // Member 'fullName' implicitly has an 'any' type.(7008)
  fullName; // Error
  firstName; // Error
  lastName; // Error

  constructor(fullName: string) {
    this.fullName = fullName;
    this.firstName = fullName.split(" ")[0];
    this.lastName =   fullName.split(" ")[1];
  }  
}

從建構函式推斷類屬性的型別,該特性給我們帶來了便利。但在使用過程中,如果我們沒法保證對成員屬性都進行賦值,那麼該屬性可能會被認為是 undefined

class Person {
   fullName;  // (property) Person.fullName: string
   firstName; // (property) Person.firstName: string | undefined
   lastName; // (property) Person.lastName: string | undefined

   constructor(fullName: string) {
     this.fullName = fullName;
     if(Math.random()){
       this.firstName = fullName.split(" ")[0];
       this.lastName =   fullName.split(" ")[1];
     }
   }  
}

14.2 標記的元組元素

在以下的示例中,我們使用元組型別來宣告剩餘引數的型別:

function addPerson(...args: [string, number]): void {
  console.log(`Person info: name: ${args[0]}, age: ${args[1]}`)
}

addPerson("lolo", 5); // Person info: name: lolo, age: 5 

其實,對於上面的 addPerson 函式,我們也可以這樣實現:

function addPerson(name: string, age: number) {
  console.log(`Person info: name: ${name}, age: ${age}`)
}

這兩種方式看起來沒有多大的區別,但對於第一種方式,我們沒法設定第一個引數和第二個引數的名稱。雖然這樣對型別檢查沒有影響,但在元組位置上缺少標籤,會使得它們難於使用。為了提高開發者使用元組的體驗,TypeScript 4.0 支援為元組型別設定標籤:

function addPerson(...args: [name: string, age: number]): void {
  console.log(`Person info: name: ${args[0]}, age: ${args[1]}`);
}

之後,當我們使用 addPerson 方法時,TypeScript 的智慧提示就會變得更加友好。

// 未使用標籤的智慧提示
// addPerson(args_0: string, args_1: number): void
function addPerson(...args: [string, number]): void {
  console.log(`Person info: name: ${args[0]}, age: ${args[1]}`)
} 

// 已使用標籤的智慧提示
// addPerson(name: string, age: number): void
function addPerson(...args: [name: string, age: number]): void {
  console.log(`Person info: name: ${args[0]}, age: ${args[1]}`);
} 

十五、編譯上下文

15.1 tsconfig.json 的作用

  • 用於標識 TypeScript 專案的根路徑;
  • 用於配置 TypeScript 編譯器;
  • 用於指定編譯的檔案。

15.2 tsconfig.json 重要欄位

  • files - 設定要編譯的檔案的名稱;
  • include - 設定需要進行編譯的檔案,支援路徑模式匹配;
  • exclude - 設定無需進行編譯的檔案,支援路徑模式匹配;
  • compilerOptions - 設定與編譯流程相關的選項。

15.3 compilerOptions 選項

compilerOptions 支援很多選項,常見的有 baseUrltargetbaseUrlmoduleResolutionlib 等。

compilerOptions 每個選項的詳細說明如下:

{
  "compilerOptions": {

    /* 基本選項 */
    "target": "es5",                       // 指定 ECMAScript 目標版本: 'ES3' (default), 'ES5', 'ES6'/'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'
    "module": "commonjs",                  // 指定使用模組: 'commonjs', 'amd', 'system', 'umd' or 'es2015'
    "lib": [],                             // 指定要包含在編譯中的庫檔案
    "allowJs": true,                       // 允許編譯 javascript 檔案
    "checkJs": true,                       // 報告 javascript 檔案中的錯誤
    "jsx": "preserve",                     // 指定 jsx 程式碼的生成: 'preserve', 'react-native', or 'react'
    "declaration": true,                   // 生成相應的 '.d.ts' 檔案
    "sourceMap": true,                     // 生成相應的 '.map' 檔案
    "outFile": "./",                       // 將輸出檔案合併為一個檔案
    "outDir": "./",                        // 指定輸出目錄
    "rootDir": "./",                       // 用來控制輸出目錄結構 --outDir.
    "removeComments": true,                // 刪除編譯後的所有的註釋
    "noEmit": true,                        // 不生成輸出檔案
    "importHelpers": true,                 // 從 tslib 匯入輔助工具函式
    "isolatedModules": true,               // 將每個檔案做為單獨的模組 (與 'ts.transpileModule' 類似).

    /* 嚴格的型別檢查選項 */
    "strict": true,                        // 啟用所有嚴格型別檢查選項
    "noImplicitAny": true,                 // 在表示式和宣告上有隱含的 any型別時報錯
    "strictNullChecks": true,              // 啟用嚴格的 null 檢查
    "noImplicitThis": true,                // 當 this 表示式值為 any 型別的時候,生成一個錯誤
    "alwaysStrict": true,                  // 以嚴格模式檢查每個模組,並在每個檔案里加入 'use strict'

    /* 額外的檢查 */
    "noUnusedLocals": true,                // 有未使用的變數時,丟擲錯誤
    "noUnusedParameters": true,            // 有未使用的引數時,丟擲錯誤
    "noImplicitReturns": true,             // 並不是所有函式裡的程式碼都有返回值時,丟擲錯誤
    "noFallthroughCasesInSwitch": true,    // 報告 switch 語句的 fallthrough 錯誤。(即,不允許 switch 的 case 語句貫穿)

    /* 模組解析選項 */
    "moduleResolution": "node",            // 選擇模組解析策略: 'node' (Node.js) or 'classic' (TypeScript pre-1.6)
    "baseUrl": "./",                       // 用於解析非相對模組名稱的基目錄
    "paths": {},                           // 模組名到基於 baseUrl 的路徑對映的列表
    "rootDirs": [],                        // 根資料夾列表,其組合內容表示專案執行時的結構內容
    "typeRoots": [],                       // 包含型別宣告的檔案列表
    "types": [],                           // 需要包含的型別宣告檔名列表
    "allowSyntheticDefaultImports": true,  // 允許從沒有設定預設匯出的模組中預設匯入。

    /* Source Map Options */
    "sourceRoot": "./",                    // 指定偵錯程式應該找到 TypeScript 檔案而不是原始檔的位置
    "mapRoot": "./",                       // 指定偵錯程式應該找到對映檔案而不是生成檔案的位置
    "inlineSourceMap": true,               // 生成單個 soucemaps 檔案,而不是將 sourcemaps 生成不同的檔案
    "inlineSources": true,                 // 將程式碼與 sourcemaps 生成到一個檔案中,要求同時設定了 --inlineSourceMap 或 --sourceMap 屬性

    /* 其他選項 */
    "experimentalDecorators": true,        // 啟用裝飾器
    "emitDecoratorMetadata": true          // 為裝飾器提供後設資料的支援
  }
}

十六、TypeScript 開發輔助工具

16.1 TypeScript Playground

簡介:TypeScript 官方提供的線上 TypeScript 執行環境,利用它你可以方便地學習 TypeScript 相關知識與不同版本的功能特性。

線上地址:https://www.typescriptlang.or...

除了 TypeScript 官方的 Playground 之外,你還可以選擇其他的 Playground,比如 codepen.iostackblitzjsbin.com 等。

16.2 TypeScript UML Playground

簡介:一款線上 TypeScript UML 工具,利用它你可以為指定的 TypeScript 程式碼生成 UML 類圖。

線上地址:https://tsuml-demo.firebaseap...

16.3 JSON TO TS

簡介:一款 TypeScript 線上工具,利用它你可以為指定的 JSON 資料生成對應的 TypeScript 介面定義。

線上地址:http://www.jsontots.com/

除了使用 jsontots 線上工具之外,對於使用 VSCode IDE 的小夥們還可以安裝 JSON to TS 擴充套件來快速完成 JSON to TS 的轉換工作。

16.4 Schemats

簡介:利用 Schemats,你可以基於(Postgres,MySQL)SQL 資料庫中的 schema 自動生成 TypeScript 介面定義。

線上地址:https://github.com/SweetIQ/sc...

16.5 TypeScript AST Viewer

簡介:一款 TypeScript AST 線上工具,利用它你可以檢視指定 TypeScript 程式碼對應的 AST(Abstract Syntax Tree)抽象語法樹。

線上地址:https://ts-ast-viewer.com/

對於瞭解過 AST 的小夥伴來說,對 astexplorer 這款線上工具應該不會陌生。該工具除了支援 JavaScript 之外,還支援 CSS、JSON、RegExp、GraphQL 和 Markdown 等格式的解析。

16.6 TypeDoc

簡介:TypeDoc 用於將 TypeScript 原始碼中的註釋轉換為 HTML 文件或 JSON 模型。它可靈活擴充套件,並支援多種配置。

線上地址:https://typedoc.org/

16.7 TypeScript ESLint

簡介:使用 TypeScript ESLint 可以幫助我們規範程式碼質量,提高團隊開發效率。

線上地址:https://typescript-eslint.io/

TypeScript ESLint 專案感興趣且想在專案中應用的小夥伴,可以參考 “在Typescript專案中,如何優雅的使用ESLint和Prettier” 這篇文章。

能堅持看到這裡的小夥伴都是 “真愛”,如果你還意猶未盡,那就來看看本人整理的 Github 上 1.8K+ 的開源專案:awesome-typescript

https://github.com/semlinker/...

十七、參考資源

十八、推薦閱讀

相關文章