js實現繼承的方法以及優缺點

夏天來嘍發表於2019-05-07

整理《javascript高階程式設計》中繼承的方法以及優缺點。

1. 原型鏈

ECMAScript中描述了原型鏈的概念,並將原型鏈作為實現繼承的主要方法。

原型鏈繼承的基本思想是利用原型讓一個引用型別繼承另一個引用型別的屬性和方法。

簡單回顧一下建構函式、原型和例項的關係:每個建構函式都有一個原型物件,原型物件都包含一個指向建構函式的指標,而例項都包含一個指向原型物件的內部指標

那麼,假如我們讓原型物件等於另一個型別的例項,結果會怎麼樣呢?顯然,此時的原型物件將包含一個指向另一個原型的指標,相應地,另一個原型中也包含著一個指向另一個建構函式的指標。假如另一個原型又是另一個型別的例項,那麼上述關係依然成立,如此層層遞進,就構成了例項與原型的鏈條。這就是所謂原型鏈的基本概念。

function SuperType() {
    this.property = true;
}
SuperType.prototype.getSuperValue = function () {
    return this.property;
}
function SubType() {
    this.subProperty = false;
}
SubType.prototype = new SuperType();
SubType.prototype.getSubValue = function (){
    return this.subproperty;
};

var instance = new SubType();
console.log(instance.getSuperValue());      //true
複製程式碼

以上程式碼定義了兩個型別:SuperType和SubType。每個型別分別有一個屬性和一個方法。它們的主要區別是SubType繼承了SuperType,而繼承是通過建立SuperType的例項,並將該例項賦給SubType.prototype實現的。實現的本質是重寫原型物件,代之以一個新型別的例項。換句話說,原來存在於SuperType的例項中的所有屬性和方法,現在也存在於SubType.prototype中了。在確立了繼承關係之後,我們給SubType.prototype新增了一個方法,這樣就在繼承了SuperType的屬性和方法的基礎上又新增了一個新方法

js實現繼承的方法以及優缺點

要注意instance.constructor現在指向的是SuperType,這是因為原來SubType.prototype中的constructor被重寫了的緣故。實際上,不是SubType的原型的constructor屬性被重寫了,而是SubType的原型指向了另一個物件——SuperType的原型,而這個原型物件的constructor屬性指向的是SuperType

別忘記預設的原型 事實上,前面例子中展示的原型鏈還少一環。我們知道,所有引用型別預設都繼承了Object,而這個繼承也是通過原型鏈實現的。大家要記住,所有函式的預設原型都是Object的例項,因此預設原型都會包含一個內部指標,指向Object.prototype。這也正是所有自定義型別都會繼承toString()、valueOf()等預設方法的根本原因

js實現繼承的方法以及優缺點

原型鏈的問題 原型鏈雖然很強大,可以用它來實現繼承,但它也存在一些問題。其中,最主要的問題來自包含引用型別值的原型

function SuperType(){
    this.colors = ["red", "blue", "green"];
}

function SubType(){
}
//繼承了SuperType
SubType.prototype = new SuperType();

var instance1 = new SubType();
instance1.colors.push("black");
console.log(instance1.colors);        //"red,blue,green,black"

var instance2 = new SubType();
console.log(instance2.colors);        //"red,blue,green,black"
複製程式碼

原型鏈的第二個問題是:在建立子型別的例項時,不能向超型別的建構函式中傳遞引數。實際上,應該說是沒有辦法在不影響所有物件例項的情況下,給超型別的建構函式傳遞引數。有鑑於此,再加上前面剛剛討論過的由於原型中包含引用型別值所帶來的問題,實踐中很少會單獨使用原型鏈

2. 借用建構函式

在子型別建構函式的內部呼叫超型別建構函式

function SuperType() {
    this.colors = ['red', 'blue', 'green'];
}
function SubType() {
    SuperType.call(this);
}
var instance1 = new SubType();
instance1.colors.push("black");
console.log(instance1.colors);    //"red,blue,green,black"

var instance2 = new SubType();
console.log(instance2.colors);    //"red,blue,green"
複製程式碼

通過使用call()方法(或apply()方法也可以),我們實際上是在(未來將要)新建立的SubType例項的環境下呼叫了SuperType建構函式。這樣一來,就會在新SubType物件上執行SuperType()函式中定義的所有物件初始化程式碼。結果,SubType的每個例項就都會具有自己的colors屬性的副本了

對於原型鏈而言,借用建構函式有一個很大的優勢,即可以在子型別建構函式中向超型別建構函式傳遞引數

function SuperType(name){
    this.name = name;
}

function SubType(){  
    //繼承了SuperType,同時還傳遞了引數
    SuperType.call(this, "Nicholas");

    //例項屬性
    this.age = 29;
}

var instance = new SubType();
console.log(instance.name);    //"Nicholas";
console.log(instance.age);     //29
複製程式碼

借用建構函式問題: 方法都在建構函式中定義,因此函式複用就無從談起了。而且,在超型別的原型中定義的方法,對子型別而言也是不可見的,結果所有型別都只能使用建構函式模式

3. 組合繼承

組合繼承(combination inheritance),有時候也叫做偽經典繼承,指的是將原型鏈和借用建構函式的技術組合到一塊,從而發揮二者之長的一種繼承模式。其背後的思路是使用原型鏈實現對原型屬性和方法的繼承,而通過借用建構函式來實現對例項屬性的繼承。這樣,既通過在原型上定義方法實現了函式複用,又能夠保證每個例項都有它自己的屬性

function SuperType(name){
    this.name = name;
    this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function(){
    console.log(this.name);
};

function SubType(name, age){  

    //繼承屬性
    SuperType.call(this, name);
    
    this.age = age;
}

//繼承方法
SubType.prototype = new SuperType();
SubType.prototype.sayAge = function(){
    console.log(this.age);
};

var instance1 = new SubType("Nicholas", 29);
instance1.colors.push("black");
console.log(instance1.colors);      //"red,blue,green,black"
instance1.sayName();          //"Nicholas";
instance1.sayAge();           //29

var instance2 = new SubType("Greg", 27);
console.log(instance2.colors);      //"red,blue,green"
instance2.sayName();          //"Greg";
instance2.sayAge();           //27
複製程式碼

組合繼承避免了原型鏈和借用建構函式的缺陷,融合了它們的優點,成為JavaScript中最常用的繼承模式。而且,instanceof和isPrototypeOf也能夠用於識別基於組合繼承建立的物件。

無論什麼情況下,都會呼叫兩次超型別建構函式:一次是在建立子型別原型的時候,另一次是在子型別建構函式內部

4. 原型式繼承

這種方法並沒有使用嚴格意義上的建構函式。藉助原型可以基於已有的物件建立新物件,同時還不必因此建立自定義型別

function object(o){
    function F(){}
    F.prototype = o;
    return new F();
}
var person = {
    name: "Nicholas",
    friends: ["Shelby", "Court", "Van"]
};

var anotherPerson = object(person);
anotherPerson.name = "Greg";
anotherPerson.friends.push("Rob");

var yetAnotherPerson = object(person);
yetAnotherPerson.name = "Linda";
yetAnotherPerson.friends.push("Barbie");

console.log(person.friends);   //"Shelby,Court,Van,Rob,Barbie"
複製程式碼

ECMAScript 5通過新增Object.create()方法規範化了原型式繼承。這個方法接收兩個引數:一個用作新物件原型的物件和(可選的)一個為新物件定義額外屬性的物件。在傳入一個引數的情況下,Object.create()與object()方法的行為相同。

Object.create()方法的第二個引數與Object.defineProperties()方法的第二個引數格式相同:每個屬性都是通過自己的描述符定義的。以這種方式指定的任何屬性都會覆蓋原型物件上的同名屬性

var person = {
    name: "Nicholas",
    friends: ["Shelby", "Court", "Van"]
};

var anotherPerson = Object.create(person, {
    name: {
        value: "Greg"
    }
});

console.log(anotherPerson.name); //"Greg"
複製程式碼

在沒有必要興師動眾地建立建構函式,而只想讓一個物件與另一個物件保持類似的情況下,原型式繼承是完全可以勝任的。不過別忘了,包含引用型別值的屬性始終都會共享相應的值,就像使用原型模式一樣

5. 寄生式繼承

建立一個僅用於封裝繼承過程的函式,該函式在內部以某種方式來增強物件,最後再像真的是它做了所有工作一樣返回物件

function createAnother(original){
    var clone = Object.create(original);    //通過呼叫函式建立一個新物件
    clone.sayHi = function(){           //以某種方式來增強這個物件
        console.log("hi");
    };
    return clone;                    //返回這個物件
}
複製程式碼

在這個例子中,createAnother()函式接收了一個引數,也就是將要作為新物件基礎的物件。然後,把這個物件(original)傳遞給object()函式,將返回的結果賦值給clone。再為clone物件新增一個新方法sayHi(),最後返回clone物件。可以像下面這樣來使用createAnother()函式:

var person = {
    name: "Nicholas",
    friends: ["Shelby", "Court", "Van"]
};

var anotherPerson = createAnother(person);
anotherPerson.sayHi(); //"hi"
複製程式碼

這個例子中的程式碼基於person返回了一個新物件——anotherPerson。新物件不僅具有person的所有屬性和方法,而且還有自己的sayHi()方法

使用寄生式繼承來為物件新增函式,會由於不能做到函式複用而降低效率;這一點與建構函式模式類似

6. 寄生組合式繼承

前面說過,組合繼承是JavaScript最常用的繼承模式;不過,它也有自己的不足。組合繼承最大的問題就是無論什麼情況下,都會呼叫兩次超型別建構函式:一次是在建立子型別原型的時候,另一次是在子型別建構函式內部。沒錯,子型別最終會包含超型別物件的全部例項屬性,但我們不得不在呼叫子型別建構函式時重寫這些屬性。再來看一看下面組合繼承的例子

function SuperType(name){
    this.name = name;
    this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function(){
    console.log(this.name);
};

function SubType(name, age){  
    SuperType.call(this, name);         //第二次呼叫SuperType()

    this.age = age;
}

SubType.prototype = new SuperType();    //第一次呼叫SuperType()
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function(){
    console.log(this.age);
};
複製程式碼

在第一次呼叫SuperType建構函式時,SubType.prototype會得到兩個屬性:name和colors;它們都是SuperType的例項屬性,只不過現在位於SubType的原型中。當呼叫SubType建構函式時,又會呼叫一次SuperType建構函式,這一次又在新物件上建立了例項屬性name和colors,於是,這兩個屬性就遮蔽了原型中的兩個同名屬性

js實現繼承的方法以及優缺點
如上圖所示,有兩組name和colors屬性:一組在例項上,一組在SubType原型中。這就是呼叫兩次SuperType建構函式的結果。好在我們已經找到了解決這個問題方法——寄生組合式繼承。

所謂寄生組合式繼承,即通過借用建構函式來繼承屬性,通過原型鏈的混成形式來繼承方法。

其背後的基本思路是:不必為了指定子型別的原型而呼叫超型別的建構函式,我們所需要的無非就是超型別原型的一個副本而已。本質上,就是使用寄生式繼承來繼承超型別的原型,然後再將結果指定給子型別的原型。寄生組合式繼承的基本模式如下所示

function inheritPrototype(subType, superType){
    var prototype = Object.create(superType.prototype);       //建立物件
    prototype.constructor = subType;                   //增強物件
    subType.prototype = prototype;                     //指定物件
}
複製程式碼

這個示例中的inheritPrototype()函式實現了寄生組合式繼承的最簡單形式。這個函式接收兩個引數:子型別建構函式和超型別建構函式。在函式內部,第一步是建立超型別原型的一個副本。第二步是為建立的副本新增constructor屬性,從而彌補因重寫原型而失去的預設的constructor屬性。最後一步,將新建立的物件(即副本)賦值給子型別的原型。這樣,我們就可以用呼叫inheritPrototype()函式的語句,去替換前面例子中為子型別原型賦值的語句了

function SuperType(name){
    this.name = name;
    this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function(){
    console.log(this.name);
};

function SubType(name, age){  
    SuperType.call(this, name);

    this.age = age;
}

inheritPrototype(SubType, SuperType);

SubType.prototype.sayAge = function(){
    console.log(this.age);
};
複製程式碼

這個例子的高效率體現在它只呼叫了一次SuperType建構函式,並且因此避免了在SubType.prototype上面建立不必要的、多餘的屬性。與此同時,原型鏈還能保持不變;因此,還能夠正常使用instanceof和isPrototypeOf()。開發人員普遍認為寄生組合式繼承是引用型別最理想的繼承正規化。

相關文章