我們都知道 JavaScript 物件建立的過程,大多都不需要事先設計「藍圖」就可以建立「物件」,不像 C#
或 Java
等強型別語言,需要先設計「藍圖」(也就是類別),才能產生物件。在 ES2015 出來之前,並沒有 class
語法,而是使採用以原型為基礎的物件導向設計模式 (Prototype-based OO)。本篇文章將介紹幾種在 JavaScript 裡面建立物件藍圖的方式。
不用藍圖的物件建立方法
很多人都說寫 JavaScript 的爽度很高,因為怎麼寫都可以,就以一個簡單的物件為例,根本連類別都不用宣告,直接用以下語法就可以建立物件:
var a = {};
建立物件後,可以隨意新增或刪除屬性進去:
var a = {};
a.name = 'Will'; // 擴增 name 屬性
delete a.name; // 刪除 name 屬性
如果要建立一個含有 name
屬性的物件,可以這樣寫:
var a = {
name: 'Will'
};
也可以這樣寫,因為屬性名稱預設就是字串型別:
var a = {
'name': 'Will'
};
這樣寫也可以,讓變數值當成新物件的屬性名稱:
var propName = 'name';
var a = {
[propName]: 'Will'
};
如果要放 Symbol 物件進去,也可以這樣寫:
var a = {
name: 'Will',
[Symbol.iterator]: function*() {
for (let i in this) {
yield this[i];
}
}
};
總之,你可以完全不用對物件進行規劃,就開始隨心所欲的建立物件,因此寫起來的爽度很高!相對的,在複雜的應用程式架構下,也比較容易失控!
定義物件的藍圖 (1) - 使用建構式函數
在 JavaScript 裡面,函數(function)其實就是建構式(constructor),因此我們會透過定一個函數,來當成一個物件的建構式,也就是物件的藍圖。
如下範例就是一個建構式,建構式名稱為 Lesson
,但看起來就像是一個函數:
function Lesson(name) {
this.name = name;
this.sayHello = () => {
return `Hello ${this.name}`;
}
}
請注意 this.sayHello
必須使用 箭頭函數 (Arrow Function) 才不會出錯!
這個函數有兩點不太相同:
- 不需要
return
任何物件
- 會使用
this
來代表未來即將建立的物件實體 (object instance)
當你想建立一個自訂型別為 Lesson
的物件時,就可以使用以下語法來建立:
var a = new Lesson('JavaScript 開發實戰:核心概念篇');
a.sayHello();
透過這種方式建立起的物件,由於事先透過建構式函數規劃與設計過,因此建立的物件,更能確保物件的一致性,也更加簡化物件建立的過程,不但可以讓程式碼更抽象,獲得更好的封裝,也能增加程式的可維護性!👍
定義物件的藍圖 (2) - 使用 class
類別
從 ES2015 開始,你開始可以透過 class
來定義類別,用來建立物件藍圖,如下範例:
class Lesson {
name; // 宣告屬性(Property)
constructor(name) { // 建構式函數
this.name = name;
}
sayHello() { // 宣告方法 (Method)
return `Hello ${this.name}`;
}
}
當要建立物件時,就跟以建構式函數建立物件的方式相同:
var a = new Lesson('JavaScript 開發實戰:核心概念篇');
a.sayHello();
這樣的寫法,相較於 C#
或 Java
這種以類別為基礎的程式語言來說,其實上手會較快,因為語法相近。
透過這種方式建立的物件,你會發現跟建構式函數建立的物件,是相當接近的。你可以說 ES2015 推出的 class
語法,其實只是早期寫法的語法糖而已,實際上物件的特性並沒有什麼差異,只有些微的變化!⭐
如何實現物件的繼承
在物件導向程式設計領域中有許多基本原則,例如封裝、繼承、多形等等。在 JavaScript 裡,繼承是一大重點!
由於從 ES2015 開始,出現了兩種定義物件藍圖的方式,所以我用兩種不同的語法,來表達相同的物件繼承關係,藉此設計出一份更好的物件藍圖,各位可以比較一下差異,並選擇你喜歡的語法來寫即可:
-
使用 prototype
實現物件繼承
如下範例是一個簡單的物件繼承關係,建構式函數名稱為 Lesson
,但我們透過建構式函數提供的 prototype
屬性來建立物件實體(object instance)的上層物件(parent object):
function Lesson(name) {
this.name = name;
}
Lesson.prototype.sayHello = function() {
return `Hello ${this.name}`;
}
var a = new Lesson('JavaScript 開發實戰:核心概念篇');
a.sayHello();
上面這段程式碼比較特別的地方有以下幾點:
- 這個建構式函數只需要撰寫初始化物件的程式碼
- 物件需要共用的部分全部移到
Lesson.prototype
原型物件中,而不是保存在物件實體中
-
使用 class
實現物件繼承
我直接拿上面這個 prorotype
的例子,直接改寫成 class
的版本:
class Lesson {
name;
constructor(name) {
this.name = name;
}
sayHello() {
return `Hello ${this.name}`;
}
}
var a = new Lesson('JavaScript 開發實戰:核心概念篇');
a.sayHello();
你可以從上述兩個範例的執行結果來看,物件的結構幾乎是完全相同的!而且使用 class
語法糖來定義物件繼承關係,會相對簡單許多! 👍
這裡比較特別的地方在於,透過 class
所建立的物件,只有類別中的 Methods (方法) 才會放進物件的上層物件中(a.__proto__
),也就是 Lesson.prototype
物件中。
-
建立一個沒有物件繼承的『純物件』
一般來說,你建立的任何一個物件,無論有幾層物件繼承,其頂層物件皆為 Object.prototype
,那如果我真的想建立一個完全沒有上層物件的純物件,那該怎麼做呢?你可以參考以下程式寫法:
var a = Object.create(null);
我覺得這樣的物件感覺真的蠻酷的,非常的乾淨! 😃
如何實現物件的多層繼承
如果要在 JavaScript 實現多層的繼承,那麼程式碼就會再複雜一些!
-
使用 prototype
實現多層物件繼承
// 定義上層建構式
function Person(age,weight) {
this.age = age;
this.weight = weight;
}
// 定義下層建構式
function Employee(age, weight, salary) {
this.age = age;
this.weight = weight;
this.salary = salary;
}
// 將 Employee 的上層物件改為 Person 的物件實體
Employee.prototype = new Person(0, 0);
// 建立 Employee 物件實體
var e = new Employee(23, 70, 40000);
此時,變數 e
所指向的物件,其上層物件就是 Employee.prototype
,而 Employee.prototype
的上層物件就是 Person.prototype
,而 Person.prototype
的上層物件是誰呢?當你沒有特別定義的時候,那就是 Object.prototype
物件,這個物件幾乎是所有物件的最頂層物件!
Object.prototype
的上層物件為 null
-
使用 class
實現多層物件繼承
透過 JavaScript 的 class
所建立出來的物件,跟用傳統 prototype
建立出來的物件,其繼承關係可能跟你想像的有點不太一樣,尤其是拿 C#
與 Java
的類別特性來相比,在觀念上的差異其實是不太相同的!
我直接拿上面這個例子,直接改寫成 class
的版本:
// 定義上層類別
class Person {
age;
weight;
constructor(age, weight) {
this.age = age;
this.weight = weight;
}
}
// 定義下層類別
class Employee extends Person {
salary;
constructor(age, weight, salary) {
super(age, weight);
this.age = age;
this.weight = weight;
this.salary = salary;
}
}
// 建立 Employee 物件實體
var e = new Employee(23, 70, 40000);
從上圖示來看,你應該不難發現,如果你用 C#
與 Java
的類別特性來想,程式碼肯定不會如你預期的來執行!
- 上層類別的「屬性」其實並非「上層物件」的屬性,而是在建構式執行的時候,都寫入到「物件實體」中!
- 雖然
Employee
與 Person
是繼承關係,但並非「物件」的繼承,而僅是「類別」的繼承而已!
如果我在 Person
類別加入一個 getAge
方法,你會發現 getAge
確實會繼承下來,但是最終的物件結構不太相同:
// 定義上層類別
class Person {
age;
weight;
constructor(age, weight) {
this.age = age;
this.weight = weight;
}
getAge() {
return this.age;
}
}
// 定義下層類別
class Employee extends Person {
salary;
constructor(age, weight, salary) {
super(age, weight);
this.age = age;
this.weight = weight;
this.salary = salary;
}
}
// 建立 Employee 物件實體
var e = new Employee(23, 70, 40000);
e.getAge();
從上圖可以看出,所有建構式寫入的屬性,都會出現在第一層物件實體中。但是上層類別的方法,會放在 e.__proto__.__proto__
物件中。
-
設定一般物件為類別的上層物件
由於 class
其實只是 prototype
的語法糖,因此如果你想用 class
去繼承另一個建構式,也完全是可行的!範例如下:
function Animal (name) {
this.name = name;
}
Animal.prototype.speak = function () {
console.log(this.name + ' makes a noise.');
}
class Dog extends Animal {
speak() {
console.log(this.name + ' barks.');
}
}
var d = new Dog('Mitzie');
d.speak(); // Mitzie barks.
如果你想設定 class
類別的上層物件,可以透過 Object.setPrototypeOf() 來達成,例如:
var Animal = {
speak() {
console.log(this.name + ' makes a noise.');
}
};
class Dog {
constructor(name) {
this.name = name;
}
}
// 直接將 Dog.prototype 設定為 Animal 物件
Object.setPrototypeOf(Dog.prototype, Animal);
var d = new Dog('Mitzie');
d.speak(); // Mitzie makes a noise.
在 JavaScript 裡,還有一個進階的物件定義方法,就是去定義每個物件中屬性的特性(Attributes in property),你可以使用 Object.defineProperty() 去定義特定物件下某個屬性的特性,或透過 Object.defineProperties() 一次定義多個屬性的特性。
我們通常會先準備好一個物件,然後使用 Object.defineProperty 去設定特定幾個屬性的特性,如下程式範例:
// 先建立一個空物件
var obj = {};
// 然後定義該物件下的 name 屬性(Property),並設定 descriptors (敘述內容)
Object.defineProperty(obj, 'name', {
enumerable: false,
configurable: false,
writable: false,
value: undefined
});
這裡的 descriptors
其實就是一個物件!
當我們在定義屬性的時候,其描述器(descriptors)有兩種類型:
-
資料描述器 (data descriptor):用來描述特定屬性有哪些特性 (共 4 種)
configurable
- 用來設定該屬性『是否可刪除』以及『是否允許再次變更資料描述器』
enumerable
- 用來設定該屬性『是否可枚舉』( 透過
for-in
或 Object.keys()
取得物件中所有屬性)
value
- 用來設定該屬性的預設值
- 當你設定
value
的時候,就不能設定訪問描述器 (accessor descriptor),這兩者是衝突的!
writable
- 用來設定該屬性的值『是否可被修改』
- 當你設定
writable
的時候,就不能設定訪問描述器 (accessor descriptor),這兩者是衝突的!
-
存取描述器 (accessor descriptor):用來描述存取屬性的 get/set 自訂邏輯
以下是 ES2015 的範例:
class Lesson {
constructor(name) {
this.name = name;
}
}
Object.defineProperty(Lesson.prototype, 'message', {
get() { return `Hello ${this.name}`; },
enumerable: false, // 無法枚舉的屬性
configurable: false // 無法被刪除的屬性
});
以下是 ES5 的範例:
function Lesson(name) {
this.name = name;
}
Object.defineProperty(Lesson.prototype, 'message', {
get() { return 'Hello ' + this.name; },
enumerable: false, // 無法枚舉的屬性
configurable: false // 無法被刪除的屬性
});
凍結與密封物件結構
預設所有的 JavaScript 物件,所有屬性都是「可變的」(Mutable)。但你可以用 Object.freeze() 將一個物件凍結,讓該物件從此之後不能再新增或移除任何屬性,甚至不能修改屬性值!換句話說,該物件會被轉變成一種「不可變的」狀態 (Immutable)!
var a = {
name: 'Will'
};
Object.freeze(a); // 凍結物件
a.name = 'John'; // 無法變更屬性值 (不會報錯)
delete a.name; // 也無法刪除屬性 (不會報錯)
a.type = 'pp'; // 也無法新增屬性 (不會報錯)
被凍結的物件,基本上就再也無法變更,但你可以透過 Object.assign() 快速重建一個新的物件。
var a = {
name: 'Will'
};
Object.freeze(a); // 凍結物件
var b = Object.assign({}, a); // 建立一個 {} 新物件,並將 a 物件的內容全部複製過去
var c = Object.assign({}, a, {type: 'pp'}); // 重建物件並加入新屬性
JavaScript 還有一個可以密封物件的 API 叫做 Object.seal(),它跟 Object.freeze() 不一樣的地方,就是「密封物件」是可以變更屬性值的!
var a = {
name: 'Will'
};
Object.seal(a); // 密封物件
a.name = 'John'; // 可以變更屬性值!
delete a.name; // 也無法刪除屬性 (不會報錯)
a.type = 'pp'; // 也無法新增屬性 (不會報錯)
結論
在 JavaScript 中使用 Object 型別,相對來說比較隨心所欲一些,雖然容易上手,但隨著 JS 程式碼量越來越多,漸漸的就會開始產生許多問題。
因此,對常用物件進行適當的藍圖設計,不但可以藉由抽象化大幅降低程式碼的複雜度,若搭配 TypeScript 一起使用,對所有類別中的屬性、方法與參數,都能在編譯時期進行型別檢查,程式碼品質肯定會有所改善,開發效率也大幅提升! 👍
相關連結
- MDN
- Can I use
- JAVASCRIPT.INFO