[JS] 物件導向 JavaScript (object-oriented JavaScript)
Object-oriented JavaScript for beginners @ MDN > Learn web development
keywords: object-oriented programming
常用
// 取得某一物件的原型,使用 Object.getPrototypeOf()
Object.getPrototypeOf(obj); // 等同於過去的 obj.__proto__
// 檢驗某一屬性是否是該物件自己本身的,而非原型內的,使用 obj.hasOwnProperty(<property>)
obj.hasOwnProperty('foobar'); // 檢驗 obj 內有無名為 'foobar' 的屬性
// 檢驗某物件是否為某類別的實例 instanceof
obj instanceof Class;
物件導向程式設計(object-oriented programming)
keywords: 封裝(encapsulated)
, 命名空間(namespace)
, 物件實例(object instance)
, 類別(class)
, 建構子函式(constructor function)
, 實例化(instantiation)
物件導向程式設計(OOP)的基本概念是使用物件的方式來表徵,並模擬真實世界中的事物。透過類別(class)的函式建構式(function constructor)可以產生物件實例(object instance),而這個從類別產生物件實例的過程稱作實例化(instantiation)。
不同的類別之間可以有繼承關係,例如 Student 和 Teacher 的類別都可以繼承自 Person 這個類別,如此 Student 和 Teacher 共通的屬性都可以繼承自 Person 中的「姓名」、「年齡」等屬性,都可以也可以保有該類別底下特有的部分。在多個不同的類別中建置相同的方法,但方法的內容不同,稱作「多型 (Polymorphism)」。
JavaScript 中的物件導向
JavaScript 中使用一種名為「建構式函式(constructor functions)」的函式來定義物件和它們的特色,透過函式建構式,將可以根據你的需要用更有效率的方式來產生許多物件,而這些物件都已經包含你所定義好的屬性和方法。
JavaScript 慣例上來說,函式建構式的命名會以大寫的字母開頭,如此可以更容易在程式中辨認出哪個函式是屬於建構式函式。
原型架構的程式語言
keywords: 原型鏈(prototype chain)
, Object.getPrototypeOf(obj)
, __proto__
此外,JavaScript 被稱作是「原型架構(prototype-based)」的程式語言,在每一個 JavaScript 的物件中,都包含了一個原型物件(prototype object)可以當作該物件的模版,用來讓 JavaScript 的物件可以從原型物件中繼承屬性和方法,而原型物件本身可能又會有屬於它的原型物件,而這就是我們所稱的「原型鏈(prototype chain)」。更精確的說,這些原型物件中的屬性和方法是透過函式建構式定義的,而不是直接定義在物件實例上的。
很重要的是去區分物件的原型物件(prototype object)和定義函式建構的式的原型。某一物件的原型我們可以透過 Object.getPrototypeOf(obj)
或只透過即將被移除的 __proto__
屬性來存取;而函式建構式的原型則是透過該建構式的 prototype
屬性來存取,例如 Foobar.prototype
。
由於在 JavaScript 中是透過原型鏈來實做繼承的方式,在不同物件之間函式共享的行為被稱作 委託(delegation),即特定物件將功能委託至通用物件類型。「委託」其實比繼承更精確一點。因為「所繼承的功能」並不會複製到「進行繼承的物件」之上,卻是保留在通用物件之中。
函式建構式的 constructor 屬性
keywords: instanceObj.constructor
, instanceObj.constructor.name
每一個建構式函式的 prototype
物件都包含一個名為 constructor
的屬性,而這個 constructor
屬性會指稱到建立該實例的建構式函式,因此你甚至可以用某一物件實例來產生該類別的新的物件實例,例如:
function Person(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
var aaron = new Person('Aaron', 'Chen');
// aaron.constructor 會指稱回 Person 這個函式建構式,
// 因此可以透過它產生其他的物件實例
var john = new aaron.constructor('John', 'Liang');
var constructorName = aaron.constructor.name; // 取得建構式的名稱
我們不常透過一個物件實例來產生另一個物件實例,但是當你沒辦法參照到原本的 function constructor 時,這麼做非常方便。
修改 prototype
修改 prototype 中的方法(function)
我們可以透過 Foobar.prototype
的方式來修改某個 function constructor 的 prototype
的內容,要留意的是-當你改變了該函式建構式中 prototype
內的方法,那麼所有根據這個建構式所建立的實例(不管是修改 prototype
之前或之後所建立的實例),在使用這個方法時,都會用到更新 prototype
後的方法:
function Person(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
// 定義一開始的 greet 方法
Person.prototype.greet = function () {
console.log(`Hello, ${this.firstName} ${this.lastName}`);
};
var aaron = new Person('Aaron', 'Chen');
var john = new aaron.constructor('John', 'Liang');
aaron.greet(); // Hello, Aaron Chen
// 修改原本的 greet 方法
Person.prototype.greet = function () {
console.log(`Welcome, ${this.firstName} ${this.lastName}`);
};
aaron.greet(); // Welcome, Aaron Chen
john.greet(); // Welcome, John Liang
當你改變了該函式建構式中
prototype
內的方法,那麼所有根據這個建構式所建立的實例(不管是修改prototype
之前或之後所建立的實例),在使用這個方法時,都會用到更新prototype
後的方法。
修改 prototype 中的屬性
我們很少直接在 prototype
定義屬性,除非它是在所有實例中都不會改變的常數:
Person.prototype.legalAge = 18;
你無法在建構式的 prototype
中透過 this
取得該物件的內容,因為這時候的 this
指稱到的會是全域物件(global object),因此你不能這麼做:
// 這麼做是錯誤的!!
Person.prototype.fullName = this.firstName + ' ' + this.lastName; // undefined undefined
JavaScript 中的繼承
keywords: Object.getOwnPropertyNames(obj)
原型鏈繼承:物件實例會影響到父層屬性
先建立父層類別 Person
和子層類別 Student
:
// Parent Classes
function Person(name, age) {
this.name = name || 'default';
this.age = age || 0;
this.interest = ['reading', 'music'];
}
Person.prototype.greet = function () {
console.log(`My name is ${this.name}. I am ${this.age} years old.`);
};
// Child Classes
function Student(name) {
this.name = name;
this.score = 80;
}
接著透過 Student
的原型鏈(Student.prototype = new Person();
),就可以讓 Student
繼承 Person
的內容:
// 讓 Student 繼承 Person
Student.prototype = new Person();
// 增加 Student 自己的方法
Student.prototype.say = function () {
console.log(`My name is ${this.name}. I am studying.`);
};
使用子類別 Student
:
var aaron = new Student('Aaron');
console.log(aaron.name); // Aaron --子類覆蓋父類的屬性
console.log(aaron.interest); // [ 'reading', 'music' ] --父類的屬性
console.log(aaron.score); // 80 --子類自己的屬性
aaron.greet(); // My name is ... --繼承自父類的方法
aaron.say(); // I am studying --子類自己的方法
var lucy = new Student('Lucy');
console.log(lucy.interest); // [ 'reading', 'music' ]
這麼做雖然乍看沒有太大問題,但是由於 interest
這個屬性是定義在父層元素,而父層元素是會被子層元素所影響,因此如果這樣寫:
// 因為在 aaron 物件中並沒有 interest 屬性
// 因此會找到的是 aaron.__proto__.interest
// 因此這樣寫會修改到父層的屬性
aaron.interest.push('basketball');
所以,雖然我們是想修改 aaron.interest
卻連帶的影響到的 lucy.interest
console.log(aaron.interest); // [ 'reading', 'music', 'basketball' ]
console.log(lucy.interest); // [ 'reading', 'music', 'basketball' ]
使用
Student.prototype = new Person();
的作法與透過Object.create()
的方法類似,兩者的差別主要在於Object.create()
內的參數不一定要是 function constructor。
建構式函式繼承:物件實例不會影響到父層屬性
為了避免子類別的實例共享父類別屬性的問題,我們可以使用 Person.call(this)
的用法:
// Parent Class
function Person(name, age) {
this.name = name || 'default';
this.age = age || 0;
this.interest = ['reading', 'music'];
}
// Child Class
function Student(name, age, score) {
// 把 Person 裡面的 this 指稱對象改成當前透過 Student 建構式所建立的物件實例
Person.call(this, name, age); // 只需填寫想延伸進 Student 的屬性
this.score = score;
}
在
Person.call()
當中,不一定要填入Person
中所有的屬性,而是只需使用想延伸到Student
的屬性即可。
這時候等於是把 Person
建構式的內容複製了一份到 Student
當中:
function Student(name, age, score) {
// 等同於把原本 Person 的內容複製到 Student 中
this.name = name || 'default';
this.age = age || 0;
this.interest = ['reading', 'music'];
this.score = score;
}
這時候它們就不會共享到父層的屬性了:
aaron = new Student('aaron', 28, 80);
lucy = new Student('lucy', 29, 88);
aaron.interest.push('basketball');
console.log(aaron.interest); // ["reading", "music", "basketball"]
console.log(lucy.interest); // ["reading", "music"]
組合繼承
同時結合「原型鏈繼承」和「建構式函式繼承」的方式:
// 父類別
function Person(name) {
this.name = name || 'default';
this.interest = ['reading', 'music'];
}
Person.prototype.say = function () {
console.log(`Hello, I am Person. My name is ${this.name}`);
};
// 子類別
function Student(name, score) {
Person.call(this, name); // 建構式函式繼承(繼承屬性)
this.score = score;
}
Student.prototype = new Person(); // 原型鏈繼承(繼承方法)
aaron = new Student('aaron', 80);
lucy = new Student('lucy', 88);
aaron.interest.push('basketball');
console.log(aaron.interest); // ["reading", "music", "basketball"]
console.log(lucy.interest); // ["reading", "music"]
然而,這麼做還是有些問題:
Person 這個建構式函式會被呼叫到兩次
由於子類別為了繼承父類別的屬性使用了 Person.call()
,另外子類別為了繼承父類別的方法又使用了 Student.prototype = new Person()
,使得 Person
被重複呼叫了兩次,為了解決這個問題,我們可以使用 Object.create()
方法:
Student.prototype = Object.create(Person.prototype);
// Student.prototype.__proto__ === Person.prototype; // true
透過這種寫法 Person.prototype
當中的方法,會被放到 Student.prototype.__proto__
當中可以被使用到。
Object.create( ) @ MDN - Web technology for developers
指稱到錯誤的 constructor
當我們使用 Student
產生物件實例時,constructor
會錯誤的指稱到 Person
而非 Student
:
// 指稱到錯誤的 constructor.name
console.log(aaron.constructor.name); // Person
因此一般來說,我們會把原本的 constructor
給回去:
// 把原本正確的 constructor 給回去
Student.prototype.constructor = Student;
因此最後完整的繼承可以寫成:
// 父類別
function Person(name) {
console.log('Person init');
this.name = name || 'default';
this.interest = ['reading', 'music'];
}
Person.prototype.say = function () {
console.log(`Hello, I am Person. My name is ${this.name}`);
};
// 子類別
function Student(name, score) {
Person.call(this, name); // 建構式函式繼承(繼承屬性)
this.score = score;
}
Student.prototype = Object.create(Person.prototype); // 原型鏈繼承(繼承方法)
Student.prototype.constructor = Student;
Student.prototype.say = function () {
console.log(`My name is ${this.name}. I'm a student.`);
};
aaron = new Student('aaron', 80);
lucy = new Student('lucy', 88);
// 子類別可以使用父類別的方法
aaron.say(); // My name is aaron. I'm a student.
// 子類別不會共享到父類別的屬性
aaron.interest.push('basketball');
console.log(aaron.interest); // ["reading", "music", "basketball"]
console.log(lucy.interest); // ["reading", "music"]
繼承自兩個以上的類別
如果我們的 Student
需要繼承自兩個以上的類別(例如,Person
和 Animal
),那麼我們可以使用 Object.assign()
來將多個類別的方法延伸至 Student
內:
/**
* 讓 Student 同時繼承 Person 和 Animal 兩個類別
**/
function Student() {
Person.call(this);
Animal.call(this);
}
// inherit one class
Student.prototype = Object.create(Person.prototype);
// mixin another
Object.assign(Student.prototype, Animal.prototype);
// re-assign constructor
Student.prototype.constructor = Student;
Student.prototype.selfMethod = function () {
// do something
};
Object.create @ MDN - Web technology for developers
JavaScript ES6 中的關鍵字 class
使用 class 建立建構式函式
在 ES6 中引入了 class
這個語法,可以更類似 classical inheritance 的方式來撰寫繼承,但要留意的是 JavaScript 的本質仍然是 prototypal inheritance,這個 class 只是個語法糖:
class Person {
constructor(name, interests) {
this.name = name;
this.interests = interests;
}
greeting() {
console.log(`Hi! I'm ${this.name}.`);
}
}
使用 class 這個關鍵字表示我們要定義一個新的類別,在這個 block {}
中會定義這個 class 要有的功能:
- 透過
constructor()
這個方法可以定義建構式函式要在Person
類別中使用到的屬性。 - 裡面的
greeting()
屬於類別方法(class methods),任何和這個類別有關的方法都可以定義在這裡。
接著我們一樣可以透過 new
運算子來實例化一個物件:
aaron = new Person('aaron', ['computer science']);
aaron.greeting();
❗ 在 JavaScript 中,
class
的用法本質上只是個語法糖,底層的運作仍然是透過原型繼承(prototypal inheritance)的方式在運作。
使用 class 繼承建構式函式
接著我們可以建立一個 Teacher
類別,讓它是繼承 Person
而來,而這個稱作建立一個子類別(subclass)或稱作 subclassing。
要建立子類別可以使用 extends
這個關鍵字來告訴 JavaScript 現在建立的這個類別要根據哪個類別而來;透過 super()
這個關鍵字來說明要繼承哪些屬性進去:
class Teacher extends Person {
constructor(name, interests, subject) {
// 繼承字 Person 的屬性
super(name, interests);
// subject 是在 Teacher 類別中才有的屬性
this.subject = subject;
}
}
這時候我們就可以在 Teacher
的實例物件中使用到 Person
的方法:
let aaron = new Teacher('aaron', ['baseball'], 'psychology');
aaron.greeting();
使用 getter 和 setter
在 class 中我們可以設定 getter
和 setter
方法,**透過 getter
和 setter
所存取的屬性會被放在物件實例的原型(__proto__
)中:
class Teacher extends Person {
constructor(name, interests, subject) {
super(name, interests);
this._subject = subject;
}
// 這個 subject 會被放在物件實例的原型 __proto__ 中
get subject() {
return this._subject;
}
set subject(newSubject) {
this._subject = newSubject;
}
}
❗ 習慣上,對於只有區域內可以存取到的變數我們會以
_
開頭來命名。
let aaron = new Teacher('aaron', ['baseball'], 'psychology');
console.log(aaron.subject); // 'psychology'
aaron.subject = 'Computer Science';
參考
- Object-oriented JavaScript for beginners @ MDN > Learn web development:解釋物件導向的基本概念
- Object Prototypes @ MDN > Learn web development:說明 JavaScript 中的原型鏈,以及如何修改 prototype
- Inheritance in JavaScript @ MDN > Learn web development:說明實做 JavaScript 繼承的方式、Class 的使用、物件導向的使用時機
- Inheritance and the prototype chain @ MDN > Web technology for developers
- 重新認識 JavaScript: Day 24 物件與原型鏈 @ iThome 鐵人賽
- 一篇文章理解 JS 繼承—原型鏈/構造函數/組合/原型式/寄生式/寄生組合/Class extends @ SegmentFault