JavaScript的物件功能十分複雜,這個章節將會用TypeScript嘗試釐清JavaScript的物件建立方式,並且利用物件導向觀念來實作程式。
建立物件
JavaScript建立物件的方式有三種,第一種是用大括號{},第二種是用函數,第三種是用類別。在TypeScript中,任何物件的型別均屬於object型別,而上一章介紹的陣列和函數也是屬於物件的一種。
大括號{}物件
在JavaScript中,可以直接用一對大括號,也就是{}來表示一個沒有任何欄位的「空物件」。
如下:
let o = {};
以上o變數儲存的值為{}這個物件的參考(reference)。此時的o變數的型別也為{}。如下:
let o: {} = {};
利用大括號產生出來的物件時,可以在大括號中直接以key: value的格式來添加物件屬性(欄位或方法)。如果有多個屬性,就用逗號,隔開。例如:
let o = {
name: "David",
age: 18,
toString: function () {
return `${this.age}: ${this.name}`;
},
};
console.log(o.toString());
以上程式會輸出:
以上程式的o變數的型別為{ name: string, age: number, toString: () => string }(用逗號,分隔屬性),或{ name: string; age: number; toString: () => string }(用分號;分隔屬性)。toString是o物件的方法,在這方法的程式實作區塊中,使用了this關鍵字來表示目前這個物件的參考。這邊要注意的是,如果是使用Lambda語法來定義函數,則無法使用this關鍵字來取得物件自身的參考。如下:
let o: { name: string, age: number, toString: () => string } = {
name: "David",
age: 18,
toString: () => `${this.age}: ${this.name}`,
};
console.log(o.toString());
以上程式會編譯失敗。
不過在JavaScript中,我們可以在物件的方法內直接存取用來存放物件參考的變數或常數。因此以上程式可以修改為:
let o: { name: string, age: number, toString: () => string } = {
name: "David",
age: 18,
toString: () => `${o.age}: ${o.name}`,
};
console.log(o.toString());
雖然我們在用大括號語法來建立物件時,屬性的鍵值是直接填上一個名稱,但這個名稱實際上會被當作字串來處理。所以以上程式其實就等同於:
let o: { name: string, age: number, toString: () => string } = {
"name": "David",
"age": 18,
"toString": () => `${o.age}: ${o.name}`,
};
console.log(o.toString());
在某些情況下,我們必須明確地用字串來當作物件屬性的鍵值。例如student-number這個名稱,因為其中帶有減號-,所以必須要明確地用字串來表示。如下:
let o: { name: string, age: number, toString: () => string, "student-number": string } = {
name: "David",
age: 18,
toString: () => `${o.age}: ${o.name}`,
"student-number": "B12345678",
};
console.log(o.toString());
在存取物件的屬性時,也可以在物件後使用中括號[]語法,在中括號中傳入要存取的字串鍵值。如下:
let o: { name: string, age: number, toString: () => string, "student-number": string } = {
name: "David",
age: 18,
toString: () => `${o.age}: ${o.name}`,
"student-number": "B12345678",
};
console.log(o["toString"]());
console.log(o["student-number"]);
如果想要讓物件擁有可選的屬性,可以在定義物件型別時,在屬性名稱後面加上問號?。例如要將student-number這個屬性變成可選的,程式如下:
let o: { name: string, age: number, toString: () => string, "student-number"?: string } = {
name: "David",
age: 18,
toString: () => `${o.age}: ${o.name}`,
};
console.log(o["student-number"]);
o["student-number"] = "B12345678";
console.log(o["student-number"]);
以上程式第7行,會輸出undefined,因為我們在建立o物件時,並沒有替其添加student-number屬性,如果查詢的鍵值並不是該物件的所擁有的話,就會回傳undefined。所以student-number這個屬性的型別雖然被明確定義為string,但它實際上是string | undefined。
若想要用Lambda語法來實作可以回傳大括號物件的函數,根據前面的章節提到的在=>後直接加上表達式的語法,我們很可能會寫出如下的程式:
let createObject = (name: string, age: number, studentNumber?: string) => {
name: name,
age: age,
toString: function () {
return `${this.age}: ${this.name}`;
},
"student-number": studentNumber,
};
let o1 = createObject("David", 18, "B12345678");
let o2 = createObject("Steven", 19);
然而以上程式有嚴重的語法錯誤,因為在=>後接上大括號{}的話就變成是程式敘述區塊了。為了解決這個問題,我們可以替大括號物件再圍上小括號()。如下:
let createObject = (name: string, age: number, studentNumber?: string) => ({
name: name,
age: age,
toString: function () {
return `${this.age}: ${this.name}`;
},
"student-number": studentNumber,
});
let o1 = createObject("David", 18, "B12345678");
let o2 = createObject("Steven", 19);
注意這邊的toString方法必須要使用this關鍵字來存取物件本身的屬性。
物件的屬性名稱除了可以直接打上名稱或是使用字串之外,也可以用型別為number、string或symbol的變數和常數來代替,只要在定義屬性名稱時替這些變數和常數加上中括號[]包起來即可。例如:
const NAME = "name";
const AGE = "age";
const TO_STRING = "toString";
const STUDENT_NUMBER = "student-number";
let o: { [NAME]: string, [AGE]: number, [TO_STRING]: () => string, [STUDENT_NUMBER]: string } = {
[NAME]: "David",
[AGE]: 18,
[TO_STRING]: () => `${o[AGE]}: ${o[NAME]}`,
[STUDENT_NUMBER]: "B12345678",
};
console.log(o.toString());
console.log(o["student-number"]);
再例如:
const NAME = Symbol();
const AGE = Symbol();
const TO_STRING = Symbol();
const STUDENT_NUMBER = Symbol();
let o: { [NAME]: string, [AGE]: number, [TO_STRING]: () => string, [STUDENT_NUMBER]: string } = {
[NAME]: "David",
[AGE]: 18,
[TO_STRING]: () => `${o[AGE]}: ${o[NAME]}`,
[STUDENT_NUMBER]: "B12345678",
};
console.log(o[TO_STRING]());
console.log(o[STUDENT_NUMBER]);
值得注意的是,即便我們使用除了字串以外的變數作為物件的屬性名稱,number型別實際上在執行階段仍會被自動轉成字串來使用。在TypeScript中,還是要遵照正確型別的鍵值來存取屬性才行。
const i = "5";
const ii = 5;
const o = {
[i]: "567",
[ii]: "567",
};
for (const k in o) {
console.log(k, typeof k);
}
以上程式的執行結果為:
再來看看下面這個程式:
const i = 5;
const o = { [i]: "567" };
console.log(o[5]);
console.log(o["5"]);
以上程式可以通過編譯,但是若將程式改為以下這樣:
const i = 5;
const o = { [i]: "567" };
console.log(o[6]);
console.log(o["6"]);
以上程式,在嚴格模式下,第5行和第6行都會編譯失敗。這是因為TypeScript在嚴格模式下知道o物件此時只有一個屬性名稱為5的屬性,所以用6或是"6"作為鍵值來存取屬性才不行的嗎?也不是,事實上,i的型別是5,而不是我們前面所說的number,這是因為i是一個「常數」。所以TypeScript在嚴格模式下只允許存取o物件時須使用型別5或是"5"的鍵值來存取屬性。有關於將定數作為型別的用法,會在之後的章節更加詳細介紹。
若將i改用let或是var關鍵字來宣告,會使得i的型別變為number,則只有第6行會編譯失敗。但是,此時我們也不能使用"5"作為鍵值來存取o物件的屬性。
let i = 5;
const o = { [i]: "567" };
console.log(o[6]);
console.log(o["5"]);
以上程式,在嚴格模式下,第6行會編譯失敗。
函數物件
在JavaScript中,任意函數都可以利用new關鍵字來建立出物件實體。例如:
function F() {
}
let a = new F();
console.log(typeof a);
console.log(a);
以上程式會輸出:
F {}
F是一個沒有參數也沒有程式敘述的函數,new F()可以建立出一個未知型別的物件。
在嚴格模式下,以上程式會編譯失敗,因為編譯器無法將未知型別的物件直接推論為any。在這個情況下,我們也只能利用前面的章節介紹過的as關鍵字,將F函數強制轉型為any,來避開編譯器的規則。
如下:
function F() {
}
let a = new (F as any)();
console.log(typeof a);
console.log(a);
事實上,F函數的建構子即為自己本身,F函數的參數就是建構子的參數,在F函數的主體中可以直接使用this關鍵字來存取到自身的屬性。例如:
function F(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.toString = () => `${this.name}: ${this.age}`;
this.studentNumber = studentNumber;
}
let o1 = new (F as any)("David", 18, "B12345678");
let o2 = new (F as any)("Steven", 19);
console.log(o1.toString());
console.log(o2.toString());
以上程式在嚴格模式下會編譯失敗,因為TypeScript的編譯器無法將this的型別自動推論為any,所以在存取this.name、this.age等屬性時會有問題。為了解決這個問題,我們必須要替this指定型別。
在TypeScript中,可以將this作為參數名稱使其成為函數的第一個參數,如此一來就可以設定this的型別了。如下:
function F(this: any, name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.toString = () => `${this.name}: ${this.age}`;
this.studentNumber = studentNumber;
}
let o1 = new (F as any)("David", 18, "B12345678");
let o2 = new (F as any)("Steven", 19);
console.log(o1.toString());
console.log(o2.toString());
或是:
function F(this: {name: string, age: number, toString: () => string, studentNumber?: string}, name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.toString = () => `${this.name}: ${this.age}`;
this.studentNumber = studentNumber;
}
let o1 = new (F as any)("David", 18, "B12345678");
let o2 = new (F as any)("Steven", 19);
console.log(o1.toString());
console.log(o2.toString());
以上程式輸出結果為:
Steven: 19
還記得我們在上一章有提到symbol型別的值可以拿來實作私有的屬性嗎?我們只要在物件的建構子中用Symbol函數建立symbol型別的值,來當物件屬性的鍵值,這個屬性就會是私有的,因為外部無法建立一個一模一樣的symbol型別的值出來當作要查詢的鍵值。
例如要將F函數產生出來的物件的name和age欄位都變成私有的話,程式改寫如下:
function F(this: any, name: string, age: number, studentNumber?: string) {
const NAME = Symbol();
const AGE = Symbol();
this[NAME] = name;
this[AGE] = age;
this.toString = () => `${this[NAME]}: ${this[AGE]}`;
this.studentNumber = studentNumber;
}
let o1 = new (F as any)("David", 18, "B12345678");
let o2 = new (F as any)("Steven", 19);
console.log(o1.toString());
console.log(o2.toString());
不過如果要使用私有屬性的話,this的型別貌似就只能是any了,除非我們把它做成雙層函數,如下:
function F(name: string, age: number, studentNumber?: string) {
const NAME = Symbol();
const AGE = Symbol();
function inner(this: { [NAME]: string, [AGE]: number, toString: () => string, studentNumber?: string }, name: string, age: number, studentNumber?: string) {
this[NAME] = name;
this[AGE] = age;
this.toString = () => `${this[NAME]}: ${this[AGE]}`;
this.studentNumber = studentNumber;
}
return new (inner as any)(name, age, studentNumber);
}
let o1 = new (F as any)("David", 18, "B12345678");
let o2 = new (F as any)("Steven", 19);
console.log(o1.toString());
console.log(o2.toString());
在函數物件的建構子中回傳的值,即為使用new關鍵字時建立出來的物件。如果函數的建構子不回傳值,則會用this作為使用new關鍵字時建立出來的物件。
類別物件
JavaScript的函數物件非常不適合在TypeScript上使用,因為型別實在是太不好處理了!還好在ES6之後,JavaScript新增了類別物件的功能,使我們可以直接使用class關鍵字來定義類別。
在TypeScript中,類別的語法結構如下:
class 類別名稱 {
欄位1
欄位2
...
欄位n
建構子
方法1
方法2
...
方法n
}
欄位的撰寫方式類似使用var、let、const關鍵字來宣告字串或常數那樣,但不需要寫上var、let、const關鍵字,並且在欄位名稱的後面可以加上問號?來表示該欄位為可選欄位。欄位、建構子和方法的撰寫順序可以隨意,並不會影響程式的運作。類別不一定要明確寫出建構子,如果不撰寫建構子的話,會被當作是有一個無任何參數的建構子。
建構子和方法的撰寫方式類似使用function關鍵字來建立函數那樣,但不需要寫上function關鍵字。
拿剛才撰寫過的函數物件來舉例:
function F(this: any, name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.toString = () => `${this.name}: ${this.age}`;
this.studentNumber = studentNumber;
}
以上程式可以用類別物件改寫為:
class F {
name: string;
age: number;
studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString() {
return `${this.name}: ${this.age}`;
}
}
若要建立類別F的物件實體,與函數物件一樣,也是要使用new關鍵字。不過TypeScript可以正常推論類別的建構子,所以不需要再像函數物件那樣用as關鍵字來轉型了!而且產生出來的物件型別就是類別本身。
例如:
class F {
name: string;
age: number;
studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString() {
return `${this.name}: ${this.age}`;
}
}
let o1: F = new F("David", 18, "B12345678");
let o2: F = new F("Steven", 19);
對於私有屬性,我們也只要在屬性名稱前,加上private修飾子即可。如下:
class F {
private name: string;
private age: number;
studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString() {
return `${this.name}: ${this.age}`;
}
}
以上程式將F類別的name和age物件欄位的存取權限設為私有的了。
建構子也可以使用private來修飾,如果這樣做的話,這個類別就無法被實體化。
既然有private修飾子,那有public修飾子嗎?當然有!public修飾子可以用在建構子的參數上,能使該參數直接變成一般的物件屬性,並且可以直接透過建構子參數來初始化,不必再在建構子主體中撰寫this.xxx = xxx這樣的程式敘述。
例如:
class F {
name: string;
age: number;
studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString() {
return `${this.name}: ${this.age}`;
}
}
let o: F = new F("David", 18, "B12345678");
console.log(o.name);
console.log(o.age);
console.log(o.studentNumber);
以上程式可以修改成:
class F {
constructor(public name: string, public age: number, public studentNumber?: string) {
}
toString() {
return `${this.name}: ${this.age}`;
}
}
let o: F = new F("David", 18, "B12345678");
console.log(o.name);
console.log(o.age);
console.log(o.studentNumber);
物件的原型(Prototype)與繼承(Inheritance)
事實上,我們剛才寫的類別物件:
class F {
name: string;
age: number;
studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString() {
return `${this.name}: ${this.age}`;
}
}
它其實更接近於以下這個函數物件:
function F(this: any, name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
F.prototype.toString = function() {
return `${this.name}: ${this.age}`;
};
直接在類別物件中定義的方法,都會變成類別本身的prototype屬性下的方法。
當我們要存取一個物件的屬性時,例如要存取toString這個屬性好了,JavaScript會先去尋找物件本身有沒有toString屬性,如果沒有的話,就會去尋找該物件的上級物件的prototype屬性下有沒有toString屬性。如果還是沒有,就會去尋找這個上級物件的prototype屬性下的__proto__屬性有沒有toString屬性。如果還是沒有,就會去尋找這個上級物件的prototype屬性下的__proto__屬性下的__proto__屬性下有沒有toString屬性。依此類推,直到找不到可用的__proto__屬性為止。此時若物件是陣列,就會去找Array這個JavaScript內建物件的prototype屬性下的toString屬性,如果還是沒有,就會去找Object這個JavaScript內建物件的prototype屬性下的toString屬性;若此時物件不是陣列,就會直接去找Object的prototype屬性下的toString屬性。
順帶一提,number型別的值在尋找屬性的時候,會先去找Number這個JavaScript內建物件的prototype屬性,再去找Object的prototype屬性;string型別的值在尋找屬性的時候,會先去找String這個JavaScript內建物件的prototype屬性,再去找Object的prototype屬性。依此類推。
使用new關鍵字產生出來的函數物件實體,其上級物件即為該函數本身。如果我們要讓函數物件去繼承其它的函數物件或是類別的話,可以直接去設定函數的prototype屬性的__proto__屬性為其它函數物件或是類別的prototype屬性。
舉例來說:
function User(this: { name: string, type: string }, name: string) {
this.name = name;
this.type = "user";
}
User.prototype.whoAmI = function () {
return `My name is ${this.name}. I am a ${this.type}.`;
};
function Human(this: { name: string, type: string }, name: string) {
User.apply(this, [name]);
this.type = "human";
}
Human.prototype.__proto__ = User.prototype;
function Robot(this: { name: string, type: string }, name: string) {
User.apply(this, [name]);
this.type = "robot";
}
Robot.prototype.__proto__ = User.prototype;
function Student(this: { name: string, type: string }, name: string) {
Human.apply(this, [name]);
this.type = "student";
}
Student.prototype.__proto__ = Human.prototype;
let o1 = new (User as any)("Unknown");
let o2 = new (Human as any)("David");
let o3 = new (Robot as any)("WALL-E");
let o4 = new (Student as any)("Steven");
console.log(o1.whoAmI());
console.log(o2.whoAmI());
console.log(o3.whoAmI());
console.log(o4.whoAmI());
以上程式,Student繼承Human,Human繼承User,Robot繼承User。利用了函數提供的apply方法來指定要使用相同的this所儲存的物件參考來呼叫函數。
執行結果如下:
My name is David. I am a human.
My name is WALL-E. I am a robot.
My name is Steven. I am a student.
當然,以上程式如果使用類別物件來改寫的話,會變得易讀許多。改寫結果如下:
class User {
name: string;
type = "user";
constructor(name: string) {
this.name = name;
}
whoAmI() {
return `My name is ${this.name}. I am a ${this.type}.`;
}
}
class Human extends User {
constructor(name: string) {
super(name);
this.type = "human";
}
}
class Robot extends User {
constructor(name: string) {
super(name);
this.type = "robot";
}
}
class Student extends Human {
constructor(name: string) {
super(name);
this.type = "student";
}
}
let o1 = new User("Unknown");
let o2 = new Human("David");
let o3 = new Robot("WALL-E");
let o4 = new Student("Steven");
console.log(o1.whoAmI());
console.log(o2.whoAmI());
console.log(o3.whoAmI());
console.log(o4.whoAmI());
在類別名稱後面加上extends關鍵字,可以指定該類別要繼承的類別。在子類別的建構子中,必須要在第一個程式敘述使用super關鍵字來呼叫父類別的建構子。
如果我們把User類別的type物件欄位改成私有的,確保其不能被外部程式更改的話,會發現其子類別均無法成功存取type物件欄位而造成TypeScript編譯錯誤。像這種需要讓屬性能夠只公開到子類別的情況,我們要將屬性的修飾子改為protected,而不是private。如下:
class User {
name: string;
protected type = "user";
constructor(name: string) {
this.name = name;
}
whoAmI() {
return `My name is ${this.name}. I am a ${this.type}.`;
}
}
class Human extends User {
constructor(name: string) {
super(name);
this.type = "human";
}
}
class Robot extends User {
constructor(name: string) {
super(name);
this.type = "robot";
}
}
class Student extends Human {
constructor(name: string) {
super(name);
this.type = "student";
}
}
let o1 = new User("Unknown");
let o2 = new Human("David");
let o3 = new Robot("WALL-E");
let o4 = new Student("Steven");
console.log(o1.whoAmI());
console.log(o2.whoAmI());
console.log(o3.whoAmI());
console.log(o4.whoAmI());
替物件的屬性添加適合的存取修飾子,正是物件導向程式設計的封裝(Encapsulation)觀念。
判斷物件是否屬於指定的類別
既然物件有了繼承關係,我們就很可能會需要在程式執行階段去判斷物件所屬的類別。在JavaScript中,提供了instanceof關鍵字,可以用來判斷一個物件是否屬於指定的類別。
例如:
class User {
name: string;
protected type = "user";
constructor(name: string) {
this.name = name;
}
whoAmI() {
return `My name is ${this.name}. I am a ${this.type}.`;
}
}
class Human extends User {
constructor(name: string) {
super(name);
this.type = "human";
}
}
class Robot extends User {
constructor(name: string) {
super(name);
this.type = "robot";
}
}
class Student extends Human {
constructor(name: string) {
super(name);
this.type = "student";
}
}
const o1: User = new User("Unknown");
const o2: User = new Human("David");
const o3: User = new Robot("WALL-E");
const o4: User = new Student("Steven");
console.log(o1 instanceof User);
console.log(o2 instanceof User);
console.log(o3 instanceof User);
console.log(o4 instanceof User);
console.log(o4 instanceof Human);
console.log(o4 instanceof Robot);
console.log(o4 instanceof Student);
以上程式的執行結果如下:
true
true
true
true
false
true
以上程式的36行到39行,由於Human、Robot、Student類別都繼承User類別,因此它們的實體的型別也可以是User。這就是物件導向的多型(Polymorphism)觀念。
類別(靜態)屬性
除了將類別實體化出來後的物件能夠擁有屬性之外,我們也可以讓類別本身也擁有屬性。只要在撰寫類別程式的時候,在屬性名稱前加上static關鍵字,即可使其成為類別屬性。
class User {
name: string;
protected type = "user";
constructor(name: string) {
this.name = name;
}
whoAmI() {
return `My name is ${this.name}. I am a ${this.type}.`;
}
static printInformation() {
console.log("User");
}
}
class Human extends User {
constructor(name: string) {
super(name);
this.type = "human";
}
static printInformation() {
console.log("Human");
}
}
class Robot extends User {
constructor(name: string) {
super(name);
this.type = "robot";
}
static printInformation() {
console.log("Robot");
}
}
class Student extends Human {
constructor(name: string) {
super(name);
this.type = "student";
}
}
User.printInformation();
Human.printInformation();
Robot.printInformation();
Student.printInformation();
以上程式的輸出結果如下:
Human
Robot
Human
類別屬性是可以被繼承的,但是我們無法透過類別實體化出來的物件來存取類別屬性。如果要替類別屬性加上存取修飾子,要添加在static關鍵字之前。
唯讀欄位
在撰寫類別程式的時候,在欄位名稱前加上readonly關鍵字,即可使其成為唯讀欄位。
class User {
readonly name: string;
protected type = "user";
constructor(name: string) {
this.name = name;
}
whoAmI() {
return `My name is ${this.name}. I am a ${this.type}.`;
}
static printInformation() {
console.log("User");
}
}
class Human extends User {
constructor(name: string) {
super(name);
this.type = "human";
}
static printInformation() {
console.log("Human");
}
}
class Robot extends User {
constructor(name: string) {
super(name);
this.type = "robot";
}
static printInformation() {
console.log("Robot");
}
}
class Student extends Human {
constructor(name: string) {
super(name);
this.type = "student";
}
}
User.printInformation();
Human.printInformation();
Robot.printInformation();
Student.printInformation();
若我們想嘗試修改User或是其子類別的物件的name欄位,就會編譯錯誤。只有在類別自己的建構子中,才可以修改唯讀欄位。
如果有存取修飾子的存在,readonly關鍵字要加在存取修飾子之後。如果有static關鍵字的存在,readonly關鍵字要加在static關鍵字之後。
大括號物件也可以有唯讀欄位,只要在型別中的屬性名稱前加上readonly關鍵字就好。例如:
let o: { readonly name: string, readonly age: number, toString: () => string } = {
name: "David",
age: 18,
toString: () => `${o.age}: ${o.name}`,
};
函數(方法)多載
類別中的函數,或者說物件的方法,當然也可以多載。例如:
class F {
name: string;
age: number;
studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString(): string;
toString(includeStudentNumber: boolean): string;
toString(includeStudentNumber?: boolean) {
if (includeStudentNumber) {
if (typeof this.studentNumber === "string") {
return `${this.name}: ${this.age}, ${this.studentNumber}`;
}
}
return `${this.name}: ${this.age}`;
}
}
呼叫父類別的方法
super關鍵字也可以用來呼叫父類別(被覆寫前的)的方法。例如:
class S {
a() {
console.log("a");
}
b() {
console.log("b");
}
}
class C extends S {
a() {
console.log("A");
}
b() {
console.log("B");
}
ab() {
this.a();
super.b();
}
}
new C().ab();
以上程式會輸出:
b
getter 和 setter
對於私有欄位,我們可以透過物件方法來存取它。例如:
class User {
readonly name: string;
protected type = "user";
constructor(name: string) {
this.name = name;
}
whoAmI() {
return `My name is ${this.name}. I am a ${this.type}.`;
}
getType(): string {
return this.type;
}
setType(t: string) {
if (t.length > 0) {
this.type = t;
} else {
this.type = "user";
}
}
}
let user = new User("David");
console.log(user.getType());
user.setType("human");
console.log(user.getType());
以上User物件的getType方法即為一個「getter」,setType方法即為一個「setter」。
事實上,若TypeScript的編譯目標為ES5或是以上的版本的話,我們可以在類別的方法前加上set或是get關鍵字,來讓該方法變成一個「getter」或一個「setter」,使該方法的屬性名稱變成「可以被安全存取的欄位」。例如:
class User {
readonly name: string;
protected _type = "user";
constructor(name: string) {
this.name = name;
}
whoAmI() {
return `My name is ${this.name}. I am a ${this.type}.`;
}
get type(): string {
return this._type;
}
set type(t: string) {
if (t.length > 0) {
this._type = t;
} else {
this._type = "user";
}
}
}
let user = new User("David");
console.log(user.type);
user.type = "human";
console.log(user.type);
被加上set或是get關鍵字的方法不可以設定存取修飾子,但是可以在其前面加上static關鍵字使其變成靜態屬性。
any、object與物件型別的轉換
前面有提到過,在TypeScript中,任何物件的型別均屬於object型別。object型別即{}型別,它無法存取任何的屬性。
我們在先前的章節也有提到過,any可以用來表示任意型別,所以我們可以將任意物件轉成any型別。
例如:
class F {
readonly name: string;
readonly age: number;
readonly studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString() {
return `${this.name}: ${this.age}`;
}
}
let o: any = new F("David", 18);
o.name = 18;
o.age = "David";
o.other = true;
以上程式,雖然o變數是F類別的物件,但它型別被明確定義為any,因此可以無限制地操作F物件的屬性。
事實上,以上這個F類別的物件型別,也可以用{readonly name: string, readonly age: number, readonly studentNumber?: string, toString: () => string }來表示。例如:
class F {
readonly name: string;
readonly age: number;
readonly studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString() {
return `${this.name}: ${this.age}`;
}
}
let o: {readonly name: string, readonly age: number, readonly studentNumber?: string, toString: () => string} = new F("David", 18);
我們可以透過型別來「隱藏」物件的某些屬性。例如要隱藏studentNumber屬性:
class F {
readonly name: string;
readonly age: number;
readonly studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString() {
return `${this.name}: ${this.age}`;
}
}
let o: {readonly name: string, readonly age: number, toString: () => string} = new F("David", 18);
我們甚至可以把型別中的readonly關鍵字直接拿掉,讓它的唯讀欄位變成可讀寫欄位。
class F {
readonly name: string;
readonly age: number;
readonly studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString() {
return `${this.name}: ${this.age}`;
}
}
let o: {name: string, age: number, toString: () => string} = new F("David", 18);
o.name = "Steven";
o.age = 19;
令人震驚的是,我們其實可以把任意值的型別改為其它完全不相關的型別,只要利用any來做中介轉型即可。
class F {
readonly name: string;
readonly age: number;
readonly studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString() {
return `${this.name}: ${this.age}`;
}
}
let o: { firstName: string, lastName: string } = new F("David", 18) as any;
o.firstName = "Steven";
o.lastName = "Nigel";
以上程式是可以通過編譯並且成功執行的。
由於TypeScript的型別並不是這麼的安全,因此在使用冒號:接上型別來明確定義變數或是參數的型別時,或是使用as關鍵字接上型別來進行轉型時,要特別注意原本值的型別。
能夠任意添加屬性的物件型別
any型別除了能夠讓我們自由存取物件的屬性之外,TypeScript還提供了另一種比較安全的方式。先看以下程式:
class F {
readonly name: string;
readonly age: number;
readonly studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString() {
return `${this.name}: ${this.age}`;
}
}
let o: {name: string, age: number, toString: () => string} = new F("David", 18);
如果我們想要讓o物件能夠在保有類別F的屬性限制下,新增額外的任意屬性,可以在其型別上加上這樣的屬性[_: string]: any。如下:
class F {
readonly name: string;
readonly age: number;
readonly studentNumber?: string;
constructor(name: string, age: number, studentNumber?: string) {
this.name = name;
this.age = age;
this.studentNumber = studentNumber;
}
toString() {
return `${this.name}: ${this.age}`;
}
}
let o: { name: string, age: number, toString: () => void, [_: string]: any } = new F("David", 18);
o.a = 1;
o.b = "2";
o.c = false;
[_: string]: any語法看起來很怪異,它的意思是:這個型別的所有物件屬性的鍵值都必須是string,且值的型別可為任意型別。屬性的鍵值可以使用數值或是字串,大部份的情況下會用字串,就算是用數值也是可以用字串來存取屬性。在型別的屬性中有了[_: string]: any後,我們就可以替這個型別的物件添加任意屬性,存放任意型別的值了!
再舉一個例子,如果要設計一個屬性值只能是數值的物件,程式如下:
let o: { [_: string]: number } = {};
o.a = 1;
o.b = 2;
利用is關鍵字來縮減型別範圍
還記得上一章中,我們寫的以下這段程式嗎?
function save(path: string, data_force?: string | boolean, force?: boolean): boolean {
let data;
const t_data_force = typeof data_force;
if (t_data_force === "undefined") {
data = "Hello, world!";
force = false;
} else if (t_data_force === "boolean") {
data = "Hello, world!";
force = data_force as boolean;
} else { // string
data = data_force;
}
const fs = require("node:fs");
if (fs.existsSync(path)) {
if (force) {
fs.unlinkSync(path);
} else {
return false;
}
}
fs.writeFileSync(path, data);
return true;
}
為了讓我們的程式更安全,我應該要儘量避免轉型的發生。TypeScript提供了is關鍵字,可以用在函數的回傳值上,來幫助TypeScript的編譯器限縮值的型別範圍。
以上程式,由於data_force參數的型別為string | boolean | undefined,所以我們其實可以加入以下利用了is關鍵字實作的兩個函數,來讓編譯器能夠正確判斷data_force的型別。
function save(path: string, data_force?: string | boolean, force?: boolean): boolean {
let data;
const t_data_force = typeof data_force;
function isUndefined(data_force?: string | boolean): data_force is undefined {
return t_data_force === "undefined";
}
function isBoolean(data_force?: string | boolean): data_force is boolean {
return t_data_force === "boolean";
}
if (isUndefined(data_force)) {
data = "Hello, world!";
force = false;
} else if (isBoolean(data_force)) {
data = "Hello, world!";
force = data_force;
} else { // string
data = data_force;
}
const fs = require("node:fs");
if (fs.existsSync(path)) {
if (force) {
fs.unlinkSync(path);
} else {
return false;
}
}
fs.writeFileSync(path, data);
return true;
}
題外話,如果值的型別全都被編譯器限縮到沒有的話,會發生什麼事呢?
例如:
function foo(x: string | number): boolean | void {
const t_x = typeof x;
function isString(data_force: string | number): data_force is string {
return t_x === "string";
}
function isNumber(data_force: string | number): data_force is number {
return t_x === "number";
}
if (isString(x)) {
return true;
} else if (isNumber(x)) {
return false;
}
}
當程式執行完第16行之後,x變數的型別是什麼?它的型別其實是never!
function foo(x: string | number): boolean | void {
const t_x = typeof x;
function isString(data_force: string | number): data_force is string {
return t_x === "string";
}
function isNumber(data_force: string | number): data_force is number {
return t_x === "number";
}
if (isString(x)) {
return true;
} else if (isNumber(x)) {
return false;
}
let xx: never = x;
}
在TypeScript中,用never來表示「沒有型別」。
判斷物件是否有指定的屬性
in關鍵字,除了可以用來製作for-in迴圈之外,還可以判斷一個物件是否有指定的屬性。例如:
class User {
name: string;
protected type = "user";
constructor(name: string) {
this.name = name;
}
whoAmI() {
return `My name is ${this.name}. I am a ${this.type}.`;
}
}
class Human extends User {
constructor(name: string) {
super(name);
this.type = "human";
}
}
const o1 = new User("Unknown");
const o2 = new Human("David");
console.log("name" in o1);
console.log("whoAmI" in o1);
console.log("name" in o2);
console.log("whoAmI" in o2);
console.log(o1.hasOwnProperty("name"));
console.log(o1.hasOwnProperty("whoAmI"));
console.log(o2.hasOwnProperty("name"));
console.log(o2.hasOwnProperty("namwhoAmIe"));
以上程式會輸出:
true
true
true
true
false
true
false
物件的hasOwnProperty方法可以判斷物件是否自己擁有(而非透過prototype)指定的屬性。其實在使用for-in迴圈來走訪物件的屬性時,通常還需要使用hasOwnProperty方法來過濾掉非自身的屬性。
例如:
class User {
name: string;
protected type = "user";
constructor(name: string) {
this.name = name;
}
whoAmI() {
return `My name is ${this.name}. I am a ${this.type}.`;
}
}
class Human extends User {
constructor(name: string) {
super(name);
this.type = "human";
}
}
const o = new Human("David");
for (const key in o) {
console.log(key);
}
以上程式會輸出:
name
constructor
whoAmI
而我們通常在使用for-in迴圈時,只是要得到物件本身擁有的屬性,所以要把程式寫成:
class User {
name: string;
protected type = "user";
constructor(name: string) {
this.name = name;
}
whoAmI() {
return `My name is ${this.name}. I am a ${this.type}.`;
}
}
class Human extends User {
constructor(name: string) {
super(name);
this.type = "human";
}
}
const o = new Human("David");
for (const key in o) {
if (o.hasOwnProperty(key)) {
console.log(key);
}
}
以上程式會輸出:
name
您可能會覺得很奇怪,既然物件有hasOwnProperty方法,為什麼hasOwnProperty在使用for-in迴圈的時候不會被走訪呢?這是因為JavaScript的屬性其實是有一些「選項」可以設定的,能夠被for-in迴圈走訪到的屬性,它的enumerable選項必須要被設為true(預設值正是true)。然而hasOwnProperty屬性的enumerable選項是設為false,所以它不會被for-in迴圈走訪。
JavaScript內建的Object物件,提供了defineProperty和defineProperties方法,可以用比較低階的方式來設定任意型別的值的屬性。由於這部份並不是本系列文章的範圍,在此就點到為止。
總結
在這個章節中,我們學到了大括號物件、函數物件和類別物件,這三種JavaScript物件的建立方式,稍微掌握了物件導向程式設計的方式,也了解了TypeScript可怕的轉型機制,然後還學會了instanceof、is和in關鍵字的用法。在下一個章節中,要來學習TypeScript提供的介面用法。

