農林漁牧網

您現在的位置是:首頁 > 農業

「TypeScript」類型別:如何高效使用型別化的面向物件程式設計利器?

2022-11-29由 吉帥振的網路日誌 發表于 農業

派生程式設計是什麼

「TypeScript」類型別:如何高效使用型別化的面向物件程式設計利器?

一、前言

在JavaScript(ES5)中僅支援透過函式和原型鏈繼承模擬類的實現(用於抽象業務模型、組織資料結構並建立可重用元件),自 ES6 引入 class 關鍵字後,它才開始支援使用與`Java`類似的語法定義宣告類。TypeScript 作為 JavaScript 的超集,自然也支援 class 的全部特性,並且還可以對類的屬性、方法等進行靜態型別檢測。

二、類

在實際業務中,任何實體都可以被抽象為一個使用類表達的類似物件的資料結構,且這個資料結構既包含屬性,又包含方法,比如我們在下方抽象了一個狗的類。

class Dog {

name: string;

constructor(name: string) {

this。name = name;

}

bark() {

console。log(‘Woof! Woof!’);

}

}

const dog = new Dog(‘Q’);

dog。bark(); // => ‘Woof! Woof!’

首先,我們定義了一個 class Dog ,它擁有 string 型別的 name 屬性(見第 2 行)、bark 方法(見第 7 行)和一個構造器函式(見第 3 行)。然後,我們透過 new 關鍵字建立了一個 Dog 的例項,並把例項賦值給變數 dog(見 12 行)。最後,我們透過例項呼叫了類中定義的 bark 方法(見 13 行)。如果使用傳統的 JavaScript 程式碼定義類,我們需要使用函式+原型鏈的形式進行模擬,如下程式碼所示:

function Dog(name: string) {

this。name = name; // ts(2683) ‘this’ implicitly has type ‘any’ because it does not have a type annotation。

}

Dog。prototype。bark = function () {

console。log(‘Woof! Woof!’);

};

const dog = new Dog(‘Q’); // ts(7009) ‘new’ expression, whose target lacks a construct signature, implicitly has an ‘any’ type。

dog。bark(); // => ‘Woof! Woof!’

在第 1~ 3 行,我們定義了 Dog 類的建構函式,並在建構函式內部定義了 name 屬性,再在第 4 行透過 Dog 的原型鏈新增 bark 方法。和透過 class 方式定義類相比,這種方式明顯麻煩不少,而且還缺少靜態型別檢測。因此,類是 TypeScript 程式設計中十分有用且不得不掌握的工具。

三、繼承

在 TypeScript 中,使用 extends 關鍵字就能很方便地定義類繼承的抽象模式,如下程式碼所示:

class Animal {

type = ‘Animal’;

say(name: string) {

console。log(`I‘m ${name}!`);

}

}

class Dog extends Animal {

bark() {

console。log(’Woof! Woof!‘);

}

}

const dog = new Dog();

dog。bark(); // => ’Woof! Woof!‘

dog。say(’Q‘); // => I’m Q!

dog。type; // => Animal

上面的例子展示了類最基本的繼承用法。比如第 8 ~12 行定義的`Dog`是派生類,它派生自第 1~6 行定義的`Animal`基類,此時`Dog`例項繼承了基類`Animal`的屬性和方法。因此,在第 15~17 行我們可以看到,例項 dog 支援 bark、say、type 等屬性和方法。

> 說明:派生類通常被稱作子類,基類也被稱作超類(或者父類)。

細心的你可能發現了,這裡的 Dog 基類與第一個例子中的類相比,少了一個建構函式。**這是因為派生類如果包含一個建構函式,則必須在建構函式中呼叫 super() 方法,這是 TypeScript 強制執行的一條重要規則。**

如下示例,因為第 1~10 行定義的 Dog 類建構函式中沒有呼叫 super 方法,所以提示了一個 ts(2377) 的錯誤;而第 12~22 行定義的 Dog 類建構函式中添加了 super 方法呼叫,所以可以透過型別檢測。

class Dog extends Animal {

name: string;

constructor(name: string) { // ts(2377) Constructors for derived classes must contain a ‘super’ call。

this。name = name;

}

bark() {

console。log(‘Woof! Woof!’);

}

}

class Dog extends Animal {

name: string;

constructor(name: string) {

super(); // 新增 super 方法

this。name = name;

}

bark() {

console。log(‘Woof! Woof!’);

}

}

這裡的 super() 是什麼作用?其實這裡的 super 函式會呼叫基類的建構函式,如下程式碼所示:

class Animal {

weight: number;

type = ‘Animal’;

constructor(weight: number) {

this。weight = weight;

}

say(name: string) {

console。log(`I‘m ${name}!`);

}

}

class Dog extends Animal {

name: string;

constructor(name: string) {

super(); // ts(2554) Expected 1 arguments, but got 0。

this。name = name;

}

bark() {

console。log(’Woof! Woof!‘);

}

}

將滑鼠放到第 15 行 Dog 類建構函式呼叫的 super 函式上,我們可以看到一個提示,它的型別是基類 Animal 的建構函式:constructor Animal(weight: number): Animal 。並且因為 Animal 類的建構函式要求必須傳入一個數字型別的 weight 引數,而第 15 行實際入參為空,所以提示了一個 ts(2554) 的錯誤;如果我們顯式地給 super 函式傳入一個 number 型別的值,比如說 super(20),則不會再提示錯誤了。

四、公共、私有與受保護的修飾符

類屬性和方法除了可以透過 extends 被繼承之外,還可以透過修飾符控制可訪問性。

在 TypeScript 中就支援 3 種訪問修飾符,分別是 public、private、protected。

- public 修飾的是在任何地方可見、公有的屬性或方法;

- private 修飾的是僅在同一類中可見、私有的屬性或方法;

- protected 修飾的是僅在類自身及子類中可見、受保護的屬性或方法。

在之前的程式碼中,示例類並沒有用到可見性修飾符,在預設情況下,類的屬性或方法預設都是 public。如果想讓有些屬性對外不可見,那麼我們可以使用`private`進行設定,如下所示:

class Son {

public firstName: string;

private lastName: string = ’Stark‘;

constructor(firstName: string) {

this。firstName = firstName;

this。lastName; // ok

}

}

const son = new Son(’Tony‘);

console。log(son。firstName); // => “Tony”

son。firstName = ’Jack‘;

console。log(son。firstName); // => “Jack”

console。log(son。lastName); // ts(2341) Property ’lastName‘ is private and only accessible within class ’Son‘。

在上面的例子中我們可以看到,第 3 行 Son 類的 lastName 屬性是私有的,只在 Son 類中可見;第 2 行定義的 firstName 屬性是公有的,在任何地方都可見。因此,我們既可以透過第 10 行建立的 Son 類的例項 son 獲取或設定公共的 firstName 的屬性(如第 11 行所示),還可以操作更改 firstName 的值(如第 12 行所示)。不過,對於 private 修飾的私有屬性,只可以在類的內部可見。比如第 6 行,私有屬性 lastName 僅在 Son 類中可見,如果其他地方獲取了 lastName ,TypeScript 就會提示一個 ts(2341) 的錯誤(如第 14 行)。

> **注意**:TypeScript 中定義類的私有屬性僅僅代表靜態型別檢測層面的私有。如果我們強制忽略 TypeScript 型別的檢查錯誤,轉譯且執行 JavaScript 時依舊可以獲取到 lastName 屬性,這是因為 JavaScript 並不支援真正意義上的私有屬性。

目前,JavaScript 類支援 private 修飾符的提案已經到 stage 3 了。相信在不久的將來,私有屬性在型別檢測和執行階段都可以被限制為僅在類的內部可見。

class Son {

public firstName: string;

protected lastName: string = ’Stark‘;

constructor(firstName: string) {

this。firstName = firstName;

this。lastName; // ok

}

}

class GrandSon extends Son {

constructor(firstName: string) {

super(firstName);

}

public getMyLastName() {

return this。lastName;

}

}

const grandSon = new GrandSon(’Tony‘);

console。log(grandSon。getMyLastName()); // => “Stark”

grandSon。lastName; // ts(2445) Property ’lastName‘ is protected and only accessible within class ’Son‘ and its subclasses。

在第 3 行,修改 Son 類的 lastName 屬性可見修飾符為 protected,表明此屬性在 Son 類及其子類中可見。如示例第 6 行和第 16 行所示,我們既可以在父類 Son 的構造器中獲取 lastName 屬性值,又可以在繼承自 Son 的子類 GrandSon 的 getMyLastName 方法獲取 lastName 屬性的值。

> **需要注意**:雖然我們不能透過派生類的例項訪問`protected`修飾的屬性和方法,但是可以透過派生類的例項方法進行訪問。比如示例中的第 21 行,透過例項的 getMyLastName 方法獲取受保護的屬性 lastName 是 ok 的,而第 22 行透過例項直接獲取受保護的屬性 lastName 則提示了一個 ts(2445) 的錯誤。

五、只讀修飾符

在前面的例子中,Son 類 public 修飾的屬性既公開可見,又可以更改值,如果我們不希望類的屬性被更改,則可以使用 readonly 只讀修飾符宣告類的屬性,如下程式碼所示:

class Son {

public readonly firstName: string;

constructor(firstName: string) {

this。firstName = firstName;

}

}

const son = new Son(’Tony‘);

son。firstName = ’Jack‘; // ts(2540) Cannot assign to ’firstName‘ because it is a read-only property。

在第 2 行,我們給公開可見屬性 firstName 指定了只讀修飾符,這個時候如果再更改 firstName 屬性的值,TypeScript 就會提示一個 ts(2540) 的錯誤(參見第 9 行)。這是因為只讀屬性修飾符保證了該屬性只能被讀取,而不能被修改。

> 注意:如果只讀修飾符和可見性修飾符同時出現,我們需要將只讀修飾符寫在可見修飾符後面。

六、存取器

除了上邊提到的修飾符之外,在 TypeScript 中還可以透過`getter`、`setter`擷取對類成員的讀寫訪問。透過對類屬性訪問的擷取,我們可以實現一些特定的訪問控制邏輯。下面我們把之前的示例改造一下,如下程式碼所示:

class Son {

public firstName: string;

protected lastName: string = ’Stark‘;

constructor(firstName: string) {

this。firstName = firstName;

}

}

class GrandSon extends Son {

constructor(firstName: string) {

super(firstName);

}

get myLastName() {

return this。lastName;

}

set myLastName(name: string) {

if (this。firstName === ’Tony‘) {

this。lastName = name;

} else {

console。error(’Unable to change myLastName‘);

}

}

}

const grandSon = new GrandSon(’Tony‘);

console。log(grandSon。myLastName); // => “Stark”

grandSon。myLastName = ’Rogers‘;

console。log(grandSon。myLastName); // => “Rogers”

const grandSon1 = new GrandSon(’Tony1‘);

grandSon1。myLastName = ’Rogers‘; // => “Unable to change myLastName”

在第 14~24 行,我們使用 myLastName 的`getter`、`setter`重寫了之前的 GrandSon 類的方法,在 getter 中實際返回的是 lastName 屬性。然後,在 setter 中,我們限定僅當 lastName 屬性值為 ’Tony‘ ,才把入參 name 賦值給它,否則列印錯誤。在第 28 行中,我們可以像訪問類屬性一樣訪問`getter`,同時也可以像更改屬性值一樣給`setter`賦值,並執行一些自定義邏輯。在第 27 行,因為 grandSon 例項的 lastName 屬性被初始化成了 ’Tony‘,所以在第 29 行我們可以把 ’Rogers‘ 賦值給 setter 。而 grandSon1 例項的 lastName 屬性在第 32 行被初始化為 ’Tony1‘,所以在第 33 行把 ’Rogers‘ 賦值給 setter 時,列印了我們自定義的錯誤資訊。

七、靜態屬性

以上介紹的關於類的所有屬性和方法,只有類在例項化時才會被初始化。實際上,我們也可以給類定義靜態屬性和方法。因為這些屬性存在於類這個特殊的物件上,而不是類的例項上,所以我們可以直接透過類訪問靜態屬性,如下程式碼所示:

class MyArray {

static displayName = ’MyArray‘;

static isArray(obj: unknown) {

return Object。prototype。toString。call(obj)。slice(8, -1) === ’Array‘;

}

}

console。log(MyArray。displayName); // => “MyArray”

console。log(MyArray。isArray([])); // => true

console。log(MyArray。isArray({})); // => false

在第 2~3 行,透過 static 修飾符,我們給 MyArray 類分別定義了一個靜態屬性 displayName 和靜態方法 isArray。之後,我們無須例項化 MyArray 就可以直接訪問類上的靜態屬性和方法了,比如第 8 行訪問的是靜態屬性 displayName,第 9~10 行訪問的是靜態方法 isArray。基於靜態屬性的特性,我們往往會把與類相關的常量、不依賴例項 this 上下文的屬性和方法定義為靜態屬性,從而避免資料冗餘,進而提升執行效能。

> **注意:上邊我們提到了不依賴例項 this 上下文的方法就可以定義成靜態方法,這就意味著需要顯式註解 this 型別才可以在靜態方法中使用 this;非靜態方法則不需要顯式註解 this 型別,因為 this 的指向預設是類的例項。**

八、抽象類

接下來我們看看關於類的另外一個特性——抽象類,它是一種不能被例項化僅能被子類繼承的特殊類。我們可以使用抽象類定義派生類需要實現的屬性和方法,同時也可以定義其他被繼承的預設屬性和方法,如下程式碼所示:

abstract class Adder {

abstract x: number;

abstract y: number;

abstract add(): number;

displayName = ’Adder‘;

addTwice(): number {

return (this。x + this。y) * 2;

}

}

class NumAdder extends Adder {

x: number;

y: number;

constructor(x: number, y: number) {

super();

this。x = x;

this。y = y;

}

add(): number {

return this。x + this。y;

}

}

const numAdder = new NumAdder(1, 2);

console。log(numAdder。displayName); // => “Adder”

console。log(numAdder。add()); // => 3

console。log(numAdder。addTwice()); // => 6

在第 1~10 行,透過 abstract 關鍵字,我們定義了一個抽象類 Adder,並透過`abstract`關鍵字定義了抽象屬性`x`、`y`及方法`add`,而且任何繼承 Adder 的派生類都需要實現這些抽象屬性和方法。同時,我們還在抽象類 Adder 中定義了可以被派生類繼承的非抽象屬性`displayName`和方法`addTwice`。然後,我們在第 12~23 行定義了繼承抽象類的派生類 NumAdder, 並實現了抽象類裡定義的 x、y 抽象屬性和 add 抽象方法。如果派生類中缺少對 x、y、add 這三者中任意一個抽象成員的實現,那麼第 12 行就會提示一個 ts(2515) 錯誤,關於這點你可以親自驗證一下。

抽象類中的其他非抽象成員則可以直接透過例項獲取,比如第 26~28 行中,透過例項 numAdder,我們獲取了 displayName 屬性和 addTwice 方法。因為抽象類不能被例項化,並且派生類必須實現繼承自抽象類上的抽象屬性和方法定義,所以抽象類的作用其實就是對基礎邏輯的封裝和抽象。實際上,我們也可以定義一個描述物件結構的介面型別(詳見 07 講)抽象類的結構,並透過 implements 關鍵字約束類的實現。

使用介面與使用抽象類相比,區別在於介面只能定義類成員的型別,如下程式碼所示:

interface IAdder {

x: number;

y: number;

add: () => number;

}

class NumAdder implements IAdder {

x: number;

y: number;

constructor(x: number, y: number) {

this。x = x;

this。y = y;

}

add() {

return this。x + this。y;

}

addTwice() {

return (this。x + this。y) * 2;

}

}

在第 1~5 行,我們定義了一個包含 x、y、add 屬性和方法的介面型別(詳見 07 講),然後在第 6~12 行實現了擁有介面約定的x、y 屬性和 add 方法,以及介面未約定的 addTwice 方法的NumAdder類 。

九、類的型別

類的最後一個特性——類的型別和函式類似,即在宣告類的時候,其實也同時聲明瞭一個特殊的型別(確切地講是一個介面型別),這個型別的名字就是類名,表示類例項的型別;在定義類的時候,我們宣告的除建構函式外所有屬性、方法的型別就是這個特殊型別的成員。如下程式碼所示:

class A {

name: string;

constructor(name: string) {

this。name = name;

}

}

const a1: A = {}; // ts(2741) Property ’name‘ is missing in type ’{}‘ but required in type ’A‘。

const a2: A = { name: ’a2‘ }; // ok

在第 1~6 行,我們在定義類 A ,也說明我們同時定義了一個包含字串屬性 name 的同名介面型別 A。因此,在第 7 行把一個空物件賦值給型別是 A 的變數 a1 時,TypeScript 會提示一個 ts(2741) 錯誤,因為缺少 name 屬性。在第 8 行把物件{ name: ’a2‘ }賦值給型別同樣是 A 的變數 a2 時,TypeScript 就直接通過了型別檢查,因為有 name 屬性。

十、總結

在 TypeScript 中,因為我們需要實踐 OOP 程式設計思想,所以離不開類的支撐。在實際工作中,類與函式一樣,都是極其有用的抽象、封裝利器。