了不起的 TypeScript 入門教程(1.2W字)

阿寶哥發表於2020-06-09

想學習 TypeScript 的小夥伴看過來,本文將帶你一步步學習 TypeScript 入門相關的十四個知識點,詳細的內容大綱請看下圖:

typescript-quickstart-directory.png

一、TypeScript 是什麼

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

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

typescript-scope-new.png

1.1 TypeScript 與 JavaScript 的區別

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

1.2 獲取 TypeScript

命令列的 TypeScript 編譯器可以使用 Node.js 包來安裝。

1.安裝 TypeScript

$ npm install -g typescript

2.編譯 TypeScript 檔案

$ tsc helloworld.ts
# helloworld.ts => helloworld.js

當然,對於剛入門 TypeScript 的小夥伴,也可以不用安裝 typescript,而是直接使用線上的 TypeScript Playground 來學習新的語法或新特性。

TypeScript Playground:https://www.typescriptlang.or...

二、TypeScript 基礎型別

2.1 Boolean 型別

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

2.2 Number 型別

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

String 型別

let name: string = "Semliker";
// ES5:var name = 'Semlinker';

2.4 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.5 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。上面的列舉示例程式碼經過編譯後會生成以下程式碼:

"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 = {}));

3.異構列舉

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

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.6 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.7 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.8 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.9 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.10 Null 和 Undefined 型別

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

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

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

2.11 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 斷言

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

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

型別斷言有兩種形式:

3.1 “尖括號” 語法

let someValue: any = "this is a string";
let strLength: number = (<string>someValue).length;

3.2 as 語法

let someValue: any = "this is a string";
let strLength: number = (someValue as string).length;

四、型別守衛

A type guard is some expression that performs a runtime check that guarantees the type in some scope. —— 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 值的型別。

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 交叉型別是將多個型別合併為一個型別。 這讓我們可以把現有的多種型別疊加到一起成為一種型別,它包含了所需的所有型別的特性。

interface IPerson {
  id: string;
  age: number;
}

interface IWorker {
  companyId: string;
}

type IStaff = IPerson & IWorker;

const staff: IStaff = {
  id: 'E1006',
  age: 33,
  companyId: 'EFT'
};

console.dir(staff)

在上面示例中,我們首先為 IPerson 和 IWorker 型別定義了不同的成員,然後通過 & 運算子定義了 IStaff 交叉型別,所以該型別同時擁有 IPerson 和 IWorker 這兩種型別的成員。

七、TypeScript 函式

7.1 TypeScript 函式與 JavaScript 函式的區別

TypeScript JavaScript
含有型別 無型別
箭頭函式 箭頭函式(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) {
  if (typeof a === "string" || typeof b === "string") {
    return a.toString() + b.toString();
  }
  return a + b;
}

在以上程式碼中,我們為 add 函式提供了多個函式型別定義,從而實現函式的過載。之後,可惡的錯誤訊息又消失了,因為這時 result 變數的型別是 string 型別。在 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!

十一、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 訪問器

在 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.3 類的繼承

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

繼承是一種 is-a 關係:

ts-is-a.jpeg

在 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.4 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);
  • 私有欄位不能在包含的類之外訪問,甚至不能被檢測到。

十二、TypeScript 泛型

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

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

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

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

12.1 泛型介面

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

12.2 泛型類

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.3 泛型變數

對剛接觸 TypeScript 泛型的小夥伴來說,看到 T 和 E,還有 K 和 V 這些泛型變數時,估計會一臉懵逼。其實這些大寫字母並沒有什麼本質的區別,只不過是一個約定好的規範而已。也就是說使用大寫字母 A-Z 定義的型別變數都屬於泛型,把 T 換成 A,也是一樣的。下面我們介紹一下一些常見泛型變數代表的意思:

  • T(Type):表示一個 TypeScript 型別
  • K(Key):表示物件中的鍵型別
  • V(Value):表示物件中的值型別
  • E(Element):表示元素型別

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: 30 };
type Sem= typeof sem; // -> Person

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

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

2.keyof

keyof 操作符可以用來一個物件中的所有 key 值:

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

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 ILengthwise {
  length: number;
}

function loggingIdentity<T extends ILengthwise>(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: "organize desk",
  description: "clear clutter",
};

const todo2 = updateTodo(todo1, {
  description: "throw out trash",
});

在上面的 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)

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 入門相關的基礎知識,猜測很多剛入門的小夥伴已有 “從入門到放棄” 的想法,最後我們來簡單介紹一下編譯上下文。

十四、編譯上下文

14.1 tsconfig.json 的作用

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

14.2 tsconfig.json 重要欄位

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

14.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          // 為裝飾器提供後設資料的支援
  }
}

看到這裡的讀者都是“真愛”,如果你還意猶未盡,那就來看看本人整理的 Github 上 1.5K+ 的開源專案:awesome-typescript

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

十五、參考資源

相關文章