物件導向程式設計是用抽象方式建立基於現實世界模型的一種程式設計模式,主要包括模組化、多型、和封裝幾種技術。對JavaScript而言,其核心是支援物件導向的,同時它也提供了強大靈活的基於原型的物件導向程式設計能力。
本文將會深入的探討有關使用JavaScript進行物件導向程式設計的一些核心基礎知識,包括物件的建立,繼承機制,最後還會簡要的介紹如何藉助ES6提供的新的類機制重寫傳統的JavaScript物件導向程式碼。
物件導向的幾個概念
在進入正題前,先了解傳統的物件導向程式設計(例如Java)中常會涉及到的概念,大致可以包括:
- 類:定義物件的特徵。它是物件的屬性和方法的模板定義。
- 物件(或稱例項):類的一個例項。
- 屬性:物件的特徵,比如顏色、尺寸等。
- 方法:物件的行為,比如行走、說話等。
- 建構函式:物件初始化的瞬間被呼叫的方法。
- 繼承:子類可以繼承父類的特徵。例如,貓繼承了動物的一般特性。
- 封裝:一種把資料和相關的方法繫結在一起使用的方法。
- 抽象:結合複雜的繼承、方法、屬性的物件能夠模擬現實的模型。
- 多型:不同的類可以定義相同的方法或屬性。
在JavaScript的物件導向程式設計中大體也包括這些。不過在稱呼上可能稍有不同,例如,JavaScript中沒有原生的“類”的概念,
而只有物件的概念。因此,隨著你認識的深入,我們會混用物件、例項、建構函式等概念。
物件(類)的建立
在JavaScript中,我們通常可以使用建構函式來建立特定型別的物件。諸如Object和Array這樣的原生建構函式,在執行時會自動出現在執行環境中。
此外,我們也可以建立自定義的建構函式。例如:
1 2 3 4 5 6 7 8 |
function Person(name, age, job) { this.name = name; this.age = age; this.job = job; } var person1 = new Person('Weiwei', 27, 'Student'); var person2 = new Person('Lily', 25, 'Doctor'); |
按照慣例,建構函式始終都應該以一個大寫字母開頭(和Java中定義的類一樣),普通函式則小寫字母開頭。
要建立Person
的新例項,必須使用new
操作符。以這種方式呼叫建構函式實際上會經歷以下4個步驟:
- 建立一個新物件(例項)
- 將建構函式的作用域賦給新物件(也就是重設了
this
的指向,this
就指向了這個新物件) - 執行建構函式中的程式碼(為這個新物件新增屬性)
- 返回新物件
有關new
操作符的更多內容請參考這篇文件。
在上面的例子中,我們建立了Person
的兩個例項person1
和person2
。
這兩個物件預設都有一個constructor
屬性,該屬性指向它們的建構函式Person
,也就是說:
1 2 |
console.log(person1.constructor == Person); //true console.log(person2.constructor == Person); //true |
自定義物件的型別檢測
我們可以使用instanceof
操作符進行型別檢測。我們建立的所有物件既是Object
的例項,同時也是Person
的例項。
因為所有的物件都繼承自Object
。
1 2 3 4 |
console.log(person1 instanceof Object); //true console.log(person1 instanceof Person); //true console.log(person2 instanceof Object); //true console.log(person2 instanceof Person); //true |
建構函式的問題
我們不建議在建構函式中直接定義方法,如果這樣做的話,每個方法都要在每個例項上重新建立一遍,這將非常損耗效能。
——不要忘了,ECMAScript中的函式是物件,每定義一個函式,也就例項化了一個物件。
幸運的是,在ECMAScript中,我們可以藉助原型物件來解決這個問題。
藉助原型模式定義物件的方法
我們建立的每個函式都有一個prototype
屬性,這個屬性是一個指標,指向該函式的原型物件,
該物件包含了由特定型別的所有例項共享的屬性和方法。也就是說,我們可以利用原型物件來讓所有物件例項共享它所包含的屬性和方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
function Person(name, age, job) { this.name = name; this.age = age; this.job = job; } // 通過原型模式來新增所有例項共享的方法 // sayName() 方法將會被Person的所有例項共享,而避免了重複建立 Person.prototype.sayName = function () { console.log(this.name); }; var person1 = new Person('Weiwei', 27, 'Student'); var person2 = new Person('Lily', 25, 'Doctor'); console.log(person1.sayName === person2.sayName); // true person1.sayName(); // Weiwei person2.sayName(); // Lily |
正如上面的程式碼所示,通過原型模式定義的方法sayName()
為所有的例項所共享。也就是,
person1
和person2
訪問的是同一個sayName()
函式。同樣的,公共屬性也可以使用原型模式進行定義。例如:
1 2 3 4 5 |
function Chinese (name) { this.name = name; } Chinese.prototype.country = 'China'; // 公共屬性,所有例項共享 |
原型物件
現在我們來深入的理解一下什麼是原型物件。
只要建立了一個新函式,就會根據一組特定的規則為該函式建立一個prototype
屬性,這個屬性指向函式的原型物件。
在預設情況下,所有原型物件都會自動獲得一個constructor
屬性,這個屬性包含一個指向prototype
屬性所在函式的指標。
也就是說:Person.prototype.constructor
指向Person
建構函式。
建立了自定義的建構函式之後,其原型物件預設只會取得constructor
屬性;至於其他方法,則都是從Object
繼承而來的。
當呼叫建構函式建立一個新例項後,該例項內部將包含一個指標(內部屬性),指向建構函式的原型物件。ES5中稱這個指標為[[Prototype]]
,
在Firefox、Safari和Chrome在每個物件上都支援一個屬性__proto__
(目前已被廢棄);而在其他實現中,這個屬性對指令碼則是完全不可見的。
要注意,這個連結存在於例項與建構函式的原型物件之間,而不是例項與建構函式之間。
這三者關係的示意圖如下:
上圖展示了Person
建構函式、Person
的原型物件以及Person
現有的兩個例項之間的關係。
Person.prototype
指向了原型物件Person.prototype.constructor
又指回了Person
建構函式Person
的每個例項person1
和person2
都包含一個內部屬性(通常為__proto__
),person1.__proto__
和person2.__proto__
指向了原型物件
查詢物件屬性
從上圖我們發現,雖然Person
的兩個例項都不包含屬性和方法,但我們卻可以呼叫person1.sayName()
。
這是通過查詢物件屬性的過程來實現的。
- 搜尋首先從物件例項本身開始(例項
person1
有sayName
屬性嗎?——沒有) - 如果沒找到,則繼續搜尋指標指向的原型物件(
person1.__proto__
有sayName
屬性嗎?——有)
這也是多個物件例項共享原型所儲存的屬性和方法的基本原理。
注意,如果我們在物件的例項中重寫了某個原型中已存在的屬性,則該例項屬性會遮蔽原型中的那個屬性。
此時,可以使用delete
操作符刪除例項上的屬性。
Object.getPrototypeOf()
根據ECMAScript標準,someObject.[[Prototype]]
符號是用於指派 someObject
的原型。
這個等同於 JavaScript 的 __proto__
屬性(現已棄用)。
從ECMAScript 5開始, [[Prototype]]
可以用Object.getPrototypeOf()
和Object.setPrototypeOf()
訪問器來訪問。
其中Object.getPrototypeOf()
在所有支援的實現中,這個方法返回[[Prototype]]
的值。例如:
1 2 |
person1.__proto__ === Object.getPrototypeOf(person1); // true Object.getPrototypeOf(person1) === Person.prototype; // true |
也就是說,Object.getPrototypeOf(p1)
返回的物件實際就是這個物件的原型。
這個方法的相容性請參考該連結。
Object.keys()
要取得物件上所有可列舉的例項屬性,可以使用ES5中的Object.keys()
方法。例如:
1 |
Object.keys(p1); // ["name", "age", "job"] |
此外,如果你想要得到所有例項屬性,無論它是否可列舉,都可以使用Object.getOwnPropertyName()
方法。
更簡單的原型語法
在上面的程式碼中,如果我們要新增原型屬性和方法,就要重複的敲一遍Person.prototype
。為了減少這個重複的過程,
更常見的做法是用一個包含所有屬性和方法的物件字面量來重寫整個原型物件。
參考資料。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
function Person(name, age, job) { this.name = name; this.age = age; this.job = job; } Person.prototype = { // 這裡務必要重新將建構函式指回Person建構函式,否則會指向這個新建立的物件 constructor: Person, // Attention! sayName: function () { console.log(this.name); } }; var person1 = new Person('Weiwei', 27, 'Student'); var person2 = new Person('Lily', 25, 'Doctor'); console.log(person1.sayName === person2.sayName); // true person1.sayName(); // Weiwei person2.sayName(); // Lily |
在上面的程式碼中特意包含了一個constructor
屬性,並將它的值設定為Person
,從而確保了通過該屬效能夠訪問到適當的值。
注意,以這種方式重設constructor
屬性會導致它的[[Enumerable]]
特性設定為true
。預設情況下,原生的constructor
屬性是不可列舉的。
你可以使用Object.defineProperty()
:
1 2 3 4 5 |
// 重設建構函式,只適用於ES5相容的瀏覽器 Object.defineProperty(Person.prototype, "constructor", { enumerable: false, value: Person }); |
組合使用建構函式模式和原型模式
建立自定義型別的最常見方式,就是組合使用建構函式模式與原型模式。建構函式模式用於定義例項屬性,
而原型模式用於定義方法和共享的屬性。結果,每個例項都會有自己的一份例項屬性的副本,但同時又共享著對方的引用,
最大限度的節省了記憶體。
繼承
大多的面嚮物件語言都支援兩種繼承方式:介面繼承和實現繼承。ECMAScript只支援實現繼承,而且其實現繼承主要依靠原型鏈來實現。
原型鏈繼承
使用原型鏈作為實現繼承的基本思想是:利用原型讓一個引用型別繼承另一個引用型別的屬性和方法。首先我們先回顧一些基本概念:
- 每個建構函式都有一個原型物件(
prototype
) - 原型物件包含一個指向建構函式的指標(
constructor
) - 例項都包含一個指向原型物件的內部指標(
[[Prototype]]
)
如果我們讓原型物件等於另一個型別的實現,結果會怎麼樣?顯然,此時的原型物件將包含一個指向另一個原型的指標,
相應的,另一個原型中也包含著一個指向另一個建構函式的指標。假如另一個原型又是另一個型別的例項,那麼上述關係依然成立,
如此層層遞進,就構成了例項與原型的鏈條。
更詳細的內容可以參考這個連結。
先看一個簡單的例子,它演示了使用原型鏈實現繼承的基本框架:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
function Father () { this.fatherValue = true; } Father.prototype.getFatherValue = function () { console.log(this.fatherValue); }; function Child () { this.childValue = false; } // 實現繼承:繼承自Father Child.prototype = new Father(); Child.prototype.getChildValue = function () { console.log(this.childValue); }; var instance = new Child(); instance.getFatherValue(); // true instance.getChildValue(); // false |
在上面的程式碼中,原型鏈繼承的核心語句是Child.prototype = new Father()
,它實現了Child
對Father
的繼承,
而繼承是通過建立Father
的例項,並將該例項賦給Child.prototype
實現的。
實現的本質是重寫原型物件,代之以一個新型別的例項。也就是說,原來存在於Father
的例項中的所有屬性和方法,
現在也存在於Child.prototype
中了。
這個例子中的例項以及建構函式和原型之間的關係如下圖所示:
在上面的程式碼中,我們沒有使用Child
預設提供的原型,而是給它換了一個新原型;這個新原型就是Father
的例項。
於是,新原型不僅具有了作為一個Father
的例項所擁有的全部屬性和方法。而且其內部還有一個指標[[Prototype]]
,指向了Father
的原型。
instance
指向Child
的原型物件Child
的原型物件指向Father
的原型物件getFatherValue()
方法仍然還在Father.prototype
中- 但是,
fatherValue
則位於Child.prototype
中 instance.constructor
現在指向的是Father
因為fatherValue
是一個例項屬性,而getFatherValue()
則是一個原型方法。既然Child.prototype
現在是Father
的例項,
那麼fatherValue
當然就位於該例項中。
通過實現原型鏈,本質上擴充套件了本章前面介紹的原型搜尋機制。例如,instance.getFatherValue()
會經歷三個搜尋步驟:
- 搜尋例項
- 搜尋
Child.prototype
- 搜尋
Father.prototype
別忘了Object
所有的函式都預設原型都是Object
的例項,因此預設原型都會包含一個內部指標[[Prototype]]
,指向Object.prototype
。
這也正是所有自定義型別都會繼承toString()
、valueOf()
等預設方法的根本原因。所以,
我們說上面例子展示的原型鏈中還應該包括另外一個繼承層次。關於Object
的更多內容,可以參考這篇部落格。
也就是說,Child
繼承了Father
,而Father
繼承了Object
。當呼叫了instance.toString()
時,
實際上呼叫的是儲存在Object.prototype
中的那個方法。
原型鏈繼承的問題
首先是順序,一定要先繼承父類,然後為子類新增新方法。
其次,使用原型鏈實現繼承時,不能使用物件字面量建立原型方法。因為這樣做就會重寫原型鏈,如下面的例子所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
function Father () { this.fatherValue = true; } Father.prototype.getFatherValue = function () { console.log(this.fatherValue); }; function Child () { this.childValue = false; } // 繼承了Father // 此時的原型鏈為 Child -> Father -> Object Child.prototype = new Father(); // 使用字面量新增新方法,會導致上一行程式碼無效 // 此時我們設想的原型鏈被切斷,而是變成 Child -> Object Child.prototype = { getChildValue: function () { console.log(this.childValue); } }; var instance = new Child(); instance.getChildValue(); // false instance.getFatherValue(); // error! |
在上面的程式碼中,我們連續兩次修改了Child.prototype
的值。由於現在的原型包含的是一個Object
的例項,
而非Father
的例項,因此我們設想中的原型鏈已經被切斷——Child
和Father
之間已經沒有關係了。
最後,在建立子型別的例項時,不能向超型別的建構函式中傳遞引數。實際上,應該說是沒有辦法在不影響所有物件例項的情況下,
給超型別的建構函式傳遞引數。因此,我們很少單獨使用原型鏈。
借用建構函式繼承
借用建構函式(constructor stealing)的基本思想如下:即在子類建構函式的內部呼叫超型別建構函式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function Father (name) { this.name = name; this.colors = ['red', 'blue', 'green']; } function Child (name) { // 繼承了Father,同時傳遞了引數 Father.call(this, name); } var instance1 = new Child("weiwei"); instance1.colors.push('black'); console.log(instance1.colors); // [ 'red', 'blue', 'green', 'black' ] console.log(instance1.name); // weiwei var instance2 = new Child("lily"); console.log(instance2.colors); // [ 'red', 'blue', 'green' ] console.log(instance2.name); // lily |
為了確保Father
建構函式不會重寫子型別的屬性,可以在呼叫超型別建構函式後,再新增應該在子型別中定義的屬性。
借用建構函式的缺點
同建構函式一樣,無法實現方法的複用。
組合使用原型鏈和借用建構函式
通常,我們會組合使用原型鏈繼承和借用建構函式來實現繼承。也就是說,使用原型鏈實現對原型屬性和方法的繼承,
而通過借用建構函式來實現對例項屬性的繼承。這樣,既通過在原型上定義方法實現了函式複用,又能夠保證每個例項都有它自己的屬性。
我們改造最初的例子如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
// 父類建構函式 function Person (name, age, job) { this.name = name; this.age = age; this.job = job; } // 父類方法 Person.prototype.sayName = function () { console.log(this.name); }; // -------------- // 子類建構函式 function Student (name, age, job, school) { // 繼承父類的所有例項屬性 Person.call(this, name, age, job); this.school = school; // 新增新的子類屬性 } // 繼承父類的原型方法 Student.prototype = new Person(); // 新增的子類方法 Student.prototype.saySchool = function () { console.log(this.school); }; var person1 = new Person('Weiwei', 27, 'Student'); var student1 = new Student('Lily', 25, 'Doctor', "Southeast University"); console.log(person1.sayName === student1.sayName); // true person1.sayName(); // Weiwei student1.sayName(); // Lily student1.saySchool(); // Southeast University |
組合整合避免了原型鏈和借用建構函式的缺陷,融合了它們的優點,成為了JavaScript中最常用的繼承模式。
而且,instanceof
和isPropertyOf()
也能夠用於識別基於組合繼承建立的物件。
組合繼承的改進版:使用Object.create()
在上面,我們繼承父類的原型方法使用的是Student.prototype = new Person()
。
這樣做有很多的問題。
改進方法是使用ES5中新增的Object.create()
。可以呼叫這個方法來建立一個新物件。新物件的原型就是呼叫create()
方法傳入的第一個引數:
1 2 3 4 5 6 |
Student.prototype = Object.create(Person.prototype); console.log(Student.prototype.constructor); // [Function: Person] // 設定 constructor 屬性指向 Student Student.prototype.constructor = Student; |
詳細用法可以參考文件。
關於Object.create()
的實現,我們可以參考一個簡單的polyfill:
1 2 3 4 5 6 7 8 |
function createObject(proto) { function F() { } F.prototype = proto; return new F(); } // Usage: Student.prototype = createObject(Person.prototype); |
從本質上講,createObject()
對傳入其中的物件執行了一次淺複製。
ES6中的物件導向語法
ES6中引入了一套新的關鍵字用來實現class。
JavaScript仍然是基於原型的,這些新的關鍵字包括class、
constructor、
static、
extends、
和super。
對前面的程式碼修改如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
'use strict'; class Person { constructor (name, age, job) { this.name = name; this.age = age; this.job = job; } sayName () { console.log(this.name); } } class Student extends Person { constructor (name, age, school) { super(name, age, 'Student'); this.school = school; } saySchool () { console.log(this.school); } } var stu1 = new Student('weiwei', 20, 'Southeast University'); var stu2 = new Student('lily', 22, 'Nanjing University'); stu1.sayName(); // weiwei stu1.saySchool(); // Southeast University stu2.sayName(); // lily stu2.saySchool(); // Nanjing University |
類:class
是JavaScript中現有基於原型的繼承的語法糖。ES6中的類並不是一種新的建立物件的方法,只不過是一種“特殊的函式”,
因此也包括類表示式和類宣告,
但需要注意的是,與函式宣告不同的是,類宣告不會被提升。
參考連結
類構造器:constructor
constructor()
方法是有一種特殊的和class
一起用於建立和初始化物件的方法。注意,在ES6類中只能有一個名稱為constructor
的方法,
否則會報錯。在constructor()
方法中可以呼叫super
關鍵字呼叫父類構造器。如果你沒有指定一個構造器方法,
類會自動使用一個預設的構造器。參考連結
類的靜態方法:static
靜態方法就是可以直接使用類名呼叫的方法,而無需對類進行例項化,當然例項化後的類也無法呼叫靜態方法。
靜態方法常被用於建立應用的工具函式。參考連結
繼承父類:extends
extends
關鍵字可以用於繼承父類。使用extends
可以擴充套件一個內建的物件(如Date
),也可以是自定義物件,或者是null
。
關鍵字:super
super
關鍵字用於呼叫父物件上的函式。
super.prop
和super[expr]
表示式在類和物件字面量中的任何方法定義中都有效。
1 2 |
super([arguments]); // 呼叫父類構造器 super.functionOnParent([arguments]); // 呼叫父類中的方法 |
如果是在類的構造器中,需要在this
關鍵字之前使用。參考連結
小結
本文對JavaScript的物件導向機制進行了較為深入的解讀,尤其是建構函式和原型鏈方式實現物件的建立、繼承、以及例項化。
此外,本文還簡要介紹瞭如在ES6中編寫物件導向程式碼。