跳至主要内容

[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 需要繼承自兩個以上的類別(例如,PersonAnimal),那麼我們可以使用 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 中我們可以設定 gettersetter 方法,**透過 gettersetter 所存取的屬性會被放在物件實例的原型(__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';

參考