在JavaScript中,對象的創建可以脫離類型(class free),通過字面量的方式可以很方便的創建出自定義對象。
另外,JavaScript中擁有原型這個強大的概念,當對象進行屬性查找的時候,如果對象本身內找不到對應的屬性,就會去搜索原型鏈。所以,結合原型和原型鏈的這個特性,JavaScript就可以用來實現對象之間的繼承了。
下面就介紹一下JavaScript中的一些常用的繼承方式。
原型鏈繼承
由於原型鏈搜索的這個特性,在JavaScript中可以很方便的通過原型鏈來實現對象之間的繼承。
下面看一個例子:
function Person(name, age){ this.name = name; this.age = age; } Person.prototype.getInfo = function(){ console.log(this.name + " is " + this.age + " years old!"); } function Teacher(staffId){ this.staffId = staffId; } Teacher.prototype = new Person(); var will = new Teacher(1000); will.name = "Will"; will.age = 28; will.getInfo(); // Will is 28 years old! console.log(will instanceof Object); // true console.log(will instanceof Person); // true console.log(will instanceof Teacher); // true console.log(Object.prototype.isPrototypeOf(will)); // true console.log(Person.prototype.isPrototypeOf(will)); // true console.log(Teacher.prototype.isPrototypeOf(will)); // true
在這個例子中,有兩個構造函數"Person"和"Teacher",通過"Teacher.prototype = new Person()"語句創建了一個"Person"對象,並且設置為"Teacher"的原型。
通過這種方式,就實現了"Teacher"繼承"Person","will"這個對象可以成功的調用"getInfo"這個屬於"Person"的方法。
在這個例子中,還演示了通過"instanceof"操作符和"isPrototypeOf()"方法來查看對象和原型之間的關系。
對於原型鏈繼承,下面看看其中的一些細節問題。
constructor屬性
對於所有的JavaScript原型對象,都有一個"constructor"屬性,該屬性對應用來創建對象的構造函數。
對於"constructor"這個屬性,最大的作用就是可以幫我們標明一個對象的"類型"。
在JavaScript中,當通過"typeof"查看Array對象的時候,返回的結果是"object"。這個我們的預期結果,所以如果要判對一個對象到底是不是Array類型,就可以結合"constructor"屬性得到想要的結果。
function isArray(myArray) { return myArray.constructor.toString().indexOf("Array") > -1; } var arr = [] console.log(typeof arr); // object console.log(isArray(arr)); // true
現在回到前面的例子,查看一下對象"will"的原型和構造函數:

從這個結果可以看到,"will"的原型是"Person {name: undefined, age: undefined}"(通過new Person()構造出來的對象),"will"的構造函數是"function Person"。
等等,"will"不是通過"Teacher"創建出來的對象么?為什么構造函數對於的是"function Person",而不是"function Teacher"?
下面,根據前面的例子繪制一張對象關系圖,從而分析一下繼承關系以及"constructor"屬性:

圖中給出了各種對象之間的關系,有幾點需要注意的是:
- "Teacher.prototype"這個原型對象是通過"Person"構造函數創建出來的一個對象"Person {name: undefined, age: undefined}"
- 對象"will"創建了自己的"name"和"age"屬性,並沒有使用父類對象的,而是覆蓋了父類的"name"和"age"屬性
-
通過"will"訪問"constructor"這個屬性的時候,先找到了"Teacher.prototype"這個對象,然后找到"Person.prototype", 通過原型鏈查找訪問到了"constructor"屬性對應的"function Person"
重設constructor
為了解決上面的問題,讓子類對象的"constructor"屬性對應正確的構造函數,我們可以重設子類原型對象的"constructor"屬性。
一般來說,可以簡單的使用下面代碼來重設"constructor"屬性:
Teacher.prototype.constructor = Teacher;
但是通過這種方式重設"constructor"屬性會導致它的[[Enumerable]]特性被設置為 true。默認情況下,原生的"constructor"屬性是不可枚舉的。
因此如果使用兼容 ECMAScript 5 的 JavaScript 引擎,就可以使用"Object.defineProperty()":
Object.defineProperty(Teacher.prototype, "constructor", { enumerable: false, value: Teacher });
通過下面的結果可以看到:

通過這個設置,對象"will" 的"constructor"屬性就指向了正確的"function Teacher"。
這時的對象關系圖就變成了如下,跟前面的關系圖比較,唯一的區別就是"Teacher.prototype"對象多了一個"constructor"屬性,並且這個屬性指向"function Teacher":

原型的動態性
原型對象是可以修改的,所以,當創建了繼承關系之后,我們可以通過更新子類的原型對象給子類添加特有的方法。
例如通過下面的方式就給子類添加了一個特有的"getId"方法。
Teacher.prototype.getId = function(){ console.log(this.name + "'s staff Id is " + this.staffId); } will.getId(); // Will's staff Id is 1000
但是,一定要區分原型的修改和原型的重寫。如果對原型進行了重寫,就會產生完全不同的效果。
下面看看如果對"Teacher"的原型重寫會產生什么效果,為了分清跟前面代碼的順序,這里貼出了完整的代碼:
function Person(name, age){ this.name = name; this.age = age; } Person.prototype.getInfo = function(){ console.log(this.name + " is " + this.age + " years old!"); } function Teacher(staffId){ this.staffId = staffId; } Teacher.prototype = new Person(); Object.defineProperty(Teacher.prototype, "constructor", { enumerable: false, value: Teacher }); var will = new Teacher(1000); will.name = "Will"; will.age = 28;
// 更新原型 Teacher.prototype.getId = function(){ console.log(this.name + "'s staff Id is " + this.staffId); } will.getId(); // Will's staff Id is 1000
// 重寫原型 Teacher.prototype = { getStaffId: function(){ console.log(this.name + "'s staff Id is " + this.staffId); } } will.getInfo(); // Will is 28 years old! will.getId(); // Will's staff Id is 1000 console.log(will.__proto__); // Person {name: undefined, age: undefined} console.log(will.__proto__.constructor); // function Teacher var wilber = new Teacher(1001); wilber.name = "Wilber"; wilber.age = 28; // wilber.getInfo(); // Uncaught TypeError: wilber.getInfo is not a function(…) wilber.getStaffId(); // Wilber's staff Id is 1001 console.log(wilber.__proto__); // Object {} console.log(wilber.__proto__.constructor); // function Object() { [native code] }
經過重寫原型之后情況更加復雜了,下面就來看看重寫原型之后的對象關系圖:

從關系圖可以看到:
- 原型對象可以被更新,通過"Teacher.prototype.getId"給"will"對象的原型添加了"getId"方法
- 重寫原型之后,在重寫原型之前創建的對象的"[[prototype]]"屬性依然指向原來的原型對象;在重寫原型之后創建的對象的"[[prototype]]"屬性將指向新的原型對象
- 對於重寫原型前后創建的兩種對象,對象的屬性查找將搜索不同的原型鏈
組合繼承
在通過原型鏈方式實現的繼承中,父類和子類的構造函數相對獨立,如果子類構造函數可以調用父類的構造函數,並且進行相關的初始化,那就比較好了。
這時就想到了JavaScript中的call方法,通過這個方法可以動態的設置this的指向,這樣就可以在子類的構造函數中調用父類的構造函數了。
這樣就有了組合繼承這種方式:
function Person(name, age){ this.name = name; this.age = age; } Person.prototype.getInfo = function(){ console.log(this.name + " is " + this.age + " years old!"); } function Teacher(name, age, staffId){ Person.call(this, name, age); // 通過call方法來調用父類的構造函數進行初始化 this.staffId = staffId; } Teacher.prototype = new Person(); Object.defineProperty(Teacher.prototype, "constructor", { enumerable: false, value: Teacher }); var will = new Teacher("Will", 28, 1000); will.getInfo(); console.log(will.__proto__); // Person {name: undefined, age: undefined} console.log(will.__proto__.constructor); // function Teacher
在這個例子中,在子類構造函數"Teacher"中,直接通過"Person.call(this, name, age);"的方式調用了父類的構造函數,進而設置了"name"和"age"屬性(但這里依舊是覆蓋了父類的"name"和"age"屬性)。
組合式繼承是比較常用的一種繼承方法,其背后的思路是使用原型鏈實現對原型屬性和方法的繼承,而通過借用構造函數來實現對實例屬性的繼承。這樣,既通過在原型上定義方法實現了函數復用,又保證每個實例都有它自己的屬性。
組合式繼承的小問題
雖然組合繼承是 JavaScript 比較常用的繼承模式,不過通過前面組合繼承的代碼可以看到,它也有一些小問題。
首先,子類會調用兩次父類的構造函數:
- 一次是在創建子類型原型的時候
- 另一次是在子類型構造函數內部
子類型最終會包含超類型對象的全部實例屬性,但我們不得不在調用子類型構造函數時重寫這些屬性,從下圖可以看到"will"對象中有兩份"name"和"age"屬性。

后面,我們會看到如何通過"寄生組合式繼承"來解決組合繼承的這個問題。
原型式繼承
在前面兩種方式中,都需要用到對象以及創建對象的構造函數(類型)來實現繼承。
但是在JavaScript中,創建對象完全不需要定義一個構造函數(類型),通過字面量的方式就可以創建一個自定義的對象。
為了實現對象之間的直接繼承,就有了原型式繼承。
這種繼承方式方法並沒有使用嚴格意義上的構造函數,而是直接借助原型基於已有的對象創建新對象,同時還不必創建自定義類型(構造函數)。為了達到這個目的,我們可以借助下面這個函數:
function object(o){ function F(){} F.prototype = o; return new F(); }
在 "object()"函數內部,先創建了一個臨時性的構造函數,然后將傳入的對象作為這個構造函數的原型,最后返回了這個臨時類型的一個新實例。
下面看看使用"object()"函數實現的對象之間的繼承:
var utilsLibA = { add: function(){ console.log("add method from utilsLibA"); }, sub: function(){ console.log("sub method from utilsLibA"); } } var utilsLibB = object(utilsLibA); utilsLibB.add = function(){ console.log("add method from utilsLibB"); } utilsLibB.div = function(){ console.log("div method from utilsLibB"); } utilsLibB.add(); // add method from utilsLibB utilsLibB.sub(); // sub method from utilsLibA utilsLibB.div(); // div method from utilsLibB
通過原型式繼承,基於"utilsLibA"創建了一個"utilsLibB"對象,並且可以正常工作,下面看看對象之間的關系:

通過"object()"函數的幫助,將"utilsLibB"的原型賦值為"utilsLibA",對於這個原型式繼承的例子,對象關系圖如下,"utilsLibB"的"add"方法覆蓋了"utilsLibA"的"add"方法:

Object.create()
ECMAScript 5 通過新增 "Object.create()"方法規范化了原型式繼承。這個方法接收兩個參數:
- 一個用作新對象原型的對象
- 一個為新對象定義額外屬性的對象(可選的)
在傳入一個參數的情況下,"Object.create()"與 上面的"object"函數行為相同。關於更多"Object.create()"的內容,請參考MDN。
繼續上面的例子,這次使用"Object.create()"來創建對象"utilsLibC":
utilsLibC = Object.create(utilsLibA, { sub: { value: function(){ console.log("sub method from utilsLibC"); } }, mult: { value: function(){ console.log("mult method from utilsLibC"); } }, }) utilsLibC.add(); // add method from utilsLibA utilsLibC.sub(); // sub method from utilsLibC utilsLibC.mult(); // mult method from utilsLibC console.log(utilsLibC.__proto__); // Object {add: (), sub: (), __proto__: Object} console.log(utilsLibC.__proto__.constructor); // function Object() { [native code] }
寄生式繼承
寄生式繼承是與原型式繼承緊密相關的一種思路,寄生式繼承的思路與寄生構造函數和工廠模式類似,即創建一個僅用於封裝繼承過程的函數,該函數在內部以某種方式來增強對象,最后再像真地是它做了所有工作一樣返回對象。
以下代碼示范了寄生式繼承模式,其實就是封裝"object()"函數的調用,以及對新的對象進行自定義的一些操作:
function create(o){ var f= object(o); // 通過原型式繼承創建一個新對象 f.run = function () { // 以某種方式來增強這個對象 return this.arr; }; return f; // 返回對象 }
寄生組合式繼承
所謂寄生組合式繼承,即通過借用構造函數來繼承屬性,通過原型鏈的混成形式來繼承方法。
其背后的基本思路是:不必為了指定子類型的原型而調用超類型的構造函數,我們所需要的無非就是父類型原型的一個副本而已。本質上,就是使用寄生式繼承來繼承父類型的原型,然后再將結果指定給子類型的原型。
注意在寄生組合式繼承中使用的“inheritPrototype()”函數。
function object(o) { function F() {} F.prototype = o; return new F(); } function inheritPrototype(subType, superType) { var prototype = object(superType.prototype); // 創建對象 prototype.constructor = subType; // 增強對象,設置constructor屬性 subType.prototype = prototype; // 指定對象 } function Person(name, age){ this.name = name; this.age = age; } Person.prototype.getInfo = function(){ console.log(this.name + " is " + this.age + " years old!"); } function Teacher(name, age, staffId){ Person.call(this, name, age) this.staffId = staffId; } inheritPrototype(Teacher, Person); Teacher.prototype.getId = function(){ console.log(this.name + "'s staff Id is " + this.staffId); } var will = new Teacher("Will", 28, 1000); will.getInfo(); // Will is 28 years old! will.getId(); // Will's staff Id is 1000 var wilber = new Teacher("Wilber", 29, 1001); wilber.getInfo(); // Wilber is 29 years old! wilber.getId(); // Wilber's staff Id is 1001
代碼中有一處地方需要注意,給子類添加"getId"方法的代碼("Teacher.prototype.getId")一定要放在"inheritPrototype()"函數調用之后,因為在“inheritPrototype()”函數中會重寫“Teacher”的原型。
下面繼續查看一下對象"will"的原型和"constructor"屬性。

這個示例中的" inheritPrototype()"函數實現了寄生組合式繼承的最簡單形式。這個函數接收兩個參數:子類型構造函數和父類型構造函數。
在函數內部,第一步是創建超類型原型的一個副本。第二步是為創建的副本添加 "constructor" 屬性,從而彌補因重寫原型而失去的默認的 "constructor" 屬性。最后一步,將新創建的對象(即副本)賦值給子類型的原型。這樣,我們就可以用調用 "inheritPrototype()"函數的語句,去替換前面例子中為子類型原型賦值的語句了("Teacher.prototype = new Person();")。
對於這個寄生組合式繼承的例子,對象關系圖如下:

總結
本文介紹了JavaScirpt中的 幾種常用繼承方式,我們可以通過構造函數實現繼承,也可以直接基於現有的對象來實現繼承。
無論哪種繼承的實現,本質上都是通過JavaScript中的原型特性,結合原型鏈的搜索實現繼承。
與其說"JavaScript是一種面向對象的語言",更恰當的可以說"JavaScript是一種基於對象的語言"。
通過了這些介紹,相信你一定對JavaScript的繼承有了一個比較清楚的認識了。
